├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Exception ├── ParsingError.php ├── ReadingError.php └── WritingError.php ├── LICENSE ├── Parser.php ├── Parser └── ListenerInterface.php ├── README.md ├── Reader.php ├── Reader └── Tokenizer.php ├── Tests ├── ExampleTest.php ├── Parser │ ├── FunctionalTest.php │ └── FunctionalTest │ │ ├── TestListener.php │ │ └── fixtures │ │ ├── data-ranges.json │ │ ├── escaped-chars.json │ │ ├── example.json │ │ └── plain.json ├── Reader │ ├── TokenizerTest.php │ └── fixtures │ │ └── tokenizer.yml ├── ReaderTest.php └── WriterTest.php ├── Writer.php ├── composer.json └── phpunit.xml.dist /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | phpunit: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest] 11 | php-version: ['7.3', '7.4', '8.0'] 12 | name: 'PHPUnit - PHP/${{ matrix.php-version }} - OS/${{ matrix.os }}' 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: ${{ matrix.php-version }} 20 | coverage: xdebug 21 | - name: Get Composer Cache Directory 22 | id: composer-cache 23 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 24 | - name: Cache dependencies 25 | uses: actions/cache@v2 26 | with: 27 | path: ${{ steps.composer-cache.outputs.dir }} 28 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 29 | restore-keys: ${{ runner.os }}-composer- 30 | - name: Install Dependencies 31 | run: composer install --no-progress 32 | - name: PHPUnit 33 | run: vendor/bin/phpunit 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .phpunit.result.cache 3 | composer.lock 4 | phpunit.xml 5 | -------------------------------------------------------------------------------- /Exception/ParsingError.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Exception; 11 | 12 | /** 13 | * Class ParsingError 14 | * @package Bcn\Component\Json\Exception 15 | */ 16 | class ParsingError extends \Exception 17 | { 18 | /** 19 | * @param int $line 20 | * @param int $char 21 | * @param string $message 22 | */ 23 | public function __construct($line, $char, $message) 24 | { 25 | parent::__construct("Parsing error in [$line:$char]: " . $message); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Exception/ReadingError.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Exception; 11 | 12 | /** 13 | * Class ReadingError 14 | * @package Bcn\Component\Json\Exception 15 | */ 16 | class ReadingError extends \Exception 17 | { 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Exception/WritingError.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Exception; 11 | 12 | /** 13 | * Class WritingError 14 | * @package Bcn\Component\Json\Exception 15 | */ 16 | class WritingError extends \Exception 17 | { 18 | 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sergey Kolodyazhnyy 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Parser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json; 11 | 12 | use Bcn\Component\Json\Parser\ListenerInterface; 13 | use Bcn\Component\Json\Exception\ParsingError; 14 | 15 | /** 16 | * Class Parser 17 | * @package Bcn\Component\Json 18 | */ 19 | class Parser 20 | { 21 | const STATE_START_DOCUMENT = 0; 22 | const STATE_DONE = -1; 23 | const STATE_IN_ARRAY = 1; 24 | const STATE_IN_OBJECT = 2; 25 | const STATE_END_KEY = 3; 26 | const STATE_AFTER_KEY = 4; 27 | const STATE_IN_STRING = 5; 28 | const STATE_START_ESCAPE = 6; 29 | const STATE_UNICODE = 7; 30 | const STATE_IN_NUMBER = 8; 31 | const STATE_IN_TRUE = 9; 32 | const STATE_IN_FALSE = 10; 33 | const STATE_IN_NULL = 11; 34 | const STATE_AFTER_VALUE = 12; 35 | 36 | const STACK_OBJECT = 0; 37 | const STACK_ARRAY = 1; 38 | const STACK_KEY = 2; 39 | const STACK_STRING = 3; 40 | 41 | /** @var array */ 42 | private $stack; 43 | /** @var int */ 44 | private $state; 45 | /** @var resource */ 46 | private $stream; 47 | /** @var Parser/Listener */ 48 | private $listener; 49 | /** @var string */ 50 | private $buffer; 51 | /** @var int */ 52 | private $bufferSize; 53 | /** @var array */ 54 | private $unicodeBuffer; 55 | /** @var int */ 56 | private $unicodeHighCodePoint; 57 | /** @var string */ 58 | private $lineEnding; 59 | /** @var integer */ 60 | private $lineNumber; 61 | /** @var integer */ 62 | private $charNumber; 63 | 64 | /** 65 | * @param resource $stream 66 | * @param ListenerInterface $listener 67 | * @param string $lineEnding 68 | * @throws \InvalidArgumentException 69 | */ 70 | public function __construct($stream, ListenerInterface $listener, $lineEnding = null) 71 | { 72 | if (!is_resource($stream) || get_resource_type($stream) != 'stream') { 73 | throw new \InvalidArgumentException("Argument is not a stream"); 74 | } 75 | 76 | $this->stream = $stream; 77 | $this->listener = $listener; 78 | 79 | $this->state = self::STATE_START_DOCUMENT; 80 | $this->stack = array(); 81 | 82 | $this->buffer = ''; 83 | $this->bufferSize = 8192; 84 | $this->unicodeBuffer = array(); 85 | $this->unicodeHighCodePoint = -1; 86 | $this->lineEnding = $lineEnding ?: "\n";; 87 | } 88 | 89 | /** 90 | * Parse stream content 91 | */ 92 | public function parse() 93 | { 94 | $this->lineNumber = 1; 95 | $this->charNumber = 1; 96 | 97 | while (!feof($this->stream)) { 98 | $pos = ftell($this->stream); 99 | $line = stream_get_line($this->stream, $this->bufferSize, $this->lineEnding); 100 | $ended = (bool) (ftell($this->stream) - strlen($line) - $pos); 101 | 102 | $byteLen = strlen($line); 103 | for ($i = 0; $i < $byteLen; $i++) { 104 | $this->listener->file_position($this->lineNumber, $this->charNumber); 105 | $this->consumeChar($line[$i]); 106 | $this->charNumber++; 107 | } 108 | 109 | if ($ended) { 110 | $this->lineNumber++; 111 | $this->charNumber = 1; 112 | } 113 | 114 | } 115 | } 116 | 117 | /** 118 | * @param string $c 119 | * @throws ParsingError 120 | */ 121 | private function consumeChar($c) 122 | { 123 | // valid whitespace characters in JSON (from RFC4627 for JSON) include: 124 | // space, horizontal tab, line feed or new line, and carriage return. 125 | // thanks: http://stackoverflow.com/questions/16042274/definition-of-whitespace-in-json 126 | if (($c === " " || $c === "\t" || $c === "\n" || $c === "\r") && 127 | !($this->state === self::STATE_IN_STRING || 128 | $this->state === self::STATE_UNICODE || 129 | $this->state === self::STATE_START_ESCAPE || 130 | $this->state === self::STATE_IN_NUMBER || 131 | $this->state === self::STATE_START_DOCUMENT)) { 132 | return; 133 | } 134 | 135 | switch ($this->state) { 136 | 137 | case self::STATE_START_DOCUMENT: 138 | $this->listener->start_document(); 139 | if ($c === '[') { 140 | $this->startArray(); 141 | } elseif ($c === '{') { 142 | $this->startObject(); 143 | } else { 144 | throw new ParsingError($this->lineNumber, $this->charNumber, 145 | "Document must start with object or array."); 146 | } 147 | break; 148 | 149 | case self::STATE_IN_ARRAY: 150 | if ($c === ']') { 151 | $this->endArray(); 152 | } else { 153 | $this->startValue($c); 154 | } 155 | break; 156 | 157 | case self::STATE_IN_OBJECT: 158 | if ($c === '}') { 159 | $this->endObject(); 160 | } elseif ($c === '"') { 161 | $this->startKey(); 162 | } else { 163 | throw new ParsingError($this->lineNumber, $this->charNumber, 164 | "Start of string expected for object key. Instead got: ".$c); 165 | } 166 | break; 167 | 168 | case self::STATE_END_KEY: 169 | if ($c !== ':') { 170 | throw new ParsingError($this->lineNumber, $this->charNumber, 171 | "Expected ':' after key."); 172 | } 173 | $this->state = self::STATE_AFTER_KEY; 174 | break; 175 | 176 | case self::STATE_AFTER_KEY: 177 | $this->startValue($c); 178 | break; 179 | 180 | case self::STATE_IN_STRING: 181 | if ($c === '"') { 182 | $this->endString(); 183 | } elseif ($c === '\\') { 184 | $this->state = self::STATE_START_ESCAPE; 185 | } elseif (($c < "\x1f") || ($c === "\x7f")) { 186 | throw new ParsingError($this->lineNumber, $this->charNumber, 187 | "Unescaped control character encountered: " . $c); 188 | } else { 189 | $this->buffer .= $c; 190 | } 191 | break; 192 | 193 | case self::STATE_START_ESCAPE: 194 | $this->processEscapeCharacter($c); 195 | break; 196 | 197 | case self::STATE_UNICODE: 198 | $this->processUnicodeCharacter($c); 199 | break; 200 | 201 | case self::STATE_AFTER_VALUE: 202 | $within = end($this->stack); 203 | if ($within === self::STACK_OBJECT) { 204 | if ($c === '}') { 205 | $this->endObject(); 206 | } elseif ($c === ',') { 207 | $this->state = self::STATE_IN_OBJECT; 208 | } else { 209 | throw new ParsingError($this->lineNumber, $this->charNumber, 210 | "Expected ',' or '}' while parsing object. Got: ".$c); 211 | } 212 | } elseif ($within === self::STACK_ARRAY) { 213 | if ($c === ']') { 214 | $this->endArray(); 215 | } elseif ($c === ',') { 216 | $this->state = self::STATE_IN_ARRAY; 217 | } else { 218 | throw new ParsingError($this->lineNumber, $this->charNumber, 219 | "Expected ',' or ']' while parsing array. Got: ".$c); 220 | } 221 | } else { 222 | throw new ParsingError($this->lineNumber, $this->charNumber, 223 | "Finished a literal, but unclear what state to move to. Last state: ".$within); 224 | } 225 | break; 226 | 227 | case self::STATE_IN_NUMBER: 228 | if (preg_match('/\d/', $c)) { 229 | $this->buffer .= $c; 230 | } elseif ($c === '.') { 231 | if (strpos($this->buffer, '.') !== false) { 232 | throw new ParsingError($this->lineNumber, $this->charNumber, 233 | "Cannot have multiple decimal points in a number."); 234 | } elseif (stripos($this->buffer, 'e') !== false) { 235 | throw new ParsingError($this->lineNumber, $this->charNumber, 236 | "Cannot have a decimal point in an exponent."); 237 | } 238 | $this->buffer .= $c; 239 | } elseif ($c === 'e' || $c === 'E') { 240 | if (stripos($this->buffer, 'e') !== false) { 241 | throw new ParsingError($this->lineNumber, $this->charNumber, 242 | "Cannot have multiple exponents in a number."); 243 | } 244 | $this->buffer .= $c; 245 | } elseif ($c === '+' || $c === '-') { 246 | $last = mb_substr($this->buffer, -1); 247 | if (!($last === 'e' || $last === 'E')) { 248 | throw new ParsingError($this->lineNumber, $this->charNumber, 249 | "Can only have '+' or '-' after the 'e' or 'E' in a number."); 250 | } 251 | $this->buffer .= $c; 252 | } else { 253 | $this->endNumber(); 254 | // we have consumed one beyond the end of the number 255 | $this->consumeChar($c); 256 | } 257 | break; 258 | 259 | case self::STATE_IN_TRUE: 260 | $this->buffer .= $c; 261 | if (mb_strlen($this->buffer) === 4) { 262 | $this->endTrue(); 263 | } 264 | break; 265 | 266 | case self::STATE_IN_FALSE: 267 | $this->buffer .= $c; 268 | if (mb_strlen($this->buffer) === 5) { 269 | $this->endFalse(); 270 | } 271 | break; 272 | 273 | case self::STATE_IN_NULL: 274 | $this->buffer .= $c; 275 | if (mb_strlen($this->buffer) === 4) { 276 | $this->endNull(); 277 | } 278 | break; 279 | 280 | case self::STATE_DONE: 281 | throw new ParsingError($this->lineNumber, $this->charNumber, 282 | "Expected end of document."); 283 | 284 | default: 285 | throw new ParsingError($this->lineNumber, $this->charNumber, 286 | "Internal error. Reached an unknown state: ".$this->state); 287 | } 288 | } 289 | 290 | /** 291 | * @param $c 292 | * @return int 293 | */ 294 | private function isHexCharacter($c) 295 | { 296 | return preg_match('/[0-9a-fA-F]/u', $c); 297 | } 298 | 299 | /** 300 | * @param $num 301 | * @return string 302 | * 303 | * Thanks: http://stackoverflow.com/questions/1805802/php-convert-unicode-codepoint-to-utf-8 304 | */ 305 | private function convertCodepointToCharacter($num) 306 | { 307 | if($num<=0x7F) return chr($num); 308 | if($num<=0x7FF) return chr(($num>>6)+192) . chr(($num&63)+128); 309 | if($num<=0xFFFF) return chr(($num>>12)+224) . chr((($num>>6)&63)+128) . chr(($num&63)+128); 310 | if($num<=0x1FFFFF) return chr(($num>>18)+240) . chr((($num>>12)&63)+128) . 311 | chr((($num>>6)&63)+128) . chr(($num&63)+128); 312 | 313 | return ''; 314 | } 315 | 316 | /** 317 | * @param $c 318 | * @return int 319 | */ 320 | private function isDigit($c) 321 | { 322 | // Only concerned with the first character in a number. 323 | return preg_match('/[0-9]|-/u', $c); 324 | } 325 | 326 | /** 327 | * @param $c 328 | * @throws JsonParser\ParsingError 329 | */ 330 | private function startValue($c) 331 | { 332 | if ($c === '[') { 333 | $this->startArray(); 334 | } elseif ($c === '{') { 335 | $this->startObject(); 336 | } elseif ($c === '"') { 337 | $this->startString(); 338 | } elseif ($this->isDigit($c)) { 339 | $this->startNumber($c); 340 | } elseif ($c === 't') { 341 | $this->state = self::STATE_IN_TRUE; 342 | $this->buffer .= $c; 343 | } elseif ($c === 'f') { 344 | $this->state = self::STATE_IN_FALSE; 345 | $this->buffer .= $c; 346 | } elseif ($c === 'n') { 347 | $this->state = self::STATE_IN_NULL; 348 | $this->buffer .= $c; 349 | } else { 350 | throw new ParsingError($this->lineNumber, $this->charNumber, 351 | "Unexpected character for value: " . $c); 352 | } 353 | } 354 | 355 | /** 356 | * 357 | */ 358 | private function startArray() 359 | { 360 | $this->listener->start_array(); 361 | $this->state = self::STATE_IN_ARRAY; 362 | array_push($this->stack, self::STACK_ARRAY); 363 | } 364 | 365 | /** 366 | * @throws JsonParser\ParsingError 367 | */ 368 | private function endArray() 369 | { 370 | $popped = array_pop($this->stack); 371 | if ($popped !== self::STACK_ARRAY) { 372 | throw new ParsingError($this->lineNumber, $this->charNumber, 373 | "Unexpected end of array encountered."); 374 | } 375 | $this->listener->end_array(); 376 | $this->state = self::STATE_AFTER_VALUE; 377 | 378 | if (empty($this->stack)) { 379 | $this->endDocument(); 380 | } 381 | } 382 | 383 | /** 384 | * 385 | */ 386 | private function startObject() 387 | { 388 | $this->listener->start_object(); 389 | $this->state = self::STATE_IN_OBJECT; 390 | array_push($this->stack, self::STACK_OBJECT); 391 | } 392 | 393 | /** 394 | * @throws JsonParser\ParsingError 395 | */ 396 | private function endObject() 397 | { 398 | $popped = array_pop($this->stack); 399 | if ($popped !== self::STACK_OBJECT) { 400 | throw new ParsingError($this->lineNumber, $this->charNumber, 401 | "Unexpected end of object encountered."); 402 | } 403 | $this->listener->end_object(); 404 | $this->state = self::STATE_AFTER_VALUE; 405 | 406 | if (empty($this->stack)) { 407 | $this->endDocument(); 408 | } 409 | } 410 | 411 | /** 412 | * 413 | */ 414 | private function startString() 415 | { 416 | array_push($this->stack, self::STACK_STRING); 417 | $this->state = self::STATE_IN_STRING; 418 | } 419 | 420 | /** 421 | * 422 | */ 423 | private function startKey() 424 | { 425 | array_push($this->stack, self::STACK_KEY); 426 | $this->state = self::STATE_IN_STRING; 427 | } 428 | 429 | /** 430 | * @throws JsonParser\ParsingError 431 | */ 432 | private function endString() 433 | { 434 | $popped = array_pop($this->stack); 435 | if ($popped === self::STACK_KEY) { 436 | $this->listener->key($this->buffer); 437 | $this->state = self::STATE_END_KEY; 438 | } elseif ($popped === self::STACK_STRING) { 439 | $this->listener->value($this->buffer); 440 | $this->state = self::STATE_AFTER_VALUE; 441 | } else { 442 | throw new ParsingError($this->lineNumber, $this->charNumber, 443 | "Unexpected end of string."); 444 | } 445 | $this->buffer = ''; 446 | } 447 | 448 | /** 449 | * @param $c 450 | * @throws JsonParser\ParsingError 451 | */ 452 | private function processEscapeCharacter($c) 453 | { 454 | if ($c === '"') { 455 | $this->buffer .= '"'; 456 | } elseif ($c === '\\') { 457 | $this->buffer .= '\\'; 458 | } elseif ($c === '/') { 459 | $this->buffer .= '/'; 460 | } elseif ($c === 'b') { 461 | $this->buffer .= "\x08"; 462 | } elseif ($c === 'f') { 463 | $this->buffer .= "\f"; 464 | } elseif ($c === 'n') { 465 | $this->buffer .= "\n"; 466 | } elseif ($c === 'r') { 467 | $this->buffer .= "\r"; 468 | } elseif ($c === 't') { 469 | $this->buffer .= "\t"; 470 | } elseif ($c === 'u') { 471 | $this->state = self::STATE_UNICODE; 472 | } else { 473 | throw new ParsingError($this->lineNumber, $this->charNumber, 474 | "Expected escaped character after backslash. Got: ".$c); 475 | } 476 | 477 | if ($this->state !== self::STATE_UNICODE) { 478 | $this->state = self::STATE_IN_STRING; 479 | } 480 | } 481 | 482 | /** 483 | * @param $c 484 | * @throws JsonParser\ParsingError 485 | */ 486 | private function processUnicodeCharacter($c) 487 | { 488 | if (!$this->isHexCharacter($c)) { 489 | throw new ParsingError($this->lineNumber, $this->charNumber, 490 | "Expected hex character for escaped unicode character. Unicode parsed: " . implode($this->unicodeBuffer) . " and got: ".$c); 491 | } 492 | array_push($this->unicodeBuffer, $c); 493 | if (count($this->unicodeBuffer) === 4) { 494 | $codepoint = hexdec(implode($this->unicodeBuffer)); 495 | 496 | if ($codepoint >= 0xD800 && $codepoint < 0xDC00) { 497 | $this->unicodeHighCodePoint = $codepoint; 498 | $this->unicodeBuffer = array(); 499 | } elseif ($codepoint >= 0xDC00 && $codepoint <= 0xDFFF) { 500 | if ($this->unicodeHighCodePoint === -1) { 501 | throw new ParsingError($this->lineNumber, $this->charNumber, 502 | "Missing high codepoint for unicode low codepoint."); 503 | } 504 | $combined_codepoint = (($this->unicodeHighCodePoint - 0xD800) * 0x400) + ($codepoint - 0xDC00) + 0x10000; 505 | 506 | $this->endUnicodeCharacter($combined_codepoint); 507 | } else { 508 | $this->endUnicodeCharacter($codepoint); 509 | } 510 | } 511 | } 512 | 513 | /** 514 | * @param $codepoint 515 | */ 516 | private function endUnicodeCharacter($codepoint) 517 | { 518 | $this->buffer .= $this->convertCodepointToCharacter($codepoint); 519 | $this->unicodeBuffer = array(); 520 | $this->unicodeHighCodePoint = -1; 521 | $this->state = self::STATE_IN_STRING; 522 | } 523 | 524 | /** 525 | * @param $c 526 | */ 527 | private function startNumber($c) 528 | { 529 | $this->state = self::STATE_IN_NUMBER; 530 | $this->buffer .= $c; 531 | } 532 | 533 | /** 534 | * 535 | */ 536 | private function endNumber() 537 | { 538 | $num = $this->buffer; 539 | if (preg_match('/\./', $num)) { 540 | $num = (float) ($num); 541 | } else { 542 | $num = (int) ($num); 543 | } 544 | $this->listener->value($num); 545 | 546 | $this->buffer = ''; 547 | $this->state = self::STATE_AFTER_VALUE; 548 | } 549 | 550 | /** 551 | * @throws JsonParser\ParsingError 552 | */ 553 | private function endTrue() 554 | { 555 | $true = $this->buffer; 556 | if ($true === 'true') { 557 | $this->listener->value(true); 558 | } else { 559 | throw new ParsingError($this->lineNumber, $this->charNumber, 560 | "Expected 'true'. Got: ".$true); 561 | } 562 | $this->buffer = ''; 563 | $this->state = self::STATE_AFTER_VALUE; 564 | } 565 | 566 | /** 567 | * @throws JsonParser\ParsingError 568 | */ 569 | private function endFalse() 570 | { 571 | $false = $this->buffer; 572 | if ($false === 'false') { 573 | $this->listener->value(false); 574 | } else { 575 | throw new ParsingError($this->lineNumber, $this->charNumber, 576 | "Expected 'false'. Got: ".$false); 577 | } 578 | $this->buffer = ''; 579 | $this->state = self::STATE_AFTER_VALUE; 580 | } 581 | 582 | /** 583 | * @throws JsonParser\ParsingError 584 | */ 585 | private function endNull() 586 | { 587 | $null = $this->buffer; 588 | if ($null === 'null') { 589 | $this->listener->value(null); 590 | } else { 591 | throw new ParsingError($this->lineNumber, $this->charNumber, 592 | "Expected 'null'. Got: ".$null); 593 | } 594 | $this->buffer = ''; 595 | $this->state = self::STATE_AFTER_VALUE; 596 | } 597 | 598 | /** 599 | * 600 | */ 601 | private function endDocument() 602 | { 603 | $this->listener->end_document(); 604 | $this->state = self::STATE_DONE; 605 | } 606 | 607 | } 608 | -------------------------------------------------------------------------------- /Parser/ListenerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Parser; 11 | 12 | /** 13 | * Interface ListenerInterface 14 | * @package Bcn\Component\Json\Parser 15 | */ 16 | interface ListenerInterface 17 | { 18 | /** 19 | * @param integer $line 20 | * @param integer $char 21 | * @return void 22 | */ 23 | public function file_position($line, $char); 24 | 25 | /** 26 | * @return void 27 | */ 28 | public function start_document(); 29 | 30 | /** 31 | * @return void 32 | */ 33 | public function end_document(); 34 | 35 | /** 36 | * @return void 37 | */ 38 | public function start_object(); 39 | 40 | /** 41 | * @return void 42 | */ 43 | public function end_object(); 44 | 45 | /** 46 | * @return void 47 | */ 48 | public function start_array(); 49 | 50 | /** 51 | * @return void 52 | */ 53 | public function end_array(); 54 | 55 | /** 56 | * @param string $key 57 | * @return void 58 | */ 59 | public function key($key); 60 | 61 | /** 62 | * @param string $value 63 | * @return void 64 | */ 65 | public function value($value); 66 | 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PHP JSON Component 2 | ================== 3 | 4 | [![Build Status](https://github.com/skolodyazhnyy/json-stream/workflows/CI/badge.svg)](https://github.com/skolodyazhnyy/json-stream/actions?query=workflow%3ACI) 5 | 6 | This project is a rewritten fork of few very nice JSON libs for PHP: 7 | 8 | - [salsify/jsonstreamingparser](https://github.com/salsify/jsonstreamingparser) - Json Stream Parser 9 | - [rayward/json-stream](https://github.com/rayward/json-stream) - Json Stream Writer 10 | 11 | 12 | JSON Writer 13 | ----------- 14 | 15 | There is an example of product catalog export using JSON Writer 16 | 17 | ```php 18 | $fh = fopen($filename, "w"); 19 | $writer = new Writer($fh); 20 | 21 | $writer->enter(Writer::TYPE_OBJECT); // enter root object 22 | $writer->write("catalog", $catalog['id']); // write key-value entry 23 | $writer->enter("items", Writer::TYPE_ARRAY); // enter items array 24 | foreach($catalog['products'] as $product) { 25 | $writer->write(null, array( // write an array item 26 | 'sku' => $product['sku'], 27 | 'name' => $product['name'] 28 | )); 29 | } 30 | $writer->leave(); // leave items array 31 | $writer->leave(); // leave root object 32 | 33 | fclose($fh); 34 | ``` 35 | 36 | **Output** 37 | 38 | ```json 39 | {"catalog":19,"items":[{"sku":"0001","name":"Product #1"},{"sku":"0002","name":"Product #2"}]} 40 | ``` 41 | 42 | 43 | JSON Reader 44 | ----------- 45 | 46 | Using JSON Reader you can easily read json generated by code above 47 | 48 | ```php 49 | $fh = fopen($filename, "r"); 50 | 51 | $reader = new Reader($fh); 52 | $reader->enter(Reader::TYPE_OBJECT); // enter root object 53 | $catalog['id'] = $reader->read("catalog"); // read catalog node 54 | $reader->enter("items", Reader::TYPE_ARRAY); // enter item array 55 | while($product = $reader->read()) { // read product structure 56 | $catalog['products'][] = $product; 57 | } 58 | $reader->leave(); // leave item node 59 | $reader->leave(); // leave root object 60 | 61 | fclose($fh); 62 | ``` 63 | -------------------------------------------------------------------------------- /Reader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json; 11 | 12 | use Bcn\Component\Json\Exception\ReadingError; 13 | use Bcn\Component\Json\Reader\Tokenizer; 14 | 15 | class Reader 16 | { 17 | 18 | const TYPE_OBJECT = 1; 19 | const TYPE_ARRAY = 2; 20 | 21 | /** @var \Bcn\Component\Json\Reader\Tokenizer */ 22 | protected $tokenizer; 23 | 24 | /** @var array */ 25 | protected $token; 26 | 27 | /** 28 | * @param resource $resource 29 | */ 30 | public function __construct($resource) 31 | { 32 | $this->tokenizer = new Tokenizer($resource); 33 | $this->token = $this->tokenizer->next(); 34 | } 35 | 36 | /** 37 | * @param null|string $key 38 | * @param null|string $type 39 | * @return bool 40 | */ 41 | public function enter($key = null, $type = null) 42 | { 43 | if ($type === null && in_array($key, array(self::TYPE_OBJECT, self::TYPE_ARRAY), true)) { 44 | $type = $key; 45 | $key = null; 46 | } 47 | 48 | switch ($type) { 49 | case self::TYPE_ARRAY: 50 | $tokens = array(Tokenizer::TOKEN_ARRAY_START); 51 | break; 52 | case self::TYPE_OBJECT: 53 | $tokens = array(Tokenizer::TOKEN_OBJECT_START); 54 | break; 55 | default: 56 | $tokens = array(Tokenizer::TOKEN_ARRAY_START, Tokenizer::TOKEN_OBJECT_START); 57 | } 58 | 59 | if ($this->token['key'] != $key || !in_array($this->token['token'], $tokens)) { 60 | return false; 61 | } 62 | 63 | $this->next(); 64 | 65 | return true; 66 | } 67 | 68 | /** 69 | * @return bool 70 | */ 71 | public function leave() 72 | { 73 | if (!($context = $this->tokenizer->context())) { 74 | return false; 75 | } 76 | 77 | $level = 0; 78 | do { 79 | switch ($this->token['token']) { 80 | case Tokenizer::TOKEN_ARRAY_START: 81 | case Tokenizer::TOKEN_OBJECT_START: 82 | $level++; 83 | break; 84 | case Tokenizer::TOKEN_ARRAY_END: 85 | case Tokenizer::TOKEN_OBJECT_END: 86 | $level--; 87 | break; 88 | } 89 | 90 | } while ($this->next() && $level >= 0 && $this->tokenizer->context()); 91 | 92 | return true; 93 | } 94 | 95 | /** 96 | * @param string|null $key 97 | * @return mixed 98 | */ 99 | public function read($key = null) 100 | { 101 | if ($this->token['key'] != $key) { 102 | return false; 103 | } 104 | 105 | switch ($this->token['token']) { 106 | case Tokenizer::TOKEN_SCALAR: 107 | $value = $this->token['content']; 108 | $this->next(); 109 | 110 | return $value; 111 | case Tokenizer::TOKEN_ARRAY_START: 112 | $items = array(); 113 | $this->enter($this->token['key']); 114 | while ($this->token['token'] != Tokenizer::TOKEN_ARRAY_END) { 115 | $items[] = $this->read(); 116 | 117 | if (!$this->token) { 118 | throw new ReadingError("Unexpected object ending"); 119 | } 120 | } 121 | $this->leave(); 122 | 123 | return $items; 124 | case Tokenizer::TOKEN_OBJECT_START: 125 | $items = array(); 126 | $this->enter($this->token['key']); 127 | while ($this->token['token'] != Tokenizer::TOKEN_OBJECT_END) { 128 | $items[$this->token['key']] = $this->read($this->token['key']); 129 | 130 | if (!$this->token) { 131 | throw new ReadingError("Unexpected object ending"); 132 | } 133 | } 134 | $this->leave(); 135 | 136 | return $items; 137 | } 138 | 139 | return null; 140 | } 141 | 142 | /** 143 | * 144 | */ 145 | protected function next() 146 | { 147 | return $this->token = $this->tokenizer->next(); 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /Reader/Tokenizer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Reader; 11 | 12 | use Bcn\Component\Json\Exception\ReadingError; 13 | 14 | class Tokenizer 15 | { 16 | 17 | const CONTEXT_OBJECT = 1; 18 | const CONTEXT_ARRAY = 2; 19 | 20 | const TOKEN_OBJECT_START = 1; 21 | const TOKEN_OBJECT_END = 2; 22 | const TOKEN_ARRAY_START = 4; 23 | const TOKEN_ARRAY_END = 8; 24 | const TOKEN_SCALAR = 16; 25 | const TOKEN_KEY = 32; 26 | const TOKEN_ITEM_SEPARATOR = 64; 27 | 28 | const EXPECTED_ANY = 127; 29 | const EXPECTED_ARRAY_ITEM = 29; // Object Start, Array Start, Scalar, Array End 30 | const EXPECTED_OBJECT_ITEM = 23; // Object Start, Array Start, Scalar, Object End 31 | const EXPECTED_SEPARATOR = 64; 32 | const EXPECTED_KEY = 32; 33 | const EXPECTED_ARRAY_END = 8; 34 | const EXPECTED_OBJECT_END = 2; 35 | 36 | /** @var resource */ 37 | protected $stream; 38 | 39 | /** @var array */ 40 | protected $context = array(); 41 | protected $expected; 42 | 43 | /** @var array */ 44 | protected $token = array(); 45 | 46 | /** @var array */ 47 | protected $buffered = array(); 48 | 49 | /** @var array */ 50 | protected $first = array(); 51 | 52 | /** 53 | * @param resource $stream 54 | * @throws \InvalidArgumentException 55 | */ 56 | public function __construct($stream) 57 | { 58 | if (!is_resource($stream) || get_resource_type($stream) != 'stream') { 59 | throw new \InvalidArgumentException("Argument is not a stream"); 60 | } 61 | 62 | $this->stream = $stream; 63 | $this->expected = self::EXPECTED_ANY; 64 | } 65 | 66 | /** 67 | * @return array 68 | * @throws ReadingError 69 | */ 70 | public function next() 71 | { 72 | $this->token = $this->fetch(); 73 | 74 | if (!$this->token['token']) { 75 | return false; 76 | } 77 | 78 | if (!($this->token['token'] & $this->expected)) { 79 | throw new ReadingError(sprintf("Read unexpected token %s/%s", $this->token['token'], $this->expected)); 80 | } 81 | 82 | switch ($this->token['token']) { 83 | case self::TOKEN_ARRAY_START: 84 | $this->context[] = self::CONTEXT_ARRAY; 85 | $this->expected = self::EXPECTED_ARRAY_ITEM; 86 | break; 87 | case self::TOKEN_OBJECT_START: 88 | $this->context[] = self::CONTEXT_OBJECT; 89 | $this->expected = self::EXPECTED_OBJECT_ITEM; 90 | break; 91 | case self::TOKEN_OBJECT_END: 92 | case self::TOKEN_ARRAY_END: 93 | array_pop($this->context); 94 | // no break; 95 | case self::TOKEN_SCALAR: 96 | if ($this->context()) { 97 | $this->expected = self::EXPECTED_SEPARATOR; 98 | $this->expected |= $this->context() == self::CONTEXT_ARRAY ? 99 | self::EXPECTED_ARRAY_END :self::EXPECTED_OBJECT_END; 100 | } else { 101 | $this->expected = 0; 102 | } 103 | break; 104 | case self::TOKEN_ITEM_SEPARATOR: 105 | $this->expected = $this->context() == self::CONTEXT_ARRAY ? 106 | self::EXPECTED_ARRAY_ITEM : self::EXPECTED_OBJECT_ITEM; 107 | 108 | return $this->next(); 109 | 110 | } 111 | 112 | return $this->token; 113 | } 114 | 115 | /** 116 | * @return array 117 | * @throws ReadingError 118 | */ 119 | protected function fetch() 120 | { 121 | if ($this->context() == self::CONTEXT_OBJECT) { 122 | list($token, $key) = $this->readKey(); 123 | if ($token != self::TOKEN_KEY) { 124 | return array( 125 | 'key' => null, 126 | 'token' => $token, 127 | 'content' => null 128 | ); 129 | } 130 | } else { 131 | $key = null; 132 | } 133 | 134 | list($token, $content) = $this->readValue(); 135 | 136 | return array( 137 | 'key' => $key, 138 | 'token' => $token, 139 | 'content' => $content 140 | ); 141 | } 142 | 143 | /** 144 | * @throws ReadingError 145 | */ 146 | protected function readKey() 147 | { 148 | list($token, $key) = $this->readKeyToken(); 149 | 150 | if ($token == self::TOKEN_KEY) { 151 | $char = $this->findSymbol(); 152 | if ($char != ":") { 153 | throw new ReadingError(sprintf("Expecting key-value separator, got \"%s\"", $char)); 154 | } 155 | } 156 | 157 | return array($token, $key); 158 | } 159 | 160 | /** 161 | * @return array 162 | */ 163 | protected function readKeyToken() 164 | { 165 | $char = $this->findSymbol(); 166 | 167 | switch ($char) { 168 | case "}": 169 | return array(self::TOKEN_OBJECT_END, null); 170 | case "]": 171 | return array(self::TOKEN_ARRAY_END, null); 172 | case ",": 173 | return array(self::TOKEN_ITEM_SEPARATOR, null); 174 | case "\"": 175 | return array(self::TOKEN_KEY, $this->completeStringReading($char)); 176 | } 177 | 178 | return array(null, null); 179 | } 180 | 181 | /** 182 | * @return array 183 | * @throws ReadingError 184 | */ 185 | protected function readValue() 186 | { 187 | $char = $this->findSymbol(); 188 | 189 | if ($char === "" || $char === false) { 190 | return array(null, null); 191 | } 192 | 193 | switch ($char) { 194 | case "{": 195 | return array(self::TOKEN_OBJECT_START, null); 196 | case "}": 197 | return array(self::TOKEN_OBJECT_END, null); 198 | case "[": 199 | return array(self::TOKEN_ARRAY_START, null); 200 | case "]": 201 | return array(self::TOKEN_ARRAY_END, null); 202 | case ",": 203 | return array(self::TOKEN_ITEM_SEPARATOR, null); 204 | case "\"": 205 | return array(self::TOKEN_SCALAR, $this->completeStringReading($char)); 206 | default: 207 | return array(self::TOKEN_SCALAR, $this->completeScalarReading($char)); 208 | } 209 | 210 | } 211 | 212 | /** 213 | * @param $char 214 | * @throws ReadingError 215 | * @return string 216 | */ 217 | protected function completeStringReading($char) 218 | { 219 | $quotes = $char; 220 | $buffer = ""; 221 | $escaped = false; 222 | 223 | while (true) { 224 | $char = $this->readSymbol(); 225 | if ($char === false || $char === "") { 226 | break; 227 | } 228 | if ($quotes == $char && !$escaped) { 229 | return json_decode($quotes . $buffer . $quotes); 230 | } 231 | $buffer .= $char; 232 | $escaped = !$escaped && $quotes === "\"" && $char == "\\"; 233 | } 234 | 235 | throw new ReadingError("String not terminated correctly " . ftell($this->stream)); 236 | } 237 | 238 | /** 239 | * @param $char 240 | * @return string 241 | * @throws ReadingError 242 | */ 243 | protected function completeScalarReading($char) 244 | { 245 | $buffer = $char; 246 | 247 | while (true) { 248 | $char = $this->readSymbol(); 249 | if ($char === "" || $char === false || strpos(",}] \t\n\r", $char) !== false) { 250 | if ($char && strpos(",}]", $char) !== false) { 251 | $this->buffered[] = $char; 252 | } 253 | break; 254 | } 255 | $buffer .= $char; 256 | } 257 | 258 | switch ($buffer) { 259 | case "true": 260 | return true; 261 | case "false": 262 | return false; 263 | case "null": 264 | return null; 265 | } 266 | 267 | if (!preg_match('/^-?(?:0|[1-9]\d*)?(?:\.\d+)?(?:[eE][+-]?\d+)?$/', $buffer)) { 268 | throw new ReadingError(sprintf("Scalar value \"%s\" is invalid", $buffer)); 269 | } 270 | 271 | return floatval($buffer); 272 | } 273 | 274 | /** 275 | * @return string 276 | */ 277 | protected function findSymbol() 278 | { 279 | while (($char = $this->readSymbol()) && strpos(" \n\r\t", $char) !== false); 280 | 281 | return $char; 282 | } 283 | 284 | /** 285 | * @return string 286 | */ 287 | protected function readSymbol() 288 | { 289 | if ($this->buffered) { 290 | return array_pop($this->buffered); 291 | } 292 | 293 | return fread($this->stream, 1); 294 | } 295 | 296 | /** 297 | * @return mixed 298 | */ 299 | public function context() 300 | { 301 | return end($this->context); 302 | } 303 | 304 | } 305 | -------------------------------------------------------------------------------- /Tests/ExampleTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Tests; 11 | 12 | use Bcn\Component\Json\Reader; 13 | use Bcn\Component\Json\Writer; 14 | use Bcn\Component\StreamWrapper\Stream; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | class ExampleTest extends TestCase 18 | { 19 | 20 | public function testWriting() 21 | { 22 | $catalog = $this->getData(); 23 | $filename = new Stream(); 24 | 25 | $fh = fopen($filename, "w"); 26 | $writer = new Writer($fh); 27 | 28 | $writer->enter(Writer::TYPE_OBJECT); // enter root object 29 | $writer->write("catalog", $catalog['id']); // write key-value entry 30 | $writer->enter("items", Writer::TYPE_ARRAY); // enter items array 31 | foreach ($catalog['products'] as $product) { 32 | $writer->write(null, array( // write an array item 33 | 'sku' => $product['sku'], 34 | 'name' => $product['name'] 35 | )); 36 | } 37 | $writer->leave(); // leave items array 38 | $writer->leave(); // leave root object 39 | 40 | fclose($fh); 41 | 42 | static::assertEquals($this->getJSON(), $filename->getContent()); 43 | } 44 | 45 | /** 46 | * 47 | */ 48 | public function testReading() 49 | { 50 | $filename = new Stream($this->getJSON()); 51 | $catalog = array(); 52 | 53 | $fh = fopen($filename, "r"); 54 | 55 | $reader = new Reader($fh); 56 | $reader->enter(Reader::TYPE_OBJECT); // enter root object 57 | $catalog['id'] = $reader->read("catalog"); // read catalog node 58 | $reader->enter("items", Reader::TYPE_ARRAY); // enter item array 59 | while ($product = $reader->read()) { // read product structure 60 | $catalog['products'][] = $product; 61 | } 62 | $reader->leave(); // leave item node 63 | $reader->leave(); // leave root object 64 | 65 | fclose($fh); 66 | 67 | static::assertEquals($this->getData(), $catalog); 68 | } 69 | 70 | /** 71 | * @return array 72 | */ 73 | protected function getData() 74 | { 75 | return array( 76 | 'id' => 19, 77 | 'products' => array( 78 | array("sku" => "0001", "name" => "Product #1"), 79 | array("sku" => "0002", "name" => "Product #2") 80 | ) 81 | ); 82 | } 83 | 84 | /** 85 | * @return string 86 | */ 87 | protected function getJSON() 88 | { 89 | return '{"catalog":19,"items":[{"sku":"0001","name":"Product #1"},{"sku":"0002","name":"Product #2"}]}'; 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /Tests/Parser/FunctionalTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Tests\Parser; 11 | 12 | use Bcn\Component\Json\Exception\ParsingError; 13 | use Bcn\Component\Json\Parser; 14 | use Bcn\Component\Json\Tests\Parser\FunctionalTest\TestListener; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | /** 18 | * Class FunctionalTest 19 | * @package Bcn\Component\Json\Tests\Parser 20 | */ 21 | class FunctionalTest extends TestCase 22 | { 23 | 24 | /** 25 | * 26 | */ 27 | public function testTraverseOrder() 28 | { 29 | $listener = new TestListener(); 30 | $parser = new Parser(fopen($this->getFixtureName('example.json'), 'r'), $listener); 31 | $parser->parse(); 32 | 33 | static::assertEquals( 34 | array( 35 | 'start_document', 36 | 'start_array', 37 | 'start_object', 38 | 'key = name', 39 | 'value = example document for wicked fast parsing of huge json docs', 40 | 'key = integer', 41 | 'value = 123', 42 | 'key = totally sweet scientific notation', 43 | 'value = -1.23123', 44 | 'key = unicode? you betcha!', 45 | 'value = ú™£¢∞§♥', 46 | 'key = zero character', 47 | 'value = 0', 48 | 'key = null is boring', 49 | 'value = NULL', 50 | 'end_object', 51 | 'start_object', 52 | 'key = name', 53 | 'value = another object', 54 | 'key = cooler than first object?', 55 | 'value = 1', 56 | 'key = nested object', 57 | 'start_object', 58 | 'key = nested object?', 59 | 'value = 1', 60 | 'key = is nested array the same combination i have on my luggage?', 61 | 'value = 1', 62 | 'key = nested array', 63 | 'start_array', 64 | 'value = 1', 65 | 'value = 2', 66 | 'value = 3', 67 | 'value = 4', 68 | 'value = 5', 69 | 'end_array', 70 | 'end_object', 71 | 'key = false', 72 | 'value = false', 73 | 'end_object', 74 | 'end_array', 75 | 'end_document', 76 | ), 77 | $listener->order 78 | ); 79 | } 80 | 81 | /** 82 | * 83 | */ 84 | public function testListenerGetsNotifiedAboutPositionInFileOfDataRead() 85 | { 86 | $listener = new TestListener(); 87 | $parser = new Parser(fopen($this->getFixtureName('data-ranges.json'), 'r'), $listener); 88 | $parser->parse(); 89 | 90 | static::assertEquals( 91 | array( 92 | array('value' => '2013-10-24', 'line' => 5, 'char' => 42), 93 | array('value' => '2013-10-25', 'line' => 5, 'char' => 67), 94 | array('value' => '2013-10-26', 'line' => 6, 'char' => 42), 95 | array('value' => '2013-10-27', 'line' => 6, 'char' => 67), 96 | array('value' => '2013-11-01', 'line' => 10, 'char' => 46), 97 | array('value' => '2013-11-10', 'line' => 10, 'char' => 71), 98 | ), 99 | $listener->positions 100 | ); 101 | } 102 | 103 | public function testCountsLongLinesCorrectly() 104 | { 105 | $value = str_repeat('!', 10000); 106 | $longStream = self::inMemoryStream(<<parse(); 117 | 118 | unset($listener->positions[0]['value']); 119 | unset($listener->positions[1]['value']); 120 | 121 | static::assertSame(array( 122 | array('line' => 2, 'char' => 10004,), 123 | array('line' => 3, 'char' => 10004,), 124 | ), 125 | $listener->positions 126 | ); 127 | } 128 | 129 | /** 130 | * 131 | */ 132 | public function testThrowsParingError() 133 | { 134 | $listener = new TestListener(); 135 | $parser = new Parser(self::inMemoryStream('{ invalid json }'), $listener); 136 | 137 | $this->expectException(ParsingError::class); 138 | $parser->parse(); 139 | } 140 | 141 | /** 142 | * @param $content 143 | * @return resource 144 | */ 145 | private static function inMemoryStream($content) 146 | { 147 | $stream = fopen('php://memory', 'rw'); 148 | fwrite($stream, $content); 149 | fseek($stream, 0); 150 | 151 | return $stream; 152 | } 153 | 154 | /** 155 | * @param string $name 156 | * @return string 157 | */ 158 | protected function getFixtureName($name) 159 | { 160 | return __DIR__ . '/FunctionalTest/fixtures/' . $name; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Tests/Parser/FunctionalTest/TestListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Tests\Parser\FunctionalTest; 11 | use Bcn\Component\Json\Parser\ListenerInterface; 12 | 13 | /** 14 | * Class TestListener 15 | * @package Bcn\Component\Json\Tests\Parser\FunctionalTest 16 | */ 17 | class TestListener implements ListenerInterface 18 | { 19 | 20 | /** @var array */ 21 | public $order = array(); 22 | /** @var array */ 23 | public $positions = array(); 24 | /** @var integer */ 25 | private $currentLine; 26 | /** @var integer */ 27 | private $currentChar; 28 | 29 | public function file_position($line, $char) 30 | { 31 | $this->currentLine = $line; 32 | $this->currentChar = $char; 33 | } 34 | 35 | public function start_document() 36 | { 37 | $this->order[] = __FUNCTION__; 38 | } 39 | 40 | public function end_document() 41 | { 42 | $this->order[] = __FUNCTION__; 43 | } 44 | 45 | public function start_object() 46 | { 47 | $this->order[] = __FUNCTION__; 48 | } 49 | 50 | public function end_object() 51 | { 52 | $this->order[] = __FUNCTION__; 53 | } 54 | 55 | public function start_array() 56 | { 57 | $this->order[] = __FUNCTION__; 58 | } 59 | 60 | public function end_array() 61 | { 62 | $this->order[] = __FUNCTION__; 63 | } 64 | 65 | public function key($key) 66 | { 67 | $this->order[] = __FUNCTION__ . ' = ' . self::stringify($key); 68 | } 69 | 70 | public function value($value) 71 | { 72 | $this->order[] = __FUNCTION__ . ' = ' . self::stringify($value); 73 | $this->positions[] = array('value' => $value, 'line' => $this->currentLine, 'char' => $this->currentChar); 74 | } 75 | 76 | private static function stringify($value) 77 | { 78 | return strlen($value) ? $value : var_export($value, true); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Tests/Parser/FunctionalTest/fixtures/data-ranges.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | {"rows" : 4 | [ 5 | {"startDate": "2013-10-24", "endDate": "2013-10-25"}, 6 | {"startDate": "2013-10-26", "endDate": "2013-10-27"} 7 | ] 8 | } 9 | ], 10 | "anotherPlace": {"startDate": "2013-11-01", "endDate": "2013-11-10"} 11 | } -------------------------------------------------------------------------------- /Tests/Parser/FunctionalTest/fixtures/escaped-chars.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "First line\nSecond line\ttabulation\\back slash\rcarriage return\fformfeed\/slash\bbackspace" 3 | } -------------------------------------------------------------------------------- /Tests/Parser/FunctionalTest/fixtures/example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "example document for wicked fast parsing of huge json docs", 4 | "integer": 123, 5 | "totally sweet scientific notation": -123.123e-2, 6 | "unicode? you betcha!": "ú™£¢∞§\u2665", 7 | "zero character": "0", 8 | "null is boring": null 9 | }, 10 | { 11 | "name": "another object", 12 | "cooler than first object?": true, 13 | "nested object": { 14 | "nested object?": true, 15 | "is nested array the same combination i have on my luggage?": true, 16 | "nested array": [1,2,3,4,5] 17 | }, 18 | "false": false 19 | } 20 | ] -------------------------------------------------------------------------------- /Tests/Parser/FunctionalTest/fixtures/plain.json: -------------------------------------------------------------------------------- 1 | { 2 | "key 1" : "value 1", 3 | "key 2" : "value 2", 4 | "key 3" : "value 3" 5 | } -------------------------------------------------------------------------------- /Tests/Reader/TokenizerTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Tests\Reader; 11 | 12 | use Bcn\Component\Json\Reader\Tokenizer; 13 | use Bcn\Component\StreamWrapper\Stream; 14 | use PHPUnit\Framework\TestCase; 15 | use Symfony\Component\Yaml\Yaml; 16 | 17 | class TokenizerTest extends TestCase 18 | { 19 | 20 | /** 21 | * @param string $content 22 | * @param array $tokens 23 | * 24 | * @dataProvider provideTokens 25 | */ 26 | public function testTokenizer($content, array $tokens) 27 | { 28 | $resource = fopen(new Stream($content), "r"); 29 | 30 | $reader = new Tokenizer($resource); 31 | foreach ($tokens as $token) { 32 | $token['token'] = $this->toTokenCode($token['token']); 33 | static::assertEquals($token, $reader->next()); 34 | } 35 | 36 | fclose($resource); 37 | } 38 | 39 | protected function toTokenCode($code) 40 | { 41 | switch ($code) { 42 | case 'scalar': return Tokenizer::TOKEN_SCALAR; 43 | case 'array_start': return Tokenizer::TOKEN_ARRAY_START; 44 | case 'object_start': return Tokenizer::TOKEN_OBJECT_START; 45 | case 'array_end': return Tokenizer::TOKEN_ARRAY_END; 46 | case 'object_end': return Tokenizer::TOKEN_OBJECT_END; 47 | } 48 | 49 | return $code; 50 | } 51 | 52 | /** 53 | * @return array 54 | */ 55 | public function provideTokens() 56 | { 57 | return Yaml::parse(__DIR__ . DIRECTORY_SEPARATOR . "fixtures" . DIRECTORY_SEPARATOR . "tokenizer.yml"); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Tests/Reader/fixtures/tokenizer.yml: -------------------------------------------------------------------------------- 1 | 'Double-quoted string': 2 | content: '"Hello"' 3 | tokens: 4 | - { key: null, token: scalar, content: Hello } 5 | 6 | 'Zero string - 0000001': 7 | content: '"0000001"' 8 | tokens: 9 | - { key: null, token: scalar, content: "0000001" } 10 | 11 | 'Double-quoted string with special chars': 12 | content: '"Hello \"World\""' 13 | tokens: [{ key: null, token: scalar, content: 'Hello "World"' }] 14 | 15 | 'Number 99.99': 16 | content: '99.99' 17 | tokens: [{ key: null, token: scalar, content: 99.99 }] 18 | 19 | 'Number .99': 20 | content: '.99' 21 | tokens: [{ key: null, token: scalar, content: 0.99 }] 22 | 23 | 'Number 0.001': 24 | content: '0.001' 25 | tokens: [{ key: null, token: scalar, content: 0.001 }] 26 | 27 | 'Number -.5': 28 | content: '-.5' 29 | tokens: [{ key: null, token: scalar, content: -0.5 }] 30 | 31 | 'Number -.01e15': 32 | content: '-.01e15' 33 | tokens: [{ key: null, token: scalar, content: -10000000000000 }] 34 | 35 | 'Number -5e-7': 36 | content: '-5e-7' 37 | tokens: [{ key: null, token: scalar, content: -0.0000005 }] 38 | 39 | 'True': 40 | content: 'true' 41 | tokens: [{ key: null, token: scalar, content: true }] 42 | 43 | 'False': 44 | content: 'false' 45 | tokens: [{ key: null, token: scalar, content: false }] 46 | 47 | 'Null': 48 | content: 'null' 49 | tokens: [{ key: null, token: scalar, content: null }] 50 | 51 | 'Object': 52 | content: '{}' 53 | tokens: 54 | - { key: null, token: object_start, content: null } 55 | - { key: null, token: object_end, content: null } 56 | 57 | 'Array': 58 | content: '[]' 59 | tokens: 60 | - { key: null, token: array_start, content: null } 61 | - { key: null, token: array_end, content: null } 62 | 63 | 'Skip leading whitespaces': 64 | content: "\n\t\r \"String\"" 65 | tokens: [{ key: null, token: scalar, content: String }] 66 | 67 | 'Single item array': 68 | content: "[1.55]" 69 | tokens: 70 | - { key: null, token: array_start, content: null } 71 | - { key: null, token: scalar, content: 1.55 } 72 | - { key: null, token: array_end, content: null } 73 | 74 | 'Multiple items array': 75 | content: "[1.55,99.2,\"Hola\"]" 76 | tokens: 77 | - { key: null, token: array_start, content: null } 78 | - { key: null, token: scalar, content: 1.55 } 79 | - { key: null, token: scalar, content: 99.2 } 80 | - { key: null, token: scalar, content: Hola } 81 | - { key: null, token: array_end, content: null } 82 | 83 | 'Multiple items array with spaces': 84 | content: "[ 1.55 ,\n 99.2 , \"Abc\" ]" 85 | tokens: 86 | - { key: null, token: array_start, content: null } 87 | - { key: null, token: scalar, content: 1.55 } 88 | - { key: null, token: scalar, content: 99.2 } 89 | - { key: null, token: scalar, content: "Abc" } 90 | - { key: null, token: array_end, content: null } 91 | 92 | 'Nested array': 93 | content: "[[1,[\"a\",[],\"b\"],2]]" 94 | tokens: 95 | - { key: null, token: array_start, content: null } 96 | - { key: null, token: array_start, content: null } 97 | - { key: null, token: scalar, content: 1 } 98 | - { key: null, token: array_start, content: null } 99 | - { key: null, token: scalar, content: 'a' } 100 | - { key: null, token: array_start, content: null } 101 | - { key: null, token: array_end, content: null } 102 | - { key: null, token: scalar, content: 'b' } 103 | - { key: null, token: array_end, content: null } 104 | - { key: null, token: scalar, content: 2 } 105 | - { key: null, token: array_end, content: null } 106 | - { key: null, token: array_end, content: null } 107 | 108 | 'Single property object': 109 | content: "{ \"Root\":1}" 110 | tokens: 111 | - { key: null, token: object_start, content: null } 112 | - { key: "Root", token: scalar, content: 1 } 113 | 114 | 'Multiple property object': 115 | content: "{\"Child A\": \"VALUE1\", \"Child B\": [] }" 116 | tokens: 117 | - { key: null, token: object_start, content: null } 118 | - { key: "Child A", token: scalar, content: "VALUE1" } 119 | - { key: "Child B", token: array_start, content: null } 120 | - { key: null, token: array_end, content: null } 121 | - { key: null, token: object_end, content: null } 122 | 123 | 'Nested object': 124 | content: "{\"Root\": { \"Child A\": \"VALUE1\", \"Child B\": [] } }" 125 | tokens: 126 | - { key: null, token: object_start, content: null } 127 | - { key: "Root", token: object_start, content: null } 128 | - { key: "Child A", token: scalar, content: "VALUE1" } 129 | - { key: "Child B", token: array_start, content: null } 130 | - { key: null, token: array_end, content: null } 131 | - { key: null, token: object_end, content: null } 132 | - { key: null, token: object_end, content: null } 133 | -------------------------------------------------------------------------------- /Tests/ReaderTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Tests; 11 | 12 | use Bcn\Component\Json\Exception\ReadingError; 13 | use Bcn\Component\Json\Reader; 14 | use Bcn\Component\StreamWrapper\Stream; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | class ReaderTest extends TestCase 18 | { 19 | 20 | /** 21 | * 22 | */ 23 | public function testObjectReading() 24 | { 25 | $stream = new Stream(<<enter(null, Reader::TYPE_OBJECT)); // enter root object 41 | $catalog = $reader->read("catalog"); // read property catalog 42 | $stock = $reader->read("stock"); // read property stock 43 | $items = array(); 44 | static::assertTrue($reader->enter("items")); // enter property items 45 | while ($reader->enter()) { // enter each item 46 | $sku = $reader->read("sku"); // read property sku 47 | $qty = $reader->read("qty"); // read property qty 48 | $reader->leave(); // leave item node 49 | 50 | $items[] = array("sku" => $sku, "qty" => $qty); 51 | } 52 | $reader->leave(); // leave items node 53 | $reader->leave(); // leave root node 54 | 55 | static::assertEquals("catalog_code", $catalog); 56 | static::assertEquals("stock_code", $stock); 57 | static::assertEquals(array( 58 | array('sku' => 'ABC', 'qty' => 1), 59 | array('sku' => 'A"BC', 'qty' => .095), 60 | array('sku' => 'CDE', 'qty' => 0) 61 | ), $items); 62 | 63 | } 64 | /** 65 | * 66 | */ 67 | public function testExtractReading() 68 | { 69 | $stream = new Stream(<< 'catalog_code', 85 | 'stock' => 'stock_code', 86 | 'items' => array( 87 | array('sku' => 'ABC', 'qty' => 1), 88 | array('sku' => 'A"BC', 'qty' => 0.095), 89 | array('sku' => 'CDE', 'qty' => 0), 90 | ) 91 | ), $reader->read()); 92 | } 93 | 94 | /** 95 | * 96 | */ 97 | public function testReaderLeaving() 98 | { 99 | $stream = new Stream(<<enter(null, Reader::TYPE_OBJECT)); // enter root object 117 | $first = $reader->read("first"); // read property first 118 | static::assertTrue($reader->enter("items")); // enter property items 119 | static::assertTrue($reader->leave()); // leave items node 120 | $last = $reader->read("last"); // leave root node 121 | 122 | static::assertEquals("1", $first); 123 | static::assertEquals("2", $last); 124 | 125 | } 126 | 127 | /** 128 | * @param $string 129 | * @param $data 130 | * 131 | * @dataProvider provideReadingData 132 | */ 133 | public function testReading($string, $data) 134 | { 135 | $stream = new Stream($string); 136 | $reader = new Reader(fopen($stream, "r")); 137 | static::assertEquals($data, $reader->read()); 138 | } 139 | 140 | /** 141 | * @return array 142 | */ 143 | public function provideReadingData() 144 | { 145 | return array( 146 | "Double quoted string" => array('"test"', "test"), 147 | "Escaped string" => array(json_encode("\"!@\n\t#$%^&*()_+/\\\"\\'"), "\"!@\n\t#$%^&*()_+/\\\"\\'"), 148 | "Integer" => array("12345", 12345), 149 | "Float" => array("123.45", 123.45), 150 | ); 151 | } 152 | 153 | /** 154 | * @param $content 155 | * @dataProvider provideMalformedFiles 156 | */ 157 | public function testMalformedFileReading($content) 158 | { 159 | $this->expectException(ReadingError::class); 160 | 161 | $reader = new Reader(fopen(new Stream($content), "r")); 162 | $reader->read(); 163 | } 164 | 165 | /** 166 | * @return array 167 | */ 168 | public function provideMalformedFiles() 169 | { 170 | return array( 171 | 'Unquoted string' => array('string'), 172 | 'Unwrapped array items' => array('"Array", "Array"'), 173 | 'Property name in non-object context' => array('"key": "Value"'), 174 | 'Property name in array' => array('["key": "Value"]'), 175 | 'Malformed object' => array('{"key": "Value": "Test"}') 176 | ); 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /Tests/WriterTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json\Tests; 11 | 12 | use Bcn\Component\Json\Writer; 13 | use PHPUnit\Framework\TestCase; 14 | 15 | /** 16 | * Class WriterTest 17 | * @package Bcn\Component\Json\Tests 18 | */ 19 | class WriterTest extends TestCase 20 | { 21 | 22 | /** 23 | * @param $data 24 | * 25 | * @dataProvider provideEncodeData 26 | */ 27 | public function testWrite($data) 28 | { 29 | $stream = fopen("php://memory", "r+"); 30 | $writer = new Writer($stream); 31 | $writer->write(null, $data); 32 | 33 | rewind($stream); 34 | $encoded = stream_get_contents($stream); 35 | fclose($stream); 36 | 37 | static::assertEquals($data, json_decode($encoded, true), $encoded); 38 | } 39 | 40 | public function testWriteWithCustomOptions() 41 | { 42 | $options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; 43 | 44 | $stream = fopen("php://memory", "r+"); 45 | $writer = new Writer($stream, $options); 46 | $writer->write(null, "Les mystérieuses cités d'or"); 47 | 48 | rewind($stream); 49 | $encoded = stream_get_contents($stream); 50 | fclose($stream); 51 | 52 | static::assertEquals('"Les mystérieuses cités d\'or"', $encoded); 53 | } 54 | 55 | /** 56 | * @return array 57 | */ 58 | public function provideEncodeData() 59 | { 60 | return array( 61 | 'String' => array("String"), 62 | 'String with special chars' => array("String\"\'\\\/!@#$%^&*()!@¡™£¢"), 63 | 'Integer' => array(1), 64 | 'Decimal' => array(9.9991119), 65 | 'Array' => array(1, 2, 3), 66 | 'Object' => array('a' => 1, 'b' => 2), 67 | 'Array of objects' => array(array(array('a' => 1, 'b' => 2), array('a' => 1, 'b' => 2))), 68 | 'Multilevel object' => array(array('a' => array('a' => 1, 'b' => 2), 'b' => array('a' => 1, 'b' => 2))), 69 | 'Object with special keys' => array(array('¡Hola!' => 'Hello!')) 70 | ); 71 | } 72 | 73 | /** 74 | * @param $items 75 | * 76 | * @dataProvider provideArrayData 77 | */ 78 | public function testArrayWriting($items) 79 | { 80 | $stream = fopen("php://memory", "r+"); 81 | $writer = new Writer($stream); 82 | $writer->enter(); 83 | foreach ($items as $item) { 84 | $writer->write(null, $item); 85 | } 86 | $writer->leave(); 87 | 88 | rewind($stream); 89 | $encoded = stream_get_contents($stream); 90 | fclose($stream); 91 | 92 | static::assertEquals($items, json_decode($encoded, true), $encoded); 93 | } 94 | 95 | /** 96 | * @return array 97 | */ 98 | public function provideArrayData() 99 | { 100 | return array( 101 | 'Array without items' => array(array()), 102 | 'Array with one item' => array(array(1)), 103 | 'Array with multiple items' => array(array(1, 2, 3)), 104 | 'Nested array' => array(array(array(1, 2), array(2, 3), 4)) 105 | ); 106 | } 107 | 108 | /** 109 | * @param $items 110 | * 111 | * @dataProvider provideObjectData 112 | */ 113 | public function testObjectWriting($items) 114 | { 115 | $stream = fopen("php://memory", "r+"); 116 | $writer = new Writer($stream); 117 | $writer->enter(Writer::TYPE_OBJECT); 118 | foreach ($items as $key => $item) { 119 | $writer->write($key, $item); 120 | } 121 | $writer->leave(); 122 | 123 | rewind($stream); 124 | $encoded = stream_get_contents($stream); 125 | fclose($stream); 126 | 127 | static::assertEquals($items, json_decode($encoded, true), $encoded); 128 | } 129 | 130 | /** 131 | * @return array 132 | */ 133 | public function provideObjectData() 134 | { 135 | return array( 136 | 'Empty object' => array(array()), 137 | 'Object with one item' => array(array('a' => 1)), 138 | 'Object with numeric keys' => array(array('2' => 1, '11' => 3)), 139 | 'Object with multiple items' => array(array('a' => 1, 'b' => 2, 'c' => 3)), 140 | 'Nested object' => array(array( 141 | 'a' => array('aa' => 1, 'ab' => 2), 142 | 'b' => array('ba' => 2, 'bb' => 3), 143 | 'c' => 4 144 | ) 145 | ) 146 | ); 147 | } 148 | 149 | /** 150 | * 151 | */ 152 | public function testBrackets() 153 | { 154 | $stream = fopen("php://memory", "r+"); 155 | $writer = new Writer($stream); 156 | $writer 157 | ->enter(Writer::TYPE_OBJECT) 158 | ->enter("key", Writer::TYPE_ARRAY) 159 | ->enter(Writer::TYPE_OBJECT) 160 | ->enter("inner", Writer::TYPE_ARRAY) 161 | ->enter() 162 | ->leave() 163 | ->leave() 164 | ->leave() 165 | ->leave() 166 | ->leave() 167 | ->leave() 168 | ; 169 | 170 | rewind($stream); 171 | $encoded = stream_get_contents($stream); 172 | fclose($stream); 173 | 174 | static::assertEquals( 175 | array("key" => array(array('inner' => array(array())))), 176 | json_decode($encoded, true), 177 | $encoded 178 | ); 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /Writer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | */ 9 | 10 | namespace Bcn\Component\Json; 11 | use Bcn\Component\Json\Exception\WritingError; 12 | 13 | /** 14 | * Class Writer 15 | * @package Bcn\Component\Json 16 | */ 17 | class Writer 18 | { 19 | 20 | const TYPE_OBJECT = 1; 21 | const TYPE_ARRAY = 2; 22 | const TYPE_NULL = 3; 23 | const TYPE_BOOL = 4; 24 | const TYPE_SCALAR = 5; 25 | 26 | const CONTEXT_NONE = 0; 27 | const CONTEXT_ARRAY = 1; 28 | const CONTEXT_ARRAY_START = 2; 29 | const CONTEXT_OBJECT = 3; 30 | const CONTEXT_OBJECT_START = 4; 31 | 32 | protected $stream; 33 | protected $options; 34 | protected $context; 35 | 36 | protected $parents = array(); 37 | 38 | /** 39 | * @param resource $stream A stream resource. 40 | * @param int $options JSON encoding options. 41 | * @throws \InvalidArgumentException If $stream is not a stream resource. 42 | */ 43 | public function __construct($stream, $options = null) 44 | { 45 | if (!is_resource($stream) || get_resource_type($stream) != 'stream') { 46 | throw new \InvalidArgumentException("Resource is not a stream"); 47 | } 48 | 49 | if ($options === null) { 50 | $options = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT; 51 | } 52 | 53 | if (!is_int($options)) { 54 | throw new \InvalidArgumentException("Options should be an integer"); 55 | } 56 | 57 | $this->stream = $stream; 58 | $this->options = $options; 59 | $this->context = self::CONTEXT_NONE; 60 | } 61 | 62 | /** 63 | * @param $key 64 | * @param $value 65 | * @param null $type 66 | * @return $this 67 | * @throws Exception\WritingError 68 | */ 69 | public function write($key, $value, $type = null) 70 | { 71 | if ($value instanceof \JsonSerializable) { 72 | $value = $value->jsonSerialize(); 73 | } 74 | 75 | switch ($type ? : $this->getType($value)) { 76 | case self::TYPE_NULL: 77 | $this->prefix($key); 78 | $this->streamWrite('null'); 79 | break; 80 | case self::TYPE_BOOL: 81 | $this->prefix($key); 82 | $this->streamWrite($value ? 'true' : 'false'); 83 | break; 84 | case self::TYPE_SCALAR: 85 | $this->prefix($key); 86 | $this->scalar($value); 87 | break; 88 | case self::TYPE_ARRAY: 89 | $this->encodeArray($key, $value); 90 | break; 91 | case self::TYPE_OBJECT: 92 | $this->encodeObject($key, $value); 93 | break; 94 | default: 95 | throw new WritingError("Unrecognized type"); 96 | } 97 | 98 | $this->streamFlush(); 99 | 100 | return $this; 101 | } 102 | 103 | /** 104 | * @param string $key 105 | * @param int $type 106 | * @return $this 107 | */ 108 | public function enter($key = null, $type = null) 109 | { 110 | if ($type === null) { 111 | if (in_array($key, array(self::TYPE_OBJECT, self::TYPE_ARRAY))) { 112 | $type = $key; 113 | $key = null; 114 | } else { 115 | $type = self::TYPE_ARRAY; 116 | } 117 | } 118 | $this->prefix($key); 119 | 120 | array_push($this->parents, $this->context); 121 | $this->context = $type == self::TYPE_OBJECT ? self::CONTEXT_OBJECT_START : self::CONTEXT_ARRAY_START; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * @return $this 128 | */ 129 | public function leave() 130 | { 131 | switch ($this->context) { 132 | case self::CONTEXT_OBJECT: 133 | $this->streamWrite("}"); 134 | break; 135 | case self::CONTEXT_OBJECT_START: 136 | $this->streamWrite("{}"); 137 | break; 138 | case self::CONTEXT_ARRAY: 139 | $this->streamWrite("]"); 140 | break; 141 | case self::CONTEXT_ARRAY_START: 142 | $this->streamWrite("[]"); 143 | break; 144 | } 145 | 146 | $this->context = array_pop($this->parents); 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * @param $value 153 | * @return string 154 | */ 155 | protected function getType($value) 156 | { 157 | if (is_bool($value)) { 158 | return self::TYPE_BOOL; 159 | } 160 | if (is_scalar($value)) { 161 | return self::TYPE_SCALAR; 162 | } 163 | if (is_array($value) && empty($value)) { 164 | return self::TYPE_ARRAY; 165 | } 166 | if (is_array($value) && !$this->isAssociative($value)) { 167 | return self::TYPE_ARRAY; 168 | } 169 | if (is_object($value) && $value instanceof \Traversable) { 170 | return self::TYPE_ARRAY; 171 | } 172 | if (is_object($value) || is_array($value)) { 173 | return self::TYPE_OBJECT; 174 | } 175 | 176 | return self::TYPE_NULL; 177 | } 178 | 179 | /** 180 | * @param $value 181 | * @return $this 182 | */ 183 | public function scalar($value) 184 | { 185 | $this->streamWrite(json_encode($value, $this->options)); 186 | 187 | return $this; 188 | } 189 | 190 | /** 191 | * @param $value 192 | * @return bool 193 | */ 194 | protected function isAssociative($value) 195 | { 196 | if (is_object($value) && $value instanceof \Traversable && !($value instanceof \ArrayAccess)) { 197 | return false; 198 | } 199 | 200 | if (!is_array($value)) { 201 | return true; 202 | } 203 | 204 | $keys = array_keys($value); 205 | sort($keys); 206 | 207 | return end($keys) !== count($keys) - 1; 208 | } 209 | 210 | /** 211 | * @param $key 212 | * @param $array 213 | */ 214 | protected function encodeArray($key, $array) 215 | { 216 | $this->enter($key, self::TYPE_ARRAY); 217 | foreach ($array as $value) { 218 | $this->write(null, $value); 219 | } 220 | $this->leave(); 221 | } 222 | 223 | /** 224 | * @param $key 225 | * @param $object 226 | */ 227 | protected function encodeObject($key, $object) 228 | { 229 | $this->enter($key, self::TYPE_OBJECT); 230 | foreach ($object as $key => $value) { 231 | $this->write((string) $key, $value); 232 | } 233 | $this->leave(); 234 | } 235 | 236 | /** 237 | * @param $value 238 | */ 239 | protected function streamWrite($value) 240 | { 241 | fwrite($this->stream, $value); 242 | } 243 | 244 | /** 245 | * 246 | */ 247 | protected function streamFlush() 248 | { 249 | fflush($this->stream); 250 | } 251 | 252 | /** 253 | * @param $key 254 | */ 255 | protected function key($key) 256 | { 257 | $this->scalar((string) $key); 258 | $this->streamWrite(":"); 259 | } 260 | 261 | /** 262 | * @param $key 263 | */ 264 | protected function prefix($key) 265 | { 266 | switch ($this->context) { 267 | case self::CONTEXT_OBJECT_START: 268 | $this->streamWrite("{"); 269 | $this->key($key); 270 | $this->context = self::CONTEXT_OBJECT; 271 | break; 272 | case self::CONTEXT_ARRAY_START: 273 | $this->streamWrite("["); 274 | $this->context = self::CONTEXT_ARRAY; 275 | break; 276 | case self::CONTEXT_OBJECT: 277 | $this->streamWrite(','); 278 | $this->key($key); 279 | break; 280 | case self::CONTEXT_ARRAY: 281 | $this->streamWrite(','); 282 | break; 283 | } 284 | } 285 | 286 | } 287 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bcncommerce/json-stream", 3 | "description": "A bundle of tools to work with JSON in PHP", 4 | "keywords": ["json", "parser", "streaming", "writer", "reader"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Sergey Kolodyazhnyy", 10 | "email": "sergey.kolodyazhnyy@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=7.3", 15 | "ext-json": "*" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^9.5", 19 | "bcncommerce/stream-wrapper": "dev-master", 20 | "symfony/yaml": "^5.2" 21 | }, 22 | "autoload": { 23 | "psr-0": { "Bcn\\Component\\Json\\": "" } 24 | }, 25 | "target-dir": "Bcn/Component/Json" 26 | } 27 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./Tests 16 | 17 | 18 | --------------------------------------------------------------------------------