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

jojo1981 / json-ast-builder / 17555152211

08 Sep 2025 03:02PM UTC coverage: 17.664%. First build
17555152211

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

89 of 408 new or added lines in 17 files covered. (21.81%)

189 of 1070 relevant lines covered (17.66%)

1.71 hits per line

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

58.67
/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);
5✔
96
        if ($this->scanner->isEmpty()) {
5✔
97
            throw ParseException::invalidInput();
×
98
        }
99
        $this->reset();
5✔
100
    }
101

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

113
    /**
114
     * @return void
115
     * @throws LogicalException
116
     */
117
    private function assertScanner(): void
118
    {
119
        if (null === $this->scanner) {
5✔
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) {
×
133
            return $this->getNext();
×
134
        }
135

136
        return $this->currentToken;
×
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();
5✔
148
        $this->assertEndOfFile();
5✔
149

150
        return $this->currentToken = $this->scan();
5✔
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()) {
5✔
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();
5✔
172

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

177
        $current = $this->scanner->look();
5✔
178
        switch (true) {
179
            case '"' === $current:
5✔
180
            {
5✔
181
                return $this->scanString();
5✔
182
            }
5✔
183
            case array_key_exists($current, self::CHAR_TOKEN_MAP):
5✔
184
            {
5✔
185
                return $this->createTokenForLexeme($this->scanner->read());
5✔
186
            }
5✔
187
            case '-' === $current || is_numeric($current):
5✔
188
            {
5✔
189
                return $this->readNumber();
5✔
190
            }
5✔
191
        }
192

193
        if (null !== $token = $this->scanKeyword()) {
5✔
194
            return $token;
5✔
195
        }
196

197
        throw ParseException::unknownCharacterFound(
×
198
            $this->scanner->look(),
×
199
            $this->scanner->getPosition(),
×
200
            $this->scanner->getLineNumber() + 1,
×
201
            $this->scanner->getLinePosition() + 1
×
202
        );
×
203
    }
204

205
    /**
206
     * @return void
207
     */
208
    private function lockPosition(): void
209
    {
210
        $this->position = $this->scanner->getPosition();
5✔
211
        $this->lineNumber = $this->scanner->getLineNumber() + 1;
5✔
212
        $this->linePosition = $this->scanner->getLinePosition() + 1;
5✔
213
    }
214

215
    /**
216
     * @param int $type
217
     * @param string|null $lexeme
218
     * @return Token
219
     * @throws UnexpectedValueException
220
     */
221
    private function createTokenType(int $type, ?string $lexeme = null): Token
222
    {
223
        return new Token(
5✔
224
            $type,
5✔
225
            TokenType::getNameForTokenType($type),
5✔
226
            $this->position,
5✔
227
            $this->lineNumber,
5✔
228
            $this->linePosition,
5✔
229
            $lexeme
5✔
230
        );
5✔
231
    }
232

233
    /**
234
     * @return Token
235
     * @throws UnexpectedValueException
236
     * @throws ParseException
237
     */
238
    private function scanString(): Token
239
    {
240
        $this->lockPosition();
5✔
241

242
        $buffer = $this->scanner->read();
5✔
243
        while (!$this->scanner->hasEndReached()) {
5✔
244
            $current = $this->scanner->look();
5✔
245
            if (ord($current) < 32) {
5✔
246
                if (PHP_EOL === $current) {
×
247
                    throw ParseException::unterminatedStringFound(
×
248
                        $this->scanner->getPosition(),
×
249
                        $this->scanner->getLineNumber() + 1,
×
250
                        $this->scanner->getLinePosition() + 1
×
251
                    );
×
252
                }
253
                throw ParseException::illegalCharacterInStringFound(
×
254
                    $this->scanner->getPosition(),
×
255
                    $this->scanner->getLineNumber() + 1,
×
NEW
256
                    $this->scanner->getLinePosition() + 1,
×
NEW
257
                    $current
×
258
                );
×
259
            }
260
            switch ($current) {
261
                case '"':
5✔
262
                {
5✔
263
                    return $this->createTokenType(TokenType::TOKEN_STRING, $buffer . $this->scanner->read());
5✔
264
                }
5✔
265
                case '\\':
5✔
266
                {
5✔
267
                    $buffer .= $this->scanEscape();
5✔
268
                    break;
5✔
269
                }
5✔
270
                default:
271
                    $buffer .= $this->scanner->read();
5✔
272
            }
273
        }
274

275
        throw ParseException::unexpectedEndOfFileFound(
×
276
            $this->scanner->getPosition(),
×
277
            $this->scanner->getLineNumber() + 1,
×
278
            $this->scanner->getLinePosition() + 1
×
279
        );
×
280
    }
