• 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

91.72
/src/Parser.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\Ast\ArrayNode;
15
use Jojo1981\JsonAstBuilder\Ast\BooleanNode;
16
use Jojo1981\JsonAstBuilder\Ast\ElementNode;
17
use Jojo1981\JsonAstBuilder\Ast\IntegerNode;
18
use Jojo1981\JsonAstBuilder\Ast\JsonNode;
19
use Jojo1981\JsonAstBuilder\Ast\KeyNode;
20
use Jojo1981\JsonAstBuilder\Ast\MemberNode;
21
use Jojo1981\JsonAstBuilder\Ast\NullNode;
22
use Jojo1981\JsonAstBuilder\Ast\NumberNode;
23
use Jojo1981\JsonAstBuilder\Ast\ObjectNode;
24
use Jojo1981\JsonAstBuilder\Ast\StringNode;
25
use Jojo1981\JsonAstBuilder\Ast\ValueNode;
26
use Jojo1981\JsonAstBuilder\Exception\ParseException;
27
use Jojo1981\JsonAstBuilder\Lexer\LexerInterface;
28
use Jojo1981\JsonAstBuilder\Lexer\Token;
29
use Jojo1981\JsonAstBuilder\Lexer\TokenType;
30
use UnexpectedValueException;
31
use function chr;
32
use function hexdec;
33
use function in_array;
34
use function preg_match;
35
use function strlen;
36
use function substr;
37

38
/**
39
 * The parser is responsible for generating an AST from the tokens it will get from the lexer and performs the
40
 * semantic analysis.
41
 *
42
 * @package Jojo1981\JsonAstBuilder
43
 */
