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

jojo1981 / json-ast-builder / 17578687754

09 Sep 2025 09:48AM UTC coverage: 35.019%. Remained the same
17578687754

push

github

jojo1981
Fix: Make compatible with PHP versions: ^8.0|^8.1|^8.2|^8.3|^8.4. Closes #1
https://github.com/jojo1981/json-ast-builder/issues/1

161 of 407 new or added lines in 17 files covered. (39.56%)

290 existing lines in 25 files now uncovered.

374 of 1068 relevant lines covered (35.02%)

3.47 hits per line

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

61.39
/src/Lexer.php
1
<?php
2
/*
3
 * This file is part of the jojo1981/json-ast-builder package
4
 *
5
 * Copyright (c) 2019 Joost Nijhuis <jnijhuis81@gmail.com>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed in the root of the source code
9
 */
10
declare(strict_types=1);
11

12
namespace Jojo1981\JsonAstBuilder;
13

14
use Jojo1981\JsonAstBuilder\Exception\LogicalException;
15
use Jojo1981\JsonAstBuilder\Exception\ParseException;
16
use Jojo1981\JsonAstBuilder\Lexer\LexerInterface;
17
use Jojo1981\JsonAstBuilder\Lexer\Scanner;
18
use Jojo1981\JsonAstBuilder\Lexer\Token;
19
use Jojo1981\JsonAstBuilder\Lexer\TokenType;
20
use UnexpectedValueException;
21
use function array_key_exists;
22
use function ctype_digit;
23
use function ctype_xdigit;
24
use function in_array;
25
use function is_numeric;
26
use function ord;
27
use function strlen;
28
use function strtolower;
29

30
/**
31
 * The lexer is responsible for generating a token stream and perform the lexical analysis and check the syntax.
32
 *
33
 * @package Jojo1981\JsonAstBuilder\Lexer
34
 */