281

282
    /**
283
     * @return string
284
     * @throws UnexpectedValueException
285
     * @throws ParseException
286
     */
287
    private function scanEscape(): string
288
    {
289
        $buffer = $this->scanner->read();
5✔
290
        $this->assertEndIsNotReachedYet();
5✔
291

292
        $current = $this->scanner->look();
5✔
293
        if (!in_array($current, self::VALID_ESCAPE_CHARS, true)) {
5✔
294
            throw ParseException::illegalEscapeUsed(
×
295
                $this->scanner->getPosition(),
×
296
                $this->scanner->getLineNumber() + 1,
×
297
                $this->scanner->getLinePosition() + 1
×
298
            );
×
299
        }
300

301
        $buffer .= $this->scanner->read();
5✔
302
        if ('u' === $current) {
5✔
303
            for ($i = 0; $i < 4; $i++) {
5✔
304
                $this->assertEndIsNotReachedYet();
5✔
305
                $current = $this->scanner->look();
5✔
306
                if (!ctype_xdigit($current)) {
5✔
307
                    throw ParseException::illegalEscapeUsed(
×
308
                        $this->scanner->getPosition(),
×
309
                        $this->scanner->getLineNumber() + 1,
×
310
                        $this->scanner->getLinePosition() + 1
×
311
                    );
×
312
                }
313
                $buffer .= $this->scanner->read();
5✔
314
            }
315
        }
316

317
        return $buffer;
5✔
318
    }
319

320
    /**
321
     * @return void
322
     * @throws ParseException
323
     */
324
    private function assertEndIsNotReachedYet(): void
325
    {
326
        if ($this->scanner->hasEndReached()) {
5✔
NEW
327
            throw ParseException::unexpectedEndOfFileFound(
×
NEW
328
                $this->scanner->getPosition(),
×
NEW
329
                $this->scanner->getLineNumber() + 1,
×
NEW
330
                $this->scanner->getLinePosition() + 1
×
NEW
331
            );
×
332
        }
333
    }
334

335
    /**
336
     * @param string $lexeme
337
     * @return Token
338
     * @throws UnexpectedValueException
339
     */
340
    private function createTokenForLexeme(string $lexeme): Token
341
    {
342
        return $this->createTokenType(self::CHAR_TOKEN_MAP[$lexeme], $lexeme);
5✔
343
    }
344

345
    /**
346
     * @return Token
347
     * @throws UnexpectedValueException
348
     * @throws ParseException
349
     */
350
    private function readNumber(): Token
351
    {
352
        $this->lockPosition();
5✔
353

354
        $buffer = '';
5✔
355
        if ('-' === $this->scanner->look()) {
5✔
356
            $buffer .= $this->scanner->read();
5✔
357
            $this->assertEndIsNotReachedYet();
5✔
358
            if (!ctype_digit($this->scanner->look())) {
5✔
359
                throw ParseException::illegalNegativeSign(
×
360
                    $this->scanner->getPosition(),
×
361
                    $this->scanner->getLineNumber() + 1,
×
362
                    $this->scanner->getLinePosition() + 1
×
363
                );
×
364
            }
365
        }
366

367
        if ('0' === $this->scanner->look()) {
5✔
368
            $buffer .= $this->scanner->read();
5✔
369
            $this->assertEndIsNotReachedYet();
5✔
370
            $current = $this->scanner->look();
5✔
371
            if (null !== $current && ctype_digit($current)) {
5✔
372
                throw ParseException::illegalOctalLiteral(
×
373
                    $this->scanner->getPosition(),
×
374
                    $this->scanner->getLineNumber() + 1,
×
375
                    $this->scanner->getLinePosition() + 1,
×
376
                    $current
×
377
                );
×
378
            }
379
        }
380

381
        while (!$this->scanner->hasEndReached()) {
5✔
382
            $char = $this->scanner->look();
5✔
383
            if (ctype_digit($char)) {
5✔
384
                $buffer .= $this->scanner->read();
5✔
385
            } elseif ('.' === $char) {
5✔
386
                return $this->createTokenType(TokenType::TOKEN_NUMBER, $buffer . $this->scanFraction());
5✔
387
            } elseif ('e' === $char || 'E' === $char) {
5✔
388
                return $this->createTokenType(TokenType::TOKEN_NUMBER, $buffer . $this->scanExponent());
×
389
            } else {
390
                return $this->createTokenType(TokenType::TOKEN_INT, $buffer);
5✔
391
            }
392
        }
393

394
        throw ParseException::unexpectedEndOfFileFound(
×
395
            $this->scanner->getPosition(),
×
396
            $this->scanner->getLineNumber() + 1,
×
397
            $this->scanner->getLinePosition() + 1
×
398
        );
×
399
    }
400

401
    /**
402
     * @return string
403
     * @throws UnexpectedValueException
404
     * @throws ParseException
405
     */
406
    private function scanFraction(): string
407
    {
408
        $buffer = $this->scanner->read(); // read '.'
5✔
409
        $this->assertEndIsNotReachedYet();
5✔
410
        if (!ctype_digit($this->scanner->look())) {
5✔
NEW
411
            throw ParseException::illegalTrailingDecimal(
×
412
                $this->scanner->getPosition(),
×
413
                $this->scanner->getLineNumber() + 1,
×
414
                $this->scanner->getLinePosition() + 1
×
415
            );
×
416
        }
417

418
        while (!$this->scanner->hasEndReached()) {
5✔
419
            $char = $this->scanner->look();
5✔
420
            if (ctype_digit($char)) {
5✔
421
                $buffer .= $this->scanner->read();
5✔
422
            } elseif ('e' === $char || 'E' === $char) {
5✔
423
                return $buffer . $this->scanExponent();
5✔
424
            } else {
425
                return $buffer;
5✔
426
            }
427
        }
428

429
        return $buffer;
×
430
    }
431

432
    /**
433
     * @return string
434
     * @throws UnexpectedValueException
435
     * @throws ParseException
436
     */
437
    private function scanExponent(): string
438
    {
439
        $buffer = $this->scanner->read(); // read 'e' or 'E'
5✔
440
        $this->assertEndIsNotReachedYet();
5✔
441
        $char = $this->scanner->look();
5✔
442
        // Handle optional sign
443
        if ($char === '+' || $char === '-') {
5✔
444
            $buffer .= $this->scanner->read();
5✔
445
            $this->assertEndIsNotReachedYet();
5✔
446
            $char = $this->scanner->look();
5✔
447
        }
448
        // Must have at least one digit after exponent (and optional sign)
449
        if (!ctype_digit($char)) {
5✔
NEW
450
            throw ParseException::illegalEmptyExponent(
×
451
                $this->scanner->getPosition(),
×
452
                $this->scanner->getLineNumber() + 1,
×
453
                $this->scanner->getLinePosition() + 1
×
454
            );
×
455
        }
456
        while (!$this->scanner->hasEndReached()) {
5✔
457
            if (ctype_digit($this->scanner->look())) {
5✔
458
                $buffer .= $this->scanner->read();
5✔
459
            } else {
460
                return $buffer;
5✔
461
            }
462
        }
463

464
        return $buffer;
×
465
    }
466

467
    /**
468
     * @return Token|null
469
     * @throws UnexpectedValueException
470
     * @throws ParseException
471
     */
472
    private function scanKeyword(): ?Token
473
    {
474
        foreach (self::KEYWORDS as $keyword) {
5✔
475
            $current = $this->scanner->look(strlen($keyword));
5✔
476
            if (array_key_exists($current, self::CHAR_TOKEN_MAP)) {
5✔
477
                return $this->createTokenForLexeme($this->scanner->read(strlen($keyword)));
5✔
478
            }
479
            if (strtolower($current) === $keyword) {
5✔
NEW
480
                throw ParseException::invalidKeywordFound(
×
NEW
481
                    $current,
×
NEW
482
                    $this->scanner->getPosition(),
×
NEW
483
                    $this->scanner->getLineNumber() + 1,
×
NEW
484
                    $this->scanner->getLinePosition() + 1,
×
NEW
485
                    $keyword
×
NEW
486
                );
×
487
            }
488
        }
489

NEW
490
        return null;
×
491
    }
492
}
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