44
final class Parser
45
{
46
    /** @var int[] */
47
    private const WHITE_SPACE_TOKENS = [
48
        TokenType::TOKEN_WHITE_SPACE,
49
        TokenType::TOKEN_NEWLINE
50
    ];
51

52
    /** @var LexerInterface */
53
    private LexerInterface $lexer;
54

55
    /**
56
     * @param LexerInterface $lexer
57
     */
58
    public function __construct(LexerInterface $lexer)
59
    {
60
        $this->lexer = $lexer;
5✔
61
    }
62

63
    /**
64
     * @param string $input
65
     * @return void
66
     * @throws ParseException
67
     */
68
    public function setInput(string $input): void
69
    {
70
        $this->lexer->setInput($input);
5✔
71
    }
72

73
    /**
74
     * @return JsonNode
75
     * @throws UnexpectedValueException
76
     * @throws ParseException
77
     */
78
    public function parse(): JsonNode
79
    {
80
        $this->lexer->reset();
5✔
81

82
        return $this->json();
5✔
83
    }
84

85
    /**
86
     * @return JsonNode
87
     * @throws UnexpectedValueException
88
     * @throws ParseException
89
     */
90
    private function json(): JsonNode
91
    {
92
        $jsonNode = new JsonNode($this->element());
5✔
93
        $this->eatToken(TokenType::TOKEN_EOF);
5✔
94

95
        return $jsonNode;
5✔
96
    }
97

98
    /**
99
     * @return ElementNode
100
     * @throws UnexpectedValueException
101
     * @throws ParseException
102
     */
103
    private function element(): ElementNode
104
    {
105
        $this->eatWhiteSpace();
5✔
106
        $valueNode = $this->value();
5✔
107
        $this->eatWhiteSpace();
5✔
108

109
        return new ElementNode($valueNode);
5✔
110
    }
111

112
    /**
113
     * @return void
114
     * @throws UnexpectedValueException
115
     * @throws ParseException
116
     */
117
    private function eatWhiteSpace(): void
118
    {
119
        while (in_array($this->lexer->getCurrent()->getType(), self::WHITE_SPACE_TOKENS, true)) {
5✔
120
            $this->lexer->getNext();
5✔
121
        }
122
    }
123

124
    /**
125
     * @return ValueNode
126
     * @throws UnexpectedValueException
127
     * @throws ParseException
128
     */
129
    private function value(): ValueNode
130
    {
131
        $expectedTokens = [
5✔
132
            TokenType::getLiteral(TokenType::TOKEN_LEFT_CURLY_BRACKET),
5✔
133
            TokenType::getLiteral(TokenType::TOKEN_LEFT_SQUARE_BRACKET),
5✔
134
            TokenType::getLiteral(TokenType::TOKEN_STRING),
5✔
135
            TokenType::getLiteral(TokenType::TOKEN_NUMBER),
5✔
136
            TokenType::getLiteral(TokenType::TOKEN_INT),
5✔
137
            TokenType::getLiteral(TokenType::TOKEN_KEYWORD)
5✔
138
        ];
5✔
139

140
        $currentToken = $this->lexer->getCurrent();
5✔
141
        switch ($currentToken->getType()) {
5✔
142
            case (TokenType::TOKEN_EOF):
143
            {
UNCOV
144
                throw ParseException::unexpectedEndOfFile($this->lexer->getCurrent(), $expectedTokens);
×
145
            }
146
            case (TokenType::TOKEN_LEFT_CURLY_BRACKET):
147
            {
5✔
148
                $typeNode = $this->object();
5✔
149
                break;
5✔
150
            }
5✔
151
            case (TokenType::TOKEN_LEFT_SQUARE_BRACKET):
152
            {
5✔
153
                $typeNode = $this->array();
5✔
154
                break;
5✔
155
            }
5✔
156
            case (TokenType::TOKEN_STRING):
157
            {
5✔
158
                $typeNode = $this->string();
5✔
159
                break;
5✔
160
            }
5✔
161
            case (TokenType::TOKEN_NUMBER):
162
            {
5✔
163
                $typeNode = new NumberNode((float) $currentToken->getLexeme());
5✔
164
                $typeNode->setToken($currentToken);
5✔
165
                $this->eatToken(TokenType::TOKEN_NUMBER);
5✔
166
                break;
5✔
167
            }
5✔
168
            case (TokenType::TOKEN_INT):
169
            {
5✔
170
                $typeNode = new IntegerNode((int) $currentToken->getLexeme());
5✔
171
                $typeNode->setToken($currentToken);
5✔
172
                $this->eatToken(TokenType::TOKEN_INT);
5✔
173
                break;
5✔
174
            }
5✔
175
            case (TokenType::TOKEN_KEYWORD && in_array($currentToken->getLexeme(), ['true', 'false'], true)):
5✔
176
            {
5✔
177
                $typeNode = new BooleanNode('true' === $currentToken->getLexeme());
5✔
178
                $typeNode->setToken($currentToken);
5✔
179
                $this->eatToken(TokenType::TOKEN_KEYWORD);
5✔
180
                break;
5✔
181
            }
5✔
182
            case (TokenType::TOKEN_KEYWORD && 'null' === $currentToken->getLexeme()):
5✔
183
            {
5✔
184
                $typeNode = new NullNode();
5✔
185
                $typeNode->setToken($currentToken);
5✔
186
                $this->eatToken(TokenType::TOKEN_KEYWORD);
5✔
187
                break;
5✔
188
            }
5✔
189
            default:
UNCOV
190
                throw ParseException::unexpectedToken($currentToken, $expectedTokens);
×
191
        }
192

193
        return new ValueNode($typeNode);
5✔
194
    }
195

196
    /**
197
     * @return ObjectNode
198
     * @throws UnexpectedValueException
199
     * @throws ParseException
200
     */
201
    private function object(): ObjectNode
202
    {
203
        $currentToken = $this->lexer->getCurrent();
5✔
204
        $this->eatToken(TokenType::TOKEN_LEFT_CURLY_BRACKET);
5✔
205
        $this->eatWhiteSpace();
5✔
206
        $members = $this->members();
5✔
207
        $this->eatWhiteSpace();
5✔
208
        $this->eatToken(TokenType::TOKEN_RIGHT_CURLY_BRACKET);
5✔
209

210
        $objectNode = new ObjectNode($members);
5✔
211
        $objectNode->setToken($currentToken);
5✔
212

213
        return $objectNode;
5✔
214
    }
215

216
    /**
217
     * @param int $tokenType
218
     * @return void
219
     * @throws UnexpectedValueException
220
     * @throws ParseException
221
     */
222
    private function eatToken(int $tokenType): void
223
    {
224
        if ($this->lexer->getCurrent()->getType() !== $tokenType) {
5✔
NEW
225
            throw ParseException::unexpectedToken($this->lexer->getCurrent(), [TokenType::getLiteral($tokenType)]);
×
226
        }
227
        if (TokenType::TOKEN_EOF !== $tokenType) {
5✔
228
            $this->lexer->getNext();
5✔
229
        }
230
    }
231

232
    /**
233
     * @return MemberNode[]
234
     * @throws UnexpectedValueException
235
     * @throws ParseException
236
     */
237
    private function members(): array
238
    {
239
        $members = [];
5✔
240
        while ($this->lexer->getCurrent()->getType() !== TokenType::TOKEN_RIGHT_CURLY_BRACKET) {
5✔
241
            $members[] = $this->member();
5✔
242
            if ($this->lexer->getCurrent()->getType() !== TokenType::TOKEN_RIGHT_CURLY_BRACKET) {
5✔
243
                $commaToken = $this->lexer->getCurrent();
5✔
244
                $this->eatToken(TokenType::TOKEN_COMMA);
5✔
245
                $this->eatWhiteSpace();
5✔
246
                if ($this->lexer->getCurrent()->getType() === TokenType::TOKEN_RIGHT_CURLY_BRACKET) {
5✔
UNCOV
247
                    ParseException::illegalTrailingComma($commaToken);
×
248
                }
249
            }
250
        }
251

252
        return $members;
5✔
253
    }
254

255
    /**
256
     * @return MemberNode
257
     * @throws UnexpectedValueException
258
     * @throws ParseException
259
     */
260
    private function member(): MemberNode
261
    {
262
        $keyNode = $this->key();
5✔
263
        $this->eatWhiteSpace();
5✔
264
        $this->eatToken(TokenType::TOKEN_COLON);
5✔
265
        $this->eatWhiteSpace();
5✔
266
        $element = $this->element();
5✔
267

268
        return new MemberNode($keyNode, $element);
5✔
269
    }
270

271
    /**
272
     * @return KeyNode
273
     * @throws UnexpectedValueException
274
     * @throws ParseException
275
     */
276
    private function key(): KeyNode
277
    {
278
        $currentToken = $this->lexer->getCurrent();
5✔
279
        $this->eatToken(TokenType::TOKEN_STRING);
5✔
280

281
        $keyNode = new KeyNode(self::parseStringValue($currentToken));
5✔
282
        $keyNode->setToken($currentToken);
5✔
283

284
        return $keyNode;
5✔
285
    }
286

287
    /**
288
     * @return ArrayNode
289
     * @throws UnexpectedValueException
290
     * @throws ParseException
291
     */
292
    private function array(): ArrayNode
293
    {
294
        $token = $this->lexer->getCurrent();
5✔
295
        $this->eatToken(TokenType::TOKEN_LEFT_SQUARE_BRACKET);
5✔
296
        $this->eatWhiteSpace();
5✔
297
        $elements = $this->elements();
5✔
298
        $this->eatToken(TokenType::TOKEN_RIGHT_SQUARE_BRACKET);
5✔
299

300
        $arrayNode = new ArrayNode($elements);
5✔
301
        $arrayNode->setToken($token);
5✔
302

303
        return $arrayNode;
5✔
304
    }
305

306
    /**
307
     * @return ElementNode[]
308
     * @throws UnexpectedValueException
309
     * @throws ParseException
310
     */
311
    private function elements(): array
312
    {
313
        $elements = [];
5✔
314
        while ($this->lexer->getCurrent()->getType() !== TokenType::TOKEN_RIGHT_SQUARE_BRACKET) {
5✔
315
            $elements[] = $this->element();
5✔
316
            if ($this->lexer->getCurrent()->getType() !== TokenType::TOKEN_RIGHT_SQUARE_BRACKET) {
5✔
317
                $commaToken = $this->lexer->getCurrent();
5✔
318
                $this->eatToken(TokenType::TOKEN_COMMA);
5✔
319
                $this->eatWhiteSpace();
5✔
320
                if ($this->lexer->getCurrent()->getType() === TokenType::TOKEN_RIGHT_SQUARE_BRACKET) {
5✔
UNCOV
321
                    throw ParseException::illegalTrailingComma($commaToken);
×
322
                }
323
            }
324
        }
325

326
        return $elements;
5✔
327
    }
328

329
    /**
330
     * @return StringNode
331
     * @throws UnexpectedValueException
332
     * @throws ParseException
333
     */
334
    private function string(): StringNode
335
    {
336
        $currentToken = $this->lexer->getCurrent();
5✔
337
        $this->eatToken(TokenType::TOKEN_STRING);
5✔
338

339
        $stringNode = new StringNode(self::parseStringValue($currentToken));
5✔
340
        $stringNode->setToken($currentToken);
5✔
341

342
        return $stringNode;
5✔
343
    }
344

345
    /**
346
     * @param Token $token
347
     * @return string
348
     * @throws UnexpectedValueException
349
     */
350
    private static function parseStringValue(Token $token): string
351
    {
352
        if ($token->getType() !== TokenType::TOKEN_STRING) {
5✔
NEW
353
            throw new UnexpectedValueException('Token is not a string');
×
354
        }
355

356
        $result = '';
5✔
357
        $rawString = substr($token->getLexeme(), 1, -1);
5✔
358
        $length = strlen($rawString);
5✔
359
        for ($i = 0; $i < $length; $i++) {
5✔
360
            $char = $rawString[$i];
5✔
361
            if ('\\' === $char) {
5✔
362
                $nextChar = $rawString[$i + 1] ?? '';
5✔
363
                if (in_array($nextChar, ['"', '\\', '/', 'b', 'f', 'n', 'r', 't'], true)) {
5✔
364
                    $result .= self::parseControlChar($char . $nextChar);
5✔
365
                    $i++;
5✔
366
                } elseif ('u' === $nextChar) {
5✔
367
                    $unicodeSequence = substr($rawString, $i, 6);
5✔
368
                    if (strlen($unicodeSequence) !== 6 || !preg_match('/\\\\u[0-9a-fA-F]{4}/', $unicodeSequence)) {
5✔
NEW
369
                        throw new UnexpectedValueException('Invalid Unicode escape sequence: ' . $unicodeSequence);
×
370
                    }
371
                    $result .= self::parseUnicode($unicodeSequence);
5✔
372
                    $i += 5;
5✔
373
                } else {
374
                    throw new UnexpectedValueException('Invalid escape sequence: \\' . $nextChar);
2✔
375
                }
376
            } else {
377
                $result .= $char;
5✔
378
            }
379
        }
380

381
        return $result;
5✔
382
    }
383

384
    /**
385
     * @param string $sequence
386
     * @return string
387
     * @throws UnexpectedValueException
388
     */
389
    private static function parseControlChar(string $sequence): string
390
    {
391
        return match ($sequence) {
5✔
392
            '\\"' => '"',
5✔
393
            '\\\\' => '\\',
5✔
394
            '\\/' => '/',
5✔
395
            '\\b' => "\b",
5✔
396
            '\\f' => "\f",
5✔
397
            '\\n' => "\n",
5✔
398
            '\\r' => "\r",
5✔
399
            '\\t' => "\t",
5✔
400
            default => throw new UnexpectedValueException('Invalid escape sequence: ' . $sequence)
5✔
401
        };
5✔
402
    }
403

404
    /**
405
     * @param string $sequence
406
     * @return string
407
     * @throws UnexpectedValueException
408
     */
409
    private static function parseUnicode(string $sequence): string
410
    {
411
        $codePoint = hexdec(substr($sequence, 2));
5✔
412
        if ($codePoint <= 0x7F) {
5✔
NEW
413
            return chr($codePoint);
×
414
        } elseif ($codePoint <= 0x7FF) {
5✔
415
            return chr(0xC0 | ($codePoint >> 6)) .
5✔
416
                   chr(0x80 | ($codePoint & 0x3F));
5✔
417
        } elseif ($codePoint <= 0xFFFF) {
5✔
418
            return chr(0xE0 | ($codePoint >> 12)) .
5✔
419
                   chr(0x80 | (($codePoint >> 6) & 0x3F)) .
5✔
420
                   chr(0x80 | ($codePoint & 0x3F));
5✔
NEW
421
        } elseif ($codePoint <= 0x10FFFF) {
×
NEW
422
            return chr(0xF0 | ($codePoint >> 18)) .
×
NEW
423
                   chr(0x80 | (($codePoint >> 12) & 0x3F)) .
×
NEW
424
                   chr(0x80 | (($codePoint >> 6) & 0x3F)) .
×
NEW
425
                   chr(0x80 | ($codePoint & 0x3F));
×
426
        } else {
NEW
427
            throw new UnexpectedValueException('Invalid Unicode code point: ' . $sequence);
×
428
        }
429
    }
430
}
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