35
final class Lexer implements LexerInterface
36
{
37
    /** @var string */
38
    private const KEYWORD_TRUE = 'true';
39

40
    /** @var string */
41
    private const KEYWORD_FALSE = 'false';
42

43
    /** @var string */
44
    private const KEYWORD_NULL = 'null';
45

46
    /** @var string[] */
47
    private const KEYWORDS = [
48
        self::KEYWORD_TRUE,
49
        self::KEYWORD_FALSE,
50
        self::KEYWORD_NULL
51
    ];
52

53
    /** @var int[] */
54
    private const CHAR_TOKEN_MAP = [
55
        PHP_EOL => TokenType::TOKEN_NEWLINE,
56
        "\t" => TokenType::TOKEN_WHITE_SPACE,
57
        ' ' => TokenType::TOKEN_WHITE_SPACE,
58
        '[' => TokenType::TOKEN_LEFT_SQUARE_BRACKET,
59
        ']' => TokenType::TOKEN_RIGHT_SQUARE_BRACKET,
60
        '{' => TokenType::TOKEN_LEFT_CURLY_BRACKET,
61
        '}' => TokenType::TOKEN_RIGHT_CURLY_BRACKET,
62
        ':' => TokenType::TOKEN_COLON,
63
        ',' => TokenType::TOKEN_COMMA,
64
        self::KEYWORD_TRUE => TokenType::TOKEN_KEYWORD,
65
        self::KEYWORD_FALSE => TokenType::TOKEN_KEYWORD,
66
        self::KEYWORD_NULL => TokenType::TOKEN_KEYWORD
67
    ];
68

69
    /** @var string[] */
70
    public const VALID_ESCAPE_CHARS = ['"', '\\', '/', 'b', 'n', 'r', 't', 'u', 'f'];
71

72
    /** @var Scanner|null */
73
    private ?Scanner $scanner = null;
74

75
    /** @var Token|null */
76
    private ?Token $currentToken = null;
77

78
    /** @var int */
79
    private int $position;
80

81
    /** @var int */
82
    private int $lineNumber;
83

84
    /** @var int */
85
    private int $linePosition;
86

87
    /**
88
     * @param string $input
89
     * @return void
90
     * @throws LogicalException
91
     * @throws ParseException
92
     */
93
    public function setInput(string $input): void
94
    {
95
        $this->scanner = new Scanner($input);
10✔
96
        if ($this->scanner->isEmpty()) {
10✔
UNCOV
97
            throw ParseException::invalidInput();
×
98
        }
99
        $this->reset();
10✔
100
    }
101

102
    /**
103
     * @return void
104
     * @throws LogicalException
105
     */
106
    public function reset(): void
107
    {
108
        $this->assertScanner();
10✔
109
        $this->scanner->rewind();
10✔
110
        $this->currentToken = null;
10✔
111
    }
112

113
    /**
114
     * @return void
115
     * @throws LogicalException
116
     */
117
    private function assertScanner(): void
118
    {
119
        if (null === $this->scanner) {
10✔
NEW
120
            throw LogicalException::noInputGiven();
×
121
        }
122
    }
123

124
    /**
125
     * @return Token
126
     * @throws ParseException
127
     * @throws UnexpectedValueException
128
     * @throws LogicalException
129
     */
130
    public function getCurrent(): Token
131
    {
132
        if (null === $this->currentToken) {
5✔
133
            return $this->getNext();
5✔
134
        }
135

136
        return $this->currentToken;
5✔
137
    }
138

139
    /**
140
     * @return Token
141
     * @throws ParseException
142
     * @throws UnexpectedValueException
143
     * @throws LogicalException
144
     */
145
    public function getNext(): Token
146
    {
147
        $this->assertScanner();
10✔
148
        $this->assertEndOfFile();
10✔
149

150
        return $this->currentToken = $this->scan();
10✔
151
    }
152

153
    /**
154
     * @return void
155
     * @throws LogicalException
156
     */
157
    private function assertEndOfFile(): void
158
    {
159
        if (null !== $this->currentToken && TokenType::TOKEN_EOF === $this->currentToken->getType()) {
10✔
NEW
160
            throw LogicalException::alreadyAtTheEndOfFile();
×
161
        }
162
    }
163

164
    /**
165
     * @return Token
166
     * @throws UnexpectedValueException
167
     * @throws ParseException
168
     */
169
    private function scan(): Token
170
    {
171
        $this->lockPosition();
10✔
172

173
        if ($this->scanner->hasEndReached()) {
10✔
174
            return $this->createTokenType(TokenType::TOKEN_EOF);
10✔
175
        }
176

177
        $current = $this->scanner->look();
10✔
178
        if (in_array($current, [' ', "\t"], true)) {
10✔
179
            $buffer = '';
10✔
180
            do {
181
                $buffer .= $this->scanner->read();
10✔
182
                $current = $this->scanner->look();
10✔
183
                // @phpstan-ignore-next-line
184
            } while (!$this->scanner->hasEndReached() && in_array($current, [' ', "\t"], true));
10✔
185
            return $this->createTokenType(TokenType::TOKEN_WHITE_SPACE, $buffer);
10✔
186
        }
187

188
        switch (true) {
189
            case '"' === $current:
10✔
190
            {
10✔
191
                return $this->scanString();
10✔
192
            }
10✔
193
            case array_key_exists($current, self::CHAR_TOKEN_MAP):
10✔
194
            {
10✔
195
                return $this->createTokenForLexeme($this->scanner->read());
10✔
196
            }
10✔
197
            case '-' === $current || is_numeric($current):
10✔
198
            {
10✔
199
                return $this->readNumber();
10✔
200
            }
10✔
201
        }
202

203
        if (null !== $token = $this->scanKeyword()) {
10✔
204
            return $token;
10✔
205
        }
206

UNCOV
207
        throw ParseException::unknownCharacterFound(
×
UNCOV
208
            $this->scanner->look(),
×
UNCOV
209
            $this->scanner->getPosition(),
×
210
            $this->scanner->getLineNumber() + 1,
×
UNCOV
211
            $this->scanner->getLinePosition() + 1
×
UNCOV
212
        );
×
213
    }
214

215
    /**
216
     * @return void
217
     */
218
    private function lockPosition(): void
219
    {
220
        $this->position = $this->scanner->getPosition();
10✔
221
        $this->lineNumber = $this->scanner->getLineNumber() + 1;
10✔
222
        $this->linePosition = $this->scanner->getLinePosition() + 1;
10✔
223
    }
224

225
    /**
226
     * @param int $type
227
     * @param string|null $lexeme
228
     * @return Token
229
     * @throws UnexpectedValueException
230
     */
231
    private function createTokenType(int $type, ?string $lexeme = null): Token
232
    {
233
        return new Token(
10✔
234
            $type,
10✔
235
            TokenType::getNameForTokenType($type),
10✔
236
            $this->position,
10✔
237
            $this->lineNumber,
10✔
238
            $this->linePosition,
10✔
239
            $lexeme
10✔
240
        );
10✔
241
    }
242

243
    /**
244
     * @return Token
245
     * @throws UnexpectedValueException
246
     * @throws ParseException
247
     */
248
    private function scanString(): Token
249
    {
250
        $this->lockPosition();
10✔
251

252
        $buffer = $this->scanner->read();
10✔
253
        while (!$this->scanner->hasEndReached()) {
10✔
254
            $current = $this->scanner->look();
10✔
255
            if (ord($current) < 32) {
10✔
UNCOV
256
                if (PHP_EOL === $current) {
×
UNCOV
257
                    throw ParseException::unterminatedStringFound(
×
UNCOV
258
                        $this->scanner->getPosition(),
×
259
                        $this->scanner->getLineNumber() + 1,
×
260
                        $this->scanner->getLinePosition() + 1
×
261
                    );
×
262
                }
263
                throw ParseException::illegalCharacterInStringFound(
×
264
                    $this->scanner->getPosition(),
×
UNCOV
265
                    $this->scanner->getLineNumber() + 1,
×
NEW
266
                    $this->scanner->getLinePosition() + 1,
×
NEW
267
                    $current
×
UNCOV
268
                );
×
269
            }
270
            switch ($current) {
271
                case '"':
10✔
272
                {
10✔
273
                    return $this->createTokenType(TokenType::TOKEN_STRING, $buffer . $this->scanner->read());
10✔
274
                }
10✔
275
                case '\\':
10✔
276
                {
10✔
277
                    $buffer .= $this->scanEscape();
10✔
278
                    break;
10✔
279
                }
10✔
280
                default:
281
                    $buffer .= $this->scanner->read();
10✔
282
            }
283
        }
284

UNCOV
285
        throw ParseException::unexpectedEndOfFileFound(
×
UNCOV
286
            $this->scanner->getPosition(),
×
UNCOV
287
            $this->scanner->getLineNumber() + 1,
×
UNCOV
288
            $this->scanner->getLinePosition() + 1
×
UNCOV
289
        );
×
290
    }
291

292
    /**
293
     * @return string
294
     * @throws UnexpectedValueException
295
     * @throws ParseException
296
     */
297
    private function scanEscape(): string
298
    {
299
        $buffer = $this->scanner->read();
10✔
300
        $this->assertEndIsNotReachedYet();
10✔
301

302
        $current = $this->scanner->look();
10✔
303
        if (!in_array($current, self::VALID_ESCAPE_CHARS, true)) {
10✔
UNCOV
304
            throw ParseException::illegalEscapeUsed(
×
UNCOV
305
                $this->scanner->getPosition(),
×
UNCOV
306
                $this->scanner->getLineNumber() + 1,
×
UNCOV
307
                $this->scanner->getLinePosition() + 1
×
UNCOV
308
            );
×
309
        }
310

311
        $buffer .= $this->scanner->read();
10✔
312
        if ('u' === $current) {
10✔
313
            for ($i = 0; $i < 4; $i++) {
10✔
314
                $this->assertEndIsNotReachedYet();
10✔
315
                $current = $this->scanner->look();
10✔
316
                if (!ctype_xdigit($current)) {
10✔
317
                    throw ParseException::illegalEscapeUsed(
×
318
                        $this->scanner->getPosition(),
×
319
                        $this->scanner->getLineNumber() + 1,
×
320
                        $this->scanner->getLinePosition() + 1
×
321
                    );
×
322
                }
323
                $buffer .= $this->scanner->read();
10✔
324
            }
325
        }
326

327
        return $buffer;
10✔
328
    }
329

330
    /**
331
     * @return void
332
     * @throws ParseException
333
     */
334
    private function assertEndIsNotReachedYet(): void
335
    {
336
        if ($this->scanner->hasEndReached()) {
10✔
NEW
337
            throw ParseException::unexpectedEndOfFileFound(
×
NEW
338
                $this->scanner->getPosition(),
×
NEW
339
                $this->scanner->getLineNumber() + 1,
×
NEW
340
                $this->scanner->getLinePosition() + 1
×
NEW
341
            );
×
342
        }
343
    }
344

345
    /**
346
     * @param string $lexeme
347
     * @return Token
348
     * @throws UnexpectedValueException
349
     */
350
    private function createTokenForLexeme(string $lexeme): Token
351
    {
352
        return $this->createTokenType(self::CHAR_TOKEN_MAP[$lexeme], $lexeme);
10✔
353
    }
354

355
    /**
356
     * @return Token
357
     * @throws UnexpectedValueException
358
     * @throws ParseException
359
     */
360
    private function readNumber(): Token
361
    {
362
        $this->lockPosition();
10✔
363

364
        $buffer = '';
10✔
365
        if ('-' === $this->scanner->look()) {
10✔
366
            $buffer .= $this->scanner->read();
10✔
367
            $this->assertEndIsNotReachedYet();
10✔
368
            if (!ctype_digit($this->scanner->look())) {
10✔
UNCOV
369
                throw ParseException::illegalNegativeSign(
×
UNCOV
370
                    $this->scanner->getPosition(),
×
UNCOV
371
                    $this->scanner->getLineNumber() + 1,
×
UNCOV
372
                    $this->scanner->getLinePosition() + 1
×
UNCOV
373
                );
×
374
            }
375
        }
376

377
        if ('0' === $this->scanner->look()) {
10✔
378
            $buffer .= $this->scanner->read();
10✔
379
            $this->assertEndIsNotReachedYet();
10✔
380
            $current = $this->scanner->look();
10✔
381
            if (null !== $current && ctype_digit($current)) {
10✔
UNCOV
382
                throw ParseException::illegalOctalLiteral(
×
383
                    $this->scanner->getPosition(),
×
384
                    $this->scanner->getLineNumber() + 1,
×
385
                    $this->scanner->getLinePosition() + 1,
×
386
                    $current
×
387
                );
×
388
            }
389
        }
390

391
        while (!$this->scanner->hasEndReached()) {
10✔
392
            $char = $this->scanner->look();
10✔
393
            if (ctype_digit($char)) {
10✔
394
                $buffer .= $this->scanner->read();
10✔
395
            } elseif ('.' === $char) {
10✔
396
                return $this->createTokenType(TokenType::TOKEN_NUMBER, $buffer . $this->scanFraction());
10✔
397
            } elseif ('e' === $char || 'E' === $char) {
10✔
UNCOV
398
                return $this->createTokenType(TokenType::TOKEN_NUMBER, $buffer . $this->scanExponent());
×
399
            } else {
400
                return $this->createTokenType(TokenType::TOKEN_INT, $buffer);
10✔
401
            }
402
        }
403

UNCOV
404
        throw ParseException::unexpectedEndOfFileFound(
×
UNCOV
405
            $this->scanner->getPosition(),
×
UNCOV
406
            $this->scanner->getLineNumber() + 1,
×
UNCOV
407
            $this->scanner->getLinePosition() + 1
×
UNCOV
408
        );
×
409
    }
410

411
    /**
412
     * @return string
413
     * @throws UnexpectedValueException
414
     * @throws ParseException
415
     */
416
    private function scanFraction(): string
417
    {
418
        $buffer = $this->scanner->read(); // read '.'
10✔
419
        $this->assertEndIsNotReachedYet();
10✔
420
        if (!ctype_digit($this->scanner->look())) {
10✔
NEW
421
            throw ParseException::illegalTrailingDecimal(
×
UNCOV
422
                $this->scanner->getPosition(),
×
UNCOV
423
                $this->scanner->getLineNumber() + 1,
×
UNCOV
424
                $this->scanner->getLinePosition() + 1
×
UNCOV
425
            );
×
426
        }
427

428
        while (!$this->scanner->hasEndReached()) {
10✔
429
            $char = $this->scanner->look();
10✔
430
            if (ctype_digit($char)) {
10✔
431
                $buffer .= $this->scanner->read();
10✔
432
            } elseif ('e' === $char || 'E' === $char) {
10✔
433
                return $buffer . $this->scanExponent();
10✔
434
            } else {
435
                return $buffer;
10✔
436
            }
437
        }
438

UNCOV
439
        return $buffer;
×
440
    }
441

442
    /**
443
     * @return string
444
     * @throws UnexpectedValueException
445
     * @throws ParseException
446
     */
447
    private function scanExponent(): string
448
    {
449
        $buffer = $this->scanner->read(); // read 'e' or 'E'
10✔
450
        $this->assertEndIsNotReachedYet();
10✔
451
        $char = $this->scanner->look();
10✔
452
        // Handle optional sign
453
        if ($char === '+' || $char === '-') {
10✔
454
            $buffer .= $this->scanner->read();
10✔
455
            $this->assertEndIsNotReachedYet();
10✔
456
            $char = $this->scanner->look();
10✔
457
        }
458
        // Must have at least one digit after exponent (and optional sign)
459
        if (!ctype_digit($char)) {
10✔
NEW
460
            throw ParseException::illegalEmptyExponent(
×
461
                $this->scanner->getPosition(),
×
462
                $this->scanner->getLineNumber() + 1,
×
UNCOV
463
                $this->scanner->getLinePosition() + 1
×
UNCOV
464
            );
×
465
        }
466
        while (!$this->scanner->hasEndReached()) {
10✔
467
            if (ctype_digit($this->scanner->look())) {
10✔
468
                $buffer .= $this->scanner->read();
10✔
469
            } else {
470
                return $buffer;
10✔
471
            }
472
        }
473

UNCOV
474
        return $buffer;
×
475
    }
476

477
    /**
478
     * @return Token|null
479
     * @throws UnexpectedValueException
480
     * @throws ParseException
481
     */
482
    private function scanKeyword(): ?Token
483
    {
484
        foreach (self::KEYWORDS as $keyword) {
10✔
485
            $current = $this->scanner->look(strlen($keyword));
10✔
486
            if (array_key_exists($current, self::CHAR_TOKEN_MAP)) {
10✔
487
                return $this->createTokenForLexeme($this->scanner->read(strlen($keyword)));
10✔
488
            }
489
            if (strtolower($current) === $keyword) {
10✔
NEW
490
                throw ParseException::invalidKeywordFound(
×
NEW
491
                    $current,
×
NEW
492
                    $this->scanner->getPosition(),
×
NEW
493
                    $this->scanner->getLineNumber() + 1,
×
NEW
494
                    $this->scanner->getLinePosition() + 1,
×
NEW
495
                    $keyword
×
NEW
496
                );
×
497
            }
498
        }
499

NEW
500
        return null;
×
501
    }
502
}
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