├── .phpstorm.meta.php ├── src ├── global.php ├── SyntaxError.php └── Json5Decoder.php ├── LICENSE.md ├── composer.json ├── bin └── json5 ├── CHANGELOG.md └── README.md /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * Based on the official JSON5 implementation for JavaScript (https://github.com/json5/json5) 9 | * - (c) 2012-2016 Aseem Kishore and others (https://github.com/json5/json5/contributors) 10 | * 11 | * For the full copyright and license information, please view the LICENSE 12 | * file that was distributed with this source code. 13 | */ 14 | 15 | namespace ColinODell\Json5; 16 | 17 | final class SyntaxError extends \JsonException 18 | { 19 | public function __construct( 20 | string $message, 21 | private int $lineNumber, 22 | private int $column, 23 | \Throwable|null $previous = null 24 | ) { 25 | $message = \sprintf('%s at line %d column %d of the JSON5 data', $message, $lineNumber, $column); 26 | 27 | parent::__construct($message, 0, $previous); 28 | } 29 | 30 | public function getLineNumber(): int 31 | { 32 | return $this->lineNumber; 33 | } 34 | 35 | public function getColumn(): int 36 | { 37 | return $this->column; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2019 Colin O'Dell . Based on https://github.com/json5/json5; Copyright (c) 2012-2016 Aseem Kishore, and others. 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "colinodell/json5", 3 | "type": "library", 4 | "description": "UTF-8 compatible JSON5 parser for PHP", 5 | "keywords": [ 6 | "json5", 7 | "json", 8 | "json5_decode", 9 | "json_decode" 10 | ], 11 | "homepage": "https://github.com/colinodell/json5", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Colin O'Dell", 16 | "email": "colinodell@gmail.com", 17 | "homepage": "https://www.colinodell.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.0", 23 | "ext-json": "*", 24 | "ext-mbstring": "*" 25 | }, 26 | "require-dev": { 27 | "mikehaertl/php-shellcommand": "^1.7.0", 28 | "phpstan/phpstan": "^2.0", 29 | "scrutinizer/ocular": "^1.9", 30 | "squizlabs/php_codesniffer": "^3.8.1", 31 | "symfony/finder": "^6.0|^7.0|^8.0", 32 | "symfony/phpunit-bridge": "^7.0.3|^8.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "ColinODell\\Json5\\": "src" 37 | }, 38 | "files": ["src/global.php"] 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "ColinODell\\Json5\\Test\\": "test" 43 | } 44 | }, 45 | "bin": ["bin/json5"], 46 | "scripts": { 47 | "test": [ 48 | "@phpunit", 49 | "@check-style", 50 | "@phpstan" 51 | ], 52 | "phpunit": "simple-phpunit", 53 | "phpstan": "phpstan analyse", 54 | "check-style": "phpcs", 55 | "fix-style": "phpcbf" 56 | }, 57 | "extra": { 58 | "branch-alias": { 59 | "dev-main": "4.0-dev" 60 | } 61 | }, 62 | "config": { 63 | "sort-packages": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /bin/json5: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | */ 12 | 13 | requireAutoloader(); 14 | 15 | ini_set('display_errors', 'stderr'); 16 | 17 | foreach ($argv as $i => $arg) { 18 | if ($i === 0) { 19 | continue; 20 | } 21 | 22 | if (substr($arg, 0, 1) === '-') { 23 | switch ($arg) { 24 | case '-h': 25 | case '--help': 26 | echo getHelpText(); 27 | exit(0); 28 | } 29 | } else { 30 | $src = $arg; 31 | } 32 | } 33 | 34 | if (isset($src)) { 35 | if (!file_exists($src)) { 36 | fail('File not found: ' . $src); 37 | } 38 | 39 | $json = file_get_contents($src); 40 | } else { 41 | $stdin = fopen('php://stdin', 'r'); 42 | 43 | if (stream_set_blocking($stdin, false)) { 44 | $json = stream_get_contents($stdin); 45 | } 46 | 47 | fclose($stdin); 48 | 49 | if (empty($json)) { 50 | fail(getHelpText()); 51 | } 52 | } 53 | 54 | try { 55 | $decoded = \ColinODell\Json5\Json5Decoder::decode($json); 56 | echo json_encode($decoded); 57 | } catch (\ColinODell\Json5\SyntaxError $e) { 58 | fail($e->getMessage()); 59 | } 60 | 61 | /** 62 | * Get help and usage info 63 | * 64 | * @return string 65 | */ 66 | function getHelpText() 67 | { 68 | if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { 69 | return << file.json 89 | 90 | See https://github.com/colinodell/json5 for more information 91 | 92 | WINDOWSHELP; 93 | } 94 | 95 | return << file.json 115 | 116 | Converting from STDIN: 117 | 118 | echo -e "{hello: 'world!'}" | json5 119 | 120 | Converting from STDIN and saving the output: 121 | 122 | echo -e "{hello: 'world!'}" | json5 > output.json 123 | 124 | See https://github.com/colinodell/json5 for more information 125 | 126 | HELP; 127 | } 128 | 129 | /** 130 | * @param string $message Error message 131 | */ 132 | function fail($message) 133 | { 134 | fwrite(STDERR, $message . "\n"); 135 | exit(1); 136 | } 137 | 138 | function requireAutoloader() 139 | { 140 | $autoloadPaths = array( 141 | // Local package usage 142 | __DIR__ . '/../vendor/autoload.php', 143 | // Package was included as a library 144 | __DIR__ . '/../../../autoload.php', 145 | ); 146 | foreach ($autoloadPaths as $path) { 147 | if (file_exists($path)) { 148 | require_once $path; 149 | break; 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `colinodell/json5` will be documented in this file. 4 | 5 | Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. 6 | 7 | ## [Unreleased][unreleased] 8 | 9 | ## [3.0.0] - 2024-02-09 10 | 11 | You should not notice any breaking changes in this release unless you were using named parameters, or ignoring argument types defined in docblocks. 12 | 13 | ### Changed 14 | - Renamed function parameters to match `json_decode()`'s signature 15 | - `$source` is now `$json` 16 | - `$options` is now `$flags` 17 | - Added explicit `mixed` return type to match `json_decode()` 18 | - Added proper types to all parameters and return values of `SyntaxError` 19 | - Renamed two arguments in the `SyntaxError`'s constructor: 20 | - `$linenumber` is now `$lineNumber` 21 | - `$columnNumber` is now `$column` 22 | 23 | ### Removed 24 | - Removed support for PHP 7.x (8.0+ is now required) 25 | 26 | ## [2.3.0] - 2022-12-27 27 | 28 | ### Added 29 | - Added parameter and return types matching the existing docblocks 30 | 31 | ### Fixed 32 | - Fixed missing `@throws` docblocks needed for PhpStorm to recognize exceptions (#21) 33 | 34 | ## [2.2.2] - 2022-02-21 35 | 36 | ### Fixed 37 | - Fixed "small" integers always being cast to strings when `JSON_BIGINT_AS_STRING` is set (#17) 38 | - Fixed exceptions not being thrown when invalid UTF-16 escape sequences are encountered in strings 39 | 40 | ## [2.2.1] - 2021-11-06 41 | ### Fixed 42 | - Fixed exceptions always being thrown on PHP 7.3+ when parsing valid JSON5 with `JSON_THROW_ON_ERROR` explicitly set to true (#15) 43 | 44 | ## [2.2.0] - 2020-11-29 45 | ### Added 46 | - Added support for PHP 8.0 47 | 48 | ### Removed 49 | - Removed support for PHP 5.6 and 7.0 50 | 51 | ## [2.1.0] - 2019-03-28 52 | ### Added 53 | - Added `.phpstorm.meta.php` for better code completion 54 | - Added several tiny micro-optimizations 55 | 56 | ### Removed 57 | - Removed support for PHP 5.4 and 5.5 58 | 59 | ## [2.0.0] - 2018-09-20 60 | ### Added 61 | - Added a polyfill for class `\JsonException` (added in PHP 7.3) 62 | - Added a polyfill for constant `JSON_THROW_ON_ERROR` 63 | ### Changed 64 | - The `SyntaxError` class now extends from `\JsonException` 65 | 66 | ## [1.0.5] - 2018-09-20 67 | ### Fixed 68 | - Fixed exceptions not being thrown for incomplete objects/arrays 69 | 70 | ## [1.0.4] - 2018-01-14 71 | ### Changed 72 | - Modified the internal pointer and string manipulations to use bytes instead of characters for better performance (#4) 73 | 74 | ## [1.0.3] - 2018-01-14 75 | ### Fixed 76 | - Fixed check for PHP 7+ 77 | 78 | ## [1.0.2] - 2018-01-14 79 | This release contains massive performance improvements of 98% or more, especially for larger JSON inputs! 80 | 81 | ### Added 82 | - On PHP 7.x: parser will try using `json_decode()` first in case normal JSON is given, since this function is much faster (#1) 83 | 84 | ### Fixed 85 | - Fixed multiple performance issues (#1) 86 | - Fixed bug where `JSON_OBJECT_AS_ARRAY` was improperly taking priority over `assoc` in some cases 87 | 88 | ## [1.0.1] - 2017-11-11 89 | ### Removed 90 | - Removed accidentally-public constant 91 | 92 | ## 1.0.0 - 2017-11-11 93 | ### Added 94 | - Initial commit 95 | 96 | [unreleased]: https://github.com/colinodell/json5/compare/v3.0.0...HEAD 97 | [3.0.0]: https://github.com/colinodell/json5/compare/v2.3.0...v3.0.0 98 | [2.3.0]: https://github.com/colinodell/json5/compare/v2.2.2...v2.3.0 99 | [2.2.2]: https://github.com/colinodell/json5/compare/v2.2.1...v2.2.2 100 | [2.2.1]: https://github.com/colinodell/json5/compare/v2.2.0...v2.2.1 101 | [2.2.0]: https://github.com/colinodell/json5/compare/v2.1.0...v2.2.0 102 | [2.1.0]: https://github.com/colinodell/json5/compare/v2.0.0...v2.1.0 103 | [2.0.0]: https://github.com/colinodell/json5/compare/v1.0.5...v2.0.0 104 | [1.0.5]: https://github.com/colinodell/json5/compare/v1.0.4...v1.0.5 105 | [1.0.4]: https://github.com/colinodell/json5/compare/v1.0.3...v1.0.4 106 | [1.0.3]: https://github.com/colinodell/json5/compare/v1.0.2...v1.0.3 107 | [1.0.2]: https://github.com/colinodell/json5/compare/v1.0.1...v1.0.2 108 | [1.0.1]: https://github.com/colinodell/json5/compare/v1.0.0...v1.0.1 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON5 for PHP - JSON for Humans 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![PHP 8.0+][ico-php]][link-packagist] 5 | [![Software License][ico-license]](LICENSE.md) 6 | [![Build Status][ico-build-status]][link-build-status] 7 | [![Coverage Status][ico-scrutinizer]][link-scrutinizer] 8 | [![SensioLabs Insight][ico-sensio]][link-sensio] 9 | ![UTF-8 Compatible][ico-utf8] 10 | [![Total Downloads][ico-downloads]][link-downloads] 11 | 12 | 13 | This library is a PHP fork of the [JSON5 reference implementation][link-json5]. 14 | 15 | JSON5 is a JS-compatible extension to JSON which allows comments, trailing commas, single-quoted strings, and more: 16 | 17 | 18 | ```js 19 | { 20 | foo: 'bar', 21 | while: true, 22 | 23 | this: 'is a \ 24 | multi-line string', 25 | 26 | // this is an inline comment 27 | here: 'is another', // inline comment 28 | 29 | /* this is a block comment 30 | that continues on another line */ 31 | 32 | hex: 0xDEADbeef, 33 | half: .5, 34 | delta: +10, 35 | to: Infinity, // and beyond! 36 | 37 | finally: [ 38 | 'some trailing commas', 39 | ], 40 | } 41 | ``` 42 | 43 | 44 | [See the JSON5 website for additional examples and details][link-json5-site]. 45 | 46 | 47 | ## Install 48 | 49 | Via Composer 50 | 51 | ``` bash 52 | composer require colinodell/json5 53 | ``` 54 | 55 | ## Usage 56 | 57 | This package adds a `json5_decode()` function which is a drop-in replacement for PHP's built-in `json_decode()`: 58 | 59 | ``` php 60 | $json = file_get_contents('foo.json5'); 61 | $data = json5_decode($json); 62 | ``` 63 | 64 | It takes the same exact parameters in the same order. For more details on these, see the [PHP docs][link-php-jsondecode]. 65 | 66 | To achieve the best possible performance, it'll try parsing with PHP's native function (which usually fails fast) and then falls back to JSON5. 67 | 68 | ### Exceptions 69 | 70 | This function will **always** throw a `SyntaxError` exception if parsing fails. This is a subclass of the new `\JsonException` introduced in PHP 7.3. 71 | Providing or omitting the `JSON_THROW_ON_ERROR` option will have no effect on this behavior. 72 | 73 | ## Binary / Executable 74 | 75 | A binary/executable named `json5` is also provided for converting JSON5 to plain JSON via your terminal. 76 | 77 | ``` 78 | Usage: json5 [OPTIONS] [FILE] 79 | 80 | -h, --help Shows help and usage information 81 | 82 | (Reading data from STDIN is not currently supported on Windows) 83 | ``` 84 | 85 | ### Examples: 86 | 87 | Converting a file named file.json5: 88 | 89 | ```bash 90 | json5 file.json5 91 | ``` 92 | 93 | Converting a file and saving its output: 94 | 95 | ```bash 96 | json5 file.json5 > file.json 97 | ``` 98 | 99 | Converting from STDIN: 100 | 101 | ```bash 102 | echo -e "{hello: 'world!'}" | json5 103 | ``` 104 | 105 | Converting from STDIN and saving the output: 106 | ```bash 107 | echo -e "{hello: 'world!'}" | json5 > output.json 108 | ``` 109 | 110 | ## Change log 111 | 112 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 113 | 114 | ## Testing 115 | 116 | ``` bash 117 | composer test 118 | ``` 119 | 120 | ## Contributing 121 | 122 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 123 | 124 | ## Security 125 | 126 | If you discover any security related issues, please email colinodell@gmail.com instead of using the issue tracker. 127 | 128 | ## Support 129 | 130 | In addition to standard support, [consider a Tidelift Subscription for professional support and get alerted when new releases or security issues come out](https://tidelift.com/subscription/pkg/packagist-colinodell-json5?utm_source=packagist-colinodell-json5&utm_medium=referral&utm_campaign=readme). 131 | 132 | ## Credits 133 | 134 | - [Colin O'Dell][link-author] 135 | - [Aseem Kishore][link-upstream-author], [the JSON5 project][link-json5], and [their contributors][link-upstream-contributors] 136 | - [All other contributors to this project][link-contributors] 137 | 138 | ## License 139 | 140 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 141 | 142 | [ico-version]: https://img.shields.io/packagist/v/colinodell/json5.svg?style=flat-square 143 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 144 | [ico-build-status]: https://img.shields.io/github/actions/workflow/status/colinodell/json5/tests.yml?branch=main&style=flat-square 145 | [ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/colinodell/json5.svg?style=flat-square 146 | [ico-code-quality]: https://img.shields.io/scrutinizer/g/colinodell/json5.svg?style=flat-square 147 | [ico-downloads]: https://img.shields.io/packagist/dt/colinodell/json5.svg?style=flat-square 148 | [ico-utf8]: https://img.shields.io/badge/utf--8-compatible-brightgreen.svg?style=flat-square 149 | [ico-sensio]: https://img.shields.io/symfony/i/grade/920abb3b-a7d0-431a-bb5a-9831d142690e?style=flat-square 150 | [ico-php]: https://img.shields.io/packagist/php-v/colinodell/json5.svg?style=flat-square 151 | 152 | [link-packagist]: https://packagist.org/packages/colinodell/json5 153 | [link-build-status]: https://github.com/colinodell/json5/actions?query=workflow%3ATests+branch%3Amain 154 | [link-scrutinizer]: https://scrutinizer-ci.com/g/colinodell/json5/code-structure/main/code-coverage 155 | [link-code-quality]: https://scrutinizer-ci.com/g/colinodell/json5 156 | [link-downloads]: https://packagist.org/packages/colinodell/json5 157 | [link-sensio]: https://insight.symfony.com/projects/920abb3b-a7d0-431a-bb5a-9831d142690e 158 | [link-author]: https://github.com/colinodell 159 | [link-json5]: https://github.com/json5/json5 160 | [link-php-jsondecode]: http://php.net/manual/en/function.json-decode.php 161 | [link-upstream-author]: https://github.com/aseemk 162 | [link-upstream-contributors]: https://github.com/json5/json5#credits 163 | [link-json5-site]: http://json5.org 164 | [link-contributors]: https://github.com/colinodell/json5/graphs/contributors 165 | -------------------------------------------------------------------------------- /src/Json5Decoder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * Based on the official JSON5 implementation for JavaScript (https://github.com/json5/json5) 9 | * - (c) 2012-2016 Aseem Kishore and others (https://github.com/json5/json5/contributors) 10 | * 11 | * For the full copyright and license information, please view the LICENSE 12 | * file that was distributed with this source code. 13 | */ 14 | 15 | namespace ColinODell\Json5; 16 | 17 | final class Json5Decoder 18 | { 19 | private int $length; 20 | private int $at = 0; 21 | private ?string $currentByte; 22 | private int $lineNumber = 1; 23 | private int $depth = 1; 24 | private int $currentLineStartsAt = 0; 25 | 26 | /** 27 | * Private constructor. 28 | */ 29 | private function __construct( 30 | private string $json, 31 | private bool $associative = false, 32 | private int $maxDepth = 512, 33 | private bool $castBigIntToString = false 34 | ) { 35 | $this->length = \strlen($json); 36 | $this->currentByte = $this->getByte(0); 37 | } 38 | 39 | /** 40 | * Takes a JSON encoded string and converts it into a PHP variable. 41 | * 42 | * The parameters exactly match PHP's json_decode() function - see 43 | * http://php.net/manual/en/function.json-decode.php for more information. 44 | * 45 | * @param string $json The JSON string being decoded. 46 | * @param bool $associative When TRUE, returned objects will be converted into associative arrays. 47 | * @param int $depth User specified recursion depth. 48 | * @param int $flags Bitmask of JSON decode options. 49 | * 50 | * @throws SyntaxError if the JSON encoded string could not be parsed. 51 | */ 52 | public static function decode(string $json, ?bool $associative = false, int $depth = 512, int $flags = 0): mixed 53 | { 54 | // Try parsing with json_decode first, since that's much faster 55 | try { 56 | $result = \json_decode($json, $associative, $depth, $flags); 57 | if (\json_last_error() === \JSON_ERROR_NONE) { 58 | return $result; 59 | } 60 | } catch (\Throwable $e) { 61 | // ignore exception, continue parsing as JSON5 62 | } 63 | 64 | // Fall back to JSON5 if that fails 65 | $associative = $associative === true || ($associative === null && $flags & \JSON_OBJECT_AS_ARRAY); 66 | $castBigIntToString = (bool) ($flags & \JSON_BIGINT_AS_STRING); 67 | 68 | $decoder = new self($json, $associative, $depth, $castBigIntToString); 69 | 70 | $result = $decoder->value(); 71 | $decoder->white(); 72 | if ($decoder->currentByte) { 73 | $decoder->throwSyntaxError('Syntax error'); 74 | } 75 | 76 | return $result; 77 | } 78 | 79 | private function getByte(int $at): ?string 80 | { 81 | if ($at >= $this->length) { 82 | return null; 83 | } 84 | 85 | return $this->json[$at]; 86 | } 87 | 88 | private function currentChar(): ?string 89 | { 90 | if ($this->at >= $this->length) { 91 | return null; 92 | } 93 | 94 | return \mb_substr(\substr($this->json, $this->at, 4), 0, 1); 95 | } 96 | 97 | /** 98 | * Parse the next character. 99 | */ 100 | private function next(): void 101 | { 102 | // Get the next character. When there are no more characters, 103 | // return the empty string. 104 | if ($this->currentByte === "\n" || ($this->currentByte === "\r" && $this->peek() !== "\n")) { 105 | $this->lineNumber++; 106 | $this->currentLineStartsAt = $this->at + 1; 107 | } 108 | 109 | $this->at++; 110 | 111 | $this->currentByte = $this->getByte($this->at); 112 | } 113 | 114 | /** 115 | * Parse the next character if it matches $c or fail. 116 | */ 117 | private function nextOrFail(string $c): void 118 | { 119 | if ($c !== $this->currentByte) { 120 | $this->throwSyntaxError(\sprintf( 121 | 'Expected %s instead of %s', 122 | self::renderChar($c), 123 | self::renderChar($this->currentChar()) 124 | )); 125 | } 126 | 127 | $this->next(); 128 | } 129 | 130 | /** 131 | * Get the next character without consuming it or 132 | * assigning it to the ch variable. 133 | */ 134 | private function peek(): ?string 135 | { 136 | return $this->getByte($this->at + 1); 137 | } 138 | 139 | /** 140 | * Attempt to match a regular expression at the current position on the current line. 141 | * 142 | * This function will not match across multiple lines. 143 | */ 144 | private function match(string $regex): ?string 145 | { 146 | $subject = \substr($this->json, $this->at); 147 | // Only match on the current line 148 | if ($pos = \strpos($subject, "\n")) { 149 | $subject = \substr($subject, 0, $pos); 150 | } 151 | 152 | if (!\preg_match($regex, $subject, $matches, PREG_OFFSET_CAPTURE)) { 153 | return null; 154 | } 155 | 156 | $this->at += $matches[0][1] + \strlen($matches[0][0]); 157 | $this->currentByte = $this->getByte($this->at); 158 | 159 | return $matches[0][0]; 160 | } 161 | 162 | /** 163 | * Parse an identifier. 164 | * 165 | * Normally, reserved words are disallowed here, but we 166 | * only use this for unquoted object keys, where reserved words are allowed, 167 | * so we don't check for those here. References: 168 | * - http://es5.github.com/#x7.6 169 | * - https://developer.mozilla.org/en/Core_JavaScript_1.5_Guide/Core_Language_Features#Variables 170 | * - http://docstore.mik.ua/orelly/webprog/jscript/ch02_07.htm 171 | */ 172 | private function identifier(): string 173 | { 174 | // @codingStandardsIgnoreStart 175 | // Be careful when editing this regex, there are a couple Unicode characters in between here -------------vv 176 | $match = $this->match('/^(?:[\$_\p{L}\p{Nl}]|\\\\u[0-9A-Fa-f]{4})(?:[\$_\p{L}\p{Nl}\p{Mn}\p{Mc}\p{Nd}\p{Pc}‌‍]|\\\\u[0-9A-Fa-f]{4})*/u'); 177 | // @codingStandardsIgnoreEnd 178 | 179 | if ($match === null) { 180 | $this->throwSyntaxError('Bad identifier as unquoted key'); 181 | } 182 | 183 | // Un-escape escaped Unicode chars 184 | $unescaped = \preg_replace_callback('/(?:\\\\u[0-9A-Fa-f]{4})+/', function ($m) { 185 | return \json_decode('"'.$m[0].'"'); 186 | }, $match); 187 | 188 | return $unescaped; 189 | } 190 | 191 | private function number(): int|float|string 192 | { 193 | $number = null; 194 | $sign = ''; 195 | $string = ''; 196 | $base = 10; 197 | 198 | if ($this->currentByte === '-' || $this->currentByte === '+') { 199 | $sign = $this->currentByte; 200 | $this->next(); 201 | } 202 | 203 | // support for Infinity 204 | if ($this->currentByte === 'I') { 205 | $this->word(); 206 | 207 | return ($sign === '-') ? -INF : INF; 208 | } 209 | 210 | // support for NaN 211 | if ($this->currentByte === 'N') { 212 | $number = $this->word(); 213 | 214 | // ignore sign as -NaN also is NaN 215 | return $number; 216 | } 217 | 218 | if ($this->currentByte === '0') { 219 | $string .= $this->currentByte; 220 | $this->next(); 221 | if ($this->currentByte === 'x' || $this->currentByte === 'X') { 222 | $string .= $this->currentByte; 223 | $this->next(); 224 | $base = 16; 225 | } elseif (\is_numeric($this->currentByte)) { 226 | $this->throwSyntaxError('Octal literal'); 227 | } 228 | } 229 | 230 | switch ($base) { 231 | case 10: 232 | // @codingStandardsIgnoreStart 233 | if ((\is_numeric($this->currentByte) || $this->currentByte === '.') && ($match = $this->match('/^\d*\.?\d*/')) !== null) { 234 | $string .= $match; 235 | } 236 | if (($this->currentByte === 'E' || $this->currentByte === 'e') && ($match = $this->match('/^[Ee][-+]?\d*/')) !== null) { 237 | $string .= $match; 238 | } 239 | // @codingStandardsIgnoreEnd 240 | $number = $string; 241 | break; 242 | case 16: 243 | if (($match = $this->match('/^[A-Fa-f0-9]+/')) !== null) { 244 | $string .= $match; 245 | $number = \hexdec($string); 246 | break; 247 | } 248 | $this->throwSyntaxError('Bad hex number'); 249 | } 250 | 251 | if ($sign === '-') { 252 | $number = '-' . $number; 253 | } 254 | 255 | if (!\is_numeric($number) || !\is_finite($number)) { 256 | $this->throwSyntaxError('Bad number'); 257 | } 258 | 259 | // Adding 0 will automatically cast this to an int or float 260 | $asIntOrFloat = $number + 0; 261 | 262 | $isIntLike = preg_match('/^-?\d+$/', $number) === 1; 263 | if ($this->castBigIntToString && $isIntLike && is_float($asIntOrFloat)) { 264 | return $number; 265 | } 266 | 267 | return $asIntOrFloat; 268 | } 269 | 270 | private function string(): string 271 | { 272 | $string = ''; 273 | 274 | $delim = $this->currentByte; 275 | $this->next(); 276 | while ($this->currentByte !== null) { 277 | if ($this->currentByte === $delim) { 278 | $this->next(); 279 | 280 | return $string; 281 | } 282 | 283 | if ($this->currentByte === '\\') { 284 | if ($this->peek() === 'u' && $unicodeEscaped = $this->match('/^(?:\\\\u[A-Fa-f0-9]{4})+/')) { 285 | try { 286 | $unicodeUnescaped = \json_decode('"' . $unicodeEscaped . '"', false, 1, JSON_THROW_ON_ERROR); 287 | if ($unicodeUnescaped === null && ($err = json_last_error_msg())) { 288 | throw new \JsonException($err); 289 | } 290 | $string .= $unicodeUnescaped; 291 | } catch (\JsonException $e) { 292 | $this->throwSyntaxError($e->getMessage()); 293 | } 294 | continue; 295 | } 296 | 297 | $this->next(); 298 | if ($this->currentByte === "\r") { 299 | if ($this->peek() === "\n") { 300 | $this->next(); 301 | } 302 | } elseif (($escapee = self::getEscapee($this->currentByte)) !== null) { 303 | $string .= $escapee; 304 | } else { 305 | break; 306 | } 307 | } elseif ($this->currentByte === "\n") { 308 | // unescaped newlines are invalid; see: 309 | // https://github.com/json5/json5/issues/24 310 | // @todo this feels special-cased; are there other invalid unescaped chars? 311 | break; 312 | } else { 313 | $string .= $this->currentByte; 314 | } 315 | 316 | $this->next(); 317 | } 318 | 319 | $this->throwSyntaxError('Bad string'); 320 | } 321 | 322 | /** 323 | * Skip an inline comment, assuming this is one. 324 | * 325 | * The current character should be the second / character in the // pair that begins this inline comment. 326 | * To finish the inline comment, we look for a newline or the end of the text. 327 | */ 328 | private function inlineComment(): void 329 | { 330 | do { 331 | $this->next(); 332 | if ($this->currentByte === "\n" || $this->currentByte === "\r") { 333 | $this->next(); 334 | 335 | return; 336 | } 337 | } while ($this->currentByte !== null); 338 | } 339 | 340 | /** 341 | * Skip a block comment, assuming this is one. 342 | * 343 | * The current character should be the * character in the /* pair that begins this block comment. 344 | * To finish the block comment, we look for an ending *​/ pair of characters, 345 | * but we also watch for the end of text before the comment is terminated. 346 | */ 347 | private function blockComment(): void 348 | { 349 | do { 350 | $this->next(); 351 | while ($this->currentByte === '*') { 352 | $this->nextOrFail('*'); 353 | if ($this->currentByte === '/') { 354 | $this->nextOrFail('/'); 355 | 356 | return; 357 | } 358 | } 359 | } while ($this->currentByte !== null); 360 | 361 | $this->throwSyntaxError('Unterminated block comment'); 362 | } 363 | 364 | /** 365 | * Skip a comment, whether inline or block-level, assuming this is one. 366 | */ 367 | private function comment(): void 368 | { 369 | // Comments always begin with a / character. 370 | $this->nextOrFail('/'); 371 | 372 | if ($this->currentByte === '/') { 373 | $this->inlineComment(); 374 | } elseif ($this->currentByte === '*') { 375 | $this->blockComment(); 376 | } else { 377 | $this->throwSyntaxError('Unrecognized comment'); 378 | } 379 | } 380 | 381 | /** 382 | * Skip whitespace and comments. 383 | * 384 | * Note that we're detecting comments by only a single / character. 385 | * This works since regular expressions are not valid JSON(5), but this will 386 | * break if there are other valid values that begin with a / character! 387 | */ 388 | private function white(): void 389 | { 390 | while ($this->currentByte !== null) { 391 | if ($this->currentByte === '/') { 392 | $this->comment(); 393 | } elseif (\preg_match('/^[ \t\r\n\v\f\xA0]/', $this->currentByte) === 1) { 394 | $this->next(); 395 | } elseif (\ord($this->currentByte) === 0xC2 && \ord($this->peek()) === 0xA0) { 396 | // Non-breaking space in UTF-8 397 | $this->next(); 398 | $this->next(); 399 | } else { 400 | return; 401 | } 402 | } 403 | } 404 | 405 | /** 406 | * Matches true, false, null, etc 407 | */ 408 | private function word(): bool|float|null 409 | { 410 | switch ($this->currentByte) { 411 | case 't': 412 | $this->nextOrFail('t'); 413 | $this->nextOrFail('r'); 414 | $this->nextOrFail('u'); 415 | $this->nextOrFail('e'); 416 | return true; 417 | case 'f': 418 | $this->nextOrFail('f'); 419 | $this->nextOrFail('a'); 420 | $this->nextOrFail('l'); 421 | $this->nextOrFail('s'); 422 | $this->nextOrFail('e'); 423 | return false; 424 | case 'n': 425 | $this->nextOrFail('n'); 426 | $this->nextOrFail('u'); 427 | $this->nextOrFail('l'); 428 | $this->nextOrFail('l'); 429 | return null; 430 | case 'I': 431 | $this->nextOrFail('I'); 432 | $this->nextOrFail('n'); 433 | $this->nextOrFail('f'); 434 | $this->nextOrFail('i'); 435 | $this->nextOrFail('n'); 436 | $this->nextOrFail('i'); 437 | $this->nextOrFail('t'); 438 | $this->nextOrFail('y'); 439 | return INF; 440 | case 'N': 441 | $this->nextOrFail('N'); 442 | $this->nextOrFail('a'); 443 | $this->nextOrFail('N'); 444 | return NAN; 445 | } 446 | 447 | $this->throwSyntaxError('Unexpected ' . self::renderChar($this->currentChar())); 448 | } 449 | 450 | private function arr(): array 451 | { 452 | $arr = []; 453 | 454 | if (++$this->depth > $this->maxDepth) { 455 | $this->throwSyntaxError('Maximum stack depth exceeded'); 456 | } 457 | 458 | $this->nextOrFail('['); 459 | $this->white(); 460 | while ($this->currentByte !== null) { 461 | if ($this->currentByte === ']') { 462 | $this->nextOrFail(']'); 463 | $this->depth--; 464 | return $arr; // Potentially empty array 465 | } 466 | // ES5 allows omitting elements in arrays, e.g. [,] and 467 | // [,null]. We don't allow this in JSON5. 468 | if ($this->currentByte === ',') { 469 | $this->throwSyntaxError('Missing array element'); 470 | } 471 | 472 | $arr[] = $this->value(); 473 | 474 | $this->white(); 475 | // If there's no comma after this value, this needs to 476 | // be the end of the array. 477 | if ($this->currentByte !== ',') { 478 | $this->nextOrFail(']'); 479 | $this->depth--; 480 | return $arr; 481 | } 482 | $this->nextOrFail(','); 483 | $this->white(); 484 | } 485 | 486 | $this->throwSyntaxError('Invalid array'); 487 | } 488 | 489 | /** 490 | * Parse an object value 491 | */ 492 | private function obj(): array|object 493 | { 494 | $object = $this->associative ? [] : new \stdClass; 495 | 496 | if (++$this->depth > $this->maxDepth) { 497 | $this->throwSyntaxError('Maximum stack depth exceeded'); 498 | } 499 | 500 | $this->nextOrFail('{'); 501 | $this->white(); 502 | while ($this->currentByte !== null) { 503 | if ($this->currentByte === '}') { 504 | $this->nextOrFail('}'); 505 | $this->depth--; 506 | return $object; // Potentially empty object 507 | } 508 | 509 | // Keys can be unquoted. If they are, they need to be 510 | // valid JS identifiers. 511 | if ($this->currentByte === '"' || $this->currentByte === "'") { 512 | $key = $this->string(); 513 | } else { 514 | $key = $this->identifier(); 515 | } 516 | 517 | $this->white(); 518 | $this->nextOrFail(':'); 519 | if ($this->associative) { 520 | $object[$key] = $this->value(); 521 | } else { 522 | $object->{$key} = $this->value(); 523 | } 524 | $this->white(); 525 | // If there's no comma after this pair, this needs to be 526 | // the end of the object. 527 | if ($this->currentByte !== ',') { 528 | $this->nextOrFail('}'); 529 | $this->depth--; 530 | return $object; 531 | } 532 | $this->nextOrFail(','); 533 | $this->white(); 534 | } 535 | 536 | $this->throwSyntaxError('Invalid object'); 537 | } 538 | 539 | /** 540 | * Parse a JSON value. 541 | * 542 | * It could be an object, an array, a string, a number, 543 | * or a word. 544 | */ 545 | private function value(): mixed 546 | { 547 | $this->white(); 548 | return match ($this->currentByte) { 549 | '{' => $this->obj(), 550 | '[' => $this->arr(), 551 | '"', "'" => $this->string(), 552 | '-', '+', '.' => $this->number(), 553 | default => \is_numeric($this->currentByte) ? $this->number() : $this->word(), 554 | }; 555 | } 556 | 557 | /** 558 | * @throws SyntaxError 559 | * 560 | * @phpstan-return never 561 | */ 562 | private function throwSyntaxError(string $message): void 563 | { 564 | // Calculate the column number 565 | $str = \substr($this->json, $this->currentLineStartsAt, $this->at - $this->currentLineStartsAt); 566 | $column = \mb_strlen($str) + 1; 567 | 568 | throw new SyntaxError($message, $this->lineNumber, $column); 569 | } 570 | 571 | private static function renderChar(?string $chr): string 572 | { 573 | return $chr === null ? 'EOF' : "'" . $chr . "'"; 574 | } 575 | 576 | private static function getEscapee(string $ch): ?string 577 | { 578 | return match ($ch) { 579 | "'" => "'", 580 | '"' => '"', 581 | '\\' => '\\', 582 | '/' => '/', 583 | "\n" => '', 584 | 'b' => \chr(8), 585 | 'f' => "\f", 586 | 'n' => "\n", 587 | 'r' => "\r", 588 | 't' => "\t", 589 | default => null, 590 | }; 591 | } 592 | } 593 | --------------------------------------------------------------------------------