Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/main/php/text/json/Input.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions src/main/php/text/json/Pointers.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php namespace text\json;

use IteratorAggregate, Traversable;
use lang\FormatException;
use util\Objects;

/**
* JSON pointers enumeration
*
* @test text.json.unittest.PointersTest
* @see https://datatracker.ietf.org/doc/html/rfc6901
*/
class Pointers implements IteratorAggregate {
private $input;

/** @param text.json.Input $input */
public function __construct(Input $input) { $this->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);
}
}
30 changes: 30 additions & 0 deletions src/test/php/text/json/unittest/JsonInputTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
134 changes: 134 additions & 0 deletions src/test/php/text/json/unittest/PointersTest.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php namespace text\json\unittest;

use lang\FormatException;
use test\{Assert, Expect, Test, Values};
use text\json\{Pointers, Types, StringInput};

class PointersTest {

/** @return iterable */
private function values() {
yield ['"test"', 'test'];
yield ['true', true];
yield ['false', false];
yield ['null', null];
yield ['1', 1];
yield ['1.5', 1.5];
}

#[Test]
public function can_create() {
new Pointers(new StringInput(''));
}

#[Test, Values(from: 'values')]
public function toplevel($input, $expected) {
Assert::equals(['' => $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}')));
}
}
Loading