diff --git a/src/main/php/text/json/Input.class.php b/src/main/php/text/json/Input.class.php index b2a28cb..b2a4935 100755 --- a/src/main/php/text/json/Input.class.php +++ b/src/main/php/text/json/Input.class.php @@ -274,6 +274,15 @@ public function pairs() { return new Pairs($this); } + /** + * Reads pointers from an input stream sequentially + * + * @return text.json.Pairs + */ + public function pointers() { + return new Pointers($this); + } + /** @return void */ public function close() { // Does nothing diff --git a/src/main/php/text/json/Pointers.class.php b/src/main/php/text/json/Pointers.class.php new file mode 100644 index 0000000..fba9f2a --- /dev/null +++ b/src/main/php/text/json/Pointers.class.php @@ -0,0 +1,97 @@ +input= $input; } + + /** + * Yields pointers + * + * @param var $token + * @param string $base + * @return iterable + */ + private function pointers($token, $base= '') { + static $escape= ['/' => '~1', '~' => '~0']; + + if (true === $token[0]) { + yield $base => $token[1]; + } else if ('{' === $token) { + yield $base => Types::$OBJECT; + + do { + $key= $this->input->nextToken(); + if ('}' === $key) break; + + $token= $this->input->nextToken(); + if (':' === $token) { + yield from $this->pointers($this->input->nextToken(), $base.'/'.strtr($key[1], $escape)); + } else { + throw new FormatException('Unexpected token ['.Objects::stringOf($token).'] reading object, expecting ":"'); + } + + $token= $this->input->nextToken(); + if (',' === $token) { + continue; + } else if ('}' === $token) { + break; + } else { + throw new FormatException('Unexpected token ['.Objects::stringOf($token).'] reading object, expecting "," or "}"'); + } + } while (true); + } else if ('[' === $token) { + yield $base => Types::$ARRAY; + + $i= 0; + do { + $value= $this->input->nextToken(); + if (']' === $value) break; + + yield from $this->pointers($value, $base.'/'.($i++)); + + $token= $this->input->nextToken(); + if (',' === $token) { + continue; + } else if (']' === $token) { + break; + } else { + throw new FormatException('Unexpected token ['.Objects::stringOf($token).'] reading array, expecting "," or "]"'); + } + } while (true); + } else if ('true' === $token) { + yield $base => true; + } else if ('false' === $token) { + yield $base => false; + } else if ('null' === $token) { + yield $base => null; + } else if (is_numeric($token)) { + yield $base => $token > PHP_INT_MAX || $token < -PHP_INT_MAX- 1 || strcspn($token, '.eE') < strlen($token) + ? (float)$token + : (int)$token + ; + } else { + throw new FormatException('Unexpected token ['.Objects::stringOf($token).'] reading value'); + } + } + + /** Start yielding from first input token */ + public function getIterator(): Traversable { + if (null === ($token= $this->input->firstToken())) { + throw new FormatException('Empty input'); + } + + yield from $this->pointers($token); + } +} \ No newline at end of file diff --git a/src/test/php/text/json/unittest/JsonInputTest.class.php b/src/test/php/text/json/unittest/JsonInputTest.class.php index bfc3827..0e82f5d 100755 --- a/src/test/php/text/json/unittest/JsonInputTest.class.php +++ b/src/test/php/text/json/unittest/JsonInputTest.class.php @@ -343,6 +343,36 @@ public function reading_malformed_pairs_sequentially() { } } + #[Test] + public function read_pointers() { + $source= <<<'JSON' + { + "data": { + "b64_json": "VGVzdA==" + }, + "meta": { + "tokens": 6100, + "dimensions": [1792, 1024], + } + } + JSON + ; + + Assert::equals( + [ + '' => Types::$OBJECT, + '/data' => Types::$OBJECT, + '/data/b64_json' => 'VGVzdA==', + '/meta' => Types::$OBJECT, + '/meta/tokens' => 6100, + '/meta/dimensions' => Types::$ARRAY, + '/meta/dimensions/0' => 1792, + '/meta/dimensions/1' => 1024, + ], + iterator_to_array($this->input($source)->pointers()) + ); + } + #[Test] public function read_long_text() { $str= str_repeat('*', 0xFFFF); diff --git a/src/test/php/text/json/unittest/PointersTest.class.php b/src/test/php/text/json/unittest/PointersTest.class.php new file mode 100644 index 0000000..d656fa5 --- /dev/null +++ b/src/test/php/text/json/unittest/PointersTest.class.php @@ -0,0 +1,134 @@ + $expected], iterator_to_array(new Pointers(new StringInput($input)))); + } + + #[Test] + public function empty_array() { + Assert::equals(['' => Types::$ARRAY], iterator_to_array(new Pointers(new StringInput('[]')))); + } + + #[Test] + public function empty_object() { + Assert::equals(['' => Types::$OBJECT], iterator_to_array(new Pointers(new StringInput('{}')))); + } + + #[Test] + public function rfc_example() { + $input= <<<'JSON' + { + "foo": ["bar", "baz"], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + " ": 7, + "m~n": 8 + } + JSON + ; + Assert::equals( + [ + '' => Types::$OBJECT, + '/foo' => Types::$ARRAY, + '/foo/0' => 'bar', + '/foo/1' => 'baz', + '/' => 0, + '/a~1b' => 1, + '/c%d' => 2, + '/e^f' => 3, + '/g|h' => 4, + '/i\\j' => 5, + '/k"l' => 6, + '/ ' => 7, + '/m~0n' => 8, + ], + iterator_to_array(new Pointers(new StringInput($input))) + ); + } + + #[Test] + public function composer_file() { + $input= <<<'JSON' + { + "name": "example/test", + "keywords": ["module", "xp"], + "require": { + "xp-forge/json": "^6.1", + "php": ">=7.4.0" + }, + "autoload" : { + "files" : ["src/main/php/autoload.php"] + } + } + JSON + ; + Assert::equals( + [ + '' => Types::$OBJECT, + '/name' => 'example/test', + '/keywords' => Types::$ARRAY, + '/keywords/0' => 'module', + '/keywords/1' => 'xp', + '/require' => Types::$OBJECT, + '/require/xp-forge~1json' => '^6.1', + '/require/php' => '>=7.4.0', + '/autoload' => Types::$OBJECT, + '/autoload/files' => Types::$ARRAY, + '/autoload/files/0' => 'src/main/php/autoload.php', + ], + iterator_to_array(new Pointers(new StringInput($input))) + ); + } + + #[Test, Expect(class: FormatException::class, message: 'Empty input')] + public function empty_input() { + iterator_to_array(new Pointers(new StringInput(''))); + } + + #[Test, Expect(class: FormatException::class, message: 'Unexpected token ["test"] reading value')] + public function invalid_literal() { + iterator_to_array(new Pointers(new StringInput('test'))); + } + + #[Test, Expect(class: FormatException::class, message: 'Unexpected token ["2"] reading array, expecting "," or "]"')] + public function missing_comma_in_array() { + iterator_to_array(new Pointers(new StringInput('[1 2]'))); + } + + #[Test, Expect(class: FormatException::class, message: 'Unexpected token ["2"] reading object, expecting ":"')] + public function missing_colon_in_object() { + iterator_to_array(new Pointers(new StringInput('{"key" 2}'))); + } + + #[Test, Expect(class: FormatException::class, message: 'Unexpected token ["2"] reading object, expecting "," or "}"')] + public function missing_comma_in_object() { + iterator_to_array(new Pointers(new StringInput('{"key": "value" 2}'))); + } +} \ No newline at end of file