From d78316c5d8b1567f352ce5c2f69fb18539edcc2c Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Wed, 5 Mar 2025 22:40:22 +0100 Subject: [PATCH 1/5] Basic implementation of a custom parser --- composer.json | 10 +- src/Json.php | 6 +- src/JsonError.php | 2 +- src/Parser/Location.php | 12 + src/Parser/Parser.php | 162 ++++++++++++ src/Parser/Span.php | 31 +++ src/Parser/SyntaxError.php | 20 ++ src/Parser/Token.php | 34 +++ src/Parser/TokenLocation.php | 14 + src/Parser/Tokenizer.php | 249 ++++++++++++++++++ src/Type/JsonType.php | 2 +- .../InvalidArrayConstructorParamTag.php | 3 +- tests/unit/Parser/ParserTest.php | 100 +++++++ tests/unit/Parser/TokenizerTest.php | 101 +++++++ 14 files changed, 735 insertions(+), 11 deletions(-) create mode 100644 src/Parser/Location.php create mode 100644 src/Parser/Parser.php create mode 100644 src/Parser/Span.php create mode 100644 src/Parser/SyntaxError.php create mode 100644 src/Parser/Token.php create mode 100644 src/Parser/TokenLocation.php create mode 100644 src/Parser/Tokenizer.php create mode 100644 tests/unit/Parser/ParserTest.php create mode 100644 tests/unit/Parser/TokenizerTest.php diff --git a/composer.json b/composer.json index 97ff953..d707cc2 100644 --- a/composer.json +++ b/composer.json @@ -12,12 +12,12 @@ "infection/infection": "^0.26.20", "maglnet/composer-require-checker": "^4.6", "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-strict-rules": "^1.5", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^10.1", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.23" + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26" }, "config": { "allow-plugins": { diff --git a/src/Json.php b/src/Json.php index 56e3be0..fb5bb0c 100644 --- a/src/Json.php +++ b/src/Json.php @@ -128,7 +128,7 @@ private static function decodeClass(string $json, object|string $value): object throw JsonError::decodeFailed(error_get_last()['message'] ?? null); } if (!is_array($data)) { - throw JsonError::decodeFailed(sprintf("Expected JSON object, got %s", gettype($data))); + throw JsonError::decodeFailed(sprintf('Expected JSON object, got %s', gettype($data))); } /** @psalm-suppress DocblockTypeContradiction */ if (!is_string($value)) { @@ -485,13 +485,13 @@ private static function parseUseStatements(string $file): array if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = ($matches['ns'] ?? '') . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; } return $useStatements; } /** - * @return list | array + * @return list | array */ private static function createConstructorArgumentForArrayType( ReflectionParameter $parameter, diff --git a/src/JsonError.php b/src/JsonError.php index 83ddb3a..63ee9d4 100644 --- a/src/JsonError.php +++ b/src/JsonError.php @@ -9,7 +9,7 @@ final class JsonError extends RuntimeException { - private function __construct(string $message = "", int $code = 0, Throwable|null $previous = null) + private function __construct(string $message = '', int $code = 0, Throwable|null $previous = null) { parent::__construct($message, $code, $previous); } diff --git a/src/Parser/Location.php b/src/Parser/Location.php new file mode 100644 index 0000000..9faf2fc --- /dev/null +++ b/src/Parser/Location.php @@ -0,0 +1,12 @@ + | bool | null + */ +final class Parser +{ + /** + * @param list $tokens + */ + public function __construct(private array &$tokens) + { + } + + /** + * @return JsonValue + */ + public static function parse(string $source): string|int|float|stdClass|array|bool|null + { + $tokens = Tokenizer::tokenize($source); + return (new self($tokens))->parseValue(); + } + + /** + * @return JsonValue + */ + private function parseValue(): string|int|float|stdClass|array|bool|null + { + $token = current($this->tokens); + if ($token === false) { + throw SyntaxError::create('Unexpected end of input', 0, 0); + } + if ($token->token === null || is_string($token->token) || is_int($token->token) || is_float($token->token) || is_bool($token->token)) { + $this->next(); + return $token->token; + } + return match ($token->token) { + Token::OpenCurly => $this->parseObject(), + Token::OpenBracket => $this->parseArray(), + default => throw SyntaxError::create( + sprintf('Unexpected token %s', Token::print($token->token)), + $token->location->start->line, + $token->location->start->column, + ), + }; + } + + private function parseObject(): stdClass + { + $object = new stdClass(); + $this->next(); + $first = true; + while (true) { + $token = current($this->tokens); + if ($token === false) { + throw SyntaxError::create('Unexpected end of input', 0, 0); + } + if ($token->token === Token::CloseCurly) { + $this->next(); + return $object; + } + if (!$first) { + if ($token->token !== Token::Comma) { + throw SyntaxError::create( + sprintf('Expected comma, got %s', Token::print($token->token)), + $token->location->start->line, + $token->location->start->column, + ); + } + $this->next(); + } + [$key, $value] = $this->parseObjectPair(); + $object->$key = $value; + $first = false; + } + } + + /** + * @return list + */ + private function parseArray(): array + { + $array = []; + $this->next(); + $first = true; + while (true) { + $token = current($this->tokens); + if ($token === false) { + throw SyntaxError::create('Unexpected end of input', 0, 0); + } + if ($token->token === Token::CloseBracket) { + $this->next(); + return $array; + } + if (!$first) { + if ($token->token !== Token::Comma) { + throw SyntaxError::create( + sprintf('Expected comma, got %s', Token::print($token->token)), + $token->location->start->line, + $token->location->start->column, + ); + } + $this->next(); + } + $array[] = $this->parseValue(); + $first = false; + } + } + + private function next(): void + { + next($this->tokens); + } + + /** + * @return array{string, JsonValue} + */ + private function parseObjectPair(): array + { + $token = current($this->tokens); + if ($token === false) { + throw SyntaxError::create('Unexpected end of input', 0, 0); + } + if (!is_string($token->token)) { + throw SyntaxError::create( + sprintf('Expected string, got %s', Token::print($token->token)), + $token->location->start->line, + $token->location->start->column, + ); + } + $key = $token->token; + $this->next(); + $token = current($this->tokens); + if ($token === false) { + throw SyntaxError::create('Unexpected end of input', 0, 0); + } + if ($token->token !== Token::Colon) { + throw SyntaxError::create( + sprintf('Expected colon, got %s', Token::print($token->token)), + $token->location->start->line, + $token->location->start->column, + ); + } + $this->next(); + return [$key, $this->parseValue()]; + } +} diff --git a/src/Parser/Span.php b/src/Parser/Span.php new file mode 100644 index 0000000..47d6bcb --- /dev/null +++ b/src/Parser/Span.php @@ -0,0 +1,31 @@ +end = $end; + } + + public static function create(int $startLine, int $startColumn, int $endLine, int $endColumn): self + { + return new self(new Location($startLine, $startColumn), new Location($endLine, $endColumn)); + } + + public static function char(int $line, int $column): self + { + return new self(new Location($line, $column), new Location($line, $column)); + } +} diff --git a/src/Parser/SyntaxError.php b/src/Parser/SyntaxError.php new file mode 100644 index 0000000..258989c --- /dev/null +++ b/src/Parser/SyntaxError.php @@ -0,0 +1,20 @@ +value; + } + if (is_string($token)) { + return '"' . str_replace(['\\', "\n"], ['\\\\', '\n'], $token) . '"'; + } + return match ($token) { + null => 'null', + true => 'true', + false => 'false', + default => (string)$token, + }; + } +} diff --git a/src/Parser/TokenLocation.php b/src/Parser/TokenLocation.php new file mode 100644 index 0000000..118abc3 --- /dev/null +++ b/src/Parser/TokenLocation.php @@ -0,0 +1,14 @@ + $chars + */ + public function __construct(private array &$chars) + { + } + + /** + * @return list + */ + public static function tokenize(string $source): array + { + $chars = mb_str_split($source); + return (new self($chars))->doTokenize(); + } + + /** + * @return list + */ + private function doTokenize(): array + { + $tokens = []; + while (true) { + $this->skipWhitespace(); + $char = current($this->chars); + if ($char === false) { + return $tokens; + } + $token = match ($char) { + self::OPEN_CURLY => Token::OpenCurly, + self::CLOSE_CURLY => Token::CloseCurly, + self::COLON => Token::Colon, + self::COMMA => Token::Comma, + self::OPEN_BRACKET => Token::OpenBracket, + self::CLOSE_BRACKET => Token::CloseBracket, + default => null, + }; + if ($token !== null) { + $tokens[] = self::charToken($token); + $this->next(); + continue; + } + if ($char === self::QUOTE) { + $tokens[] = $this->readString(); + continue; + } + if ($char === '-' || ($char >= '0' && $char <= '9')) { + $tokens[] = $this->readNumber(); + continue; + } + if ($char === 't') { + $this->expect('t'); + $this->expect('r'); + $this->expect('u'); + $this->expect('e'); + $tokens[] = new TokenLocation(true, Span::create($this->line, $this->column - 4, $this->line, $this->column)); + continue; + } + if ($char === 'f') { + $this->expect('f'); + $this->expect('a'); + $this->expect('l'); + $this->expect('s'); + $this->expect('e'); + $tokens[] = new TokenLocation(false, Span::create($this->line, $this->column - 5, $this->line, $this->column)); + continue; + } + if ($char === 'n') { + $this->expect('n'); + $this->expect('u'); + $this->expect('l'); + $this->expect('l'); + $tokens[] = new TokenLocation(null, Span::create($this->line, $this->column - 3, $this->line, $this->column)); + continue; + } + throw SyntaxError::create( + sprintf("Unexpected character '%s' at line %d, column %d", $char, $this->line, $this->column), + $this->line, + $this->column, + ); + } + } + + private function readString(): TokenLocation + { + $startLine = $this->line; + $startColumn = $this->column; + $string = ''; + $this->expect(self::QUOTE); + while (true) { + $char = current($this->chars); + if ($char === self::QUOTE) { + $this->next(); + break; + } + if ($char === false) { + throw SyntaxError::create( + sprintf('Unexpected end of input at line %d, column %d', $this->line, $this->column), + $this->line, + $this->column, + ); + } + if ($char !== self::BACKSLASH) { + $string .= $char; + $this->next(); + continue; + } + $this->next(); + $char = current($this->chars); + $string .= match ($char) { + false => throw SyntaxError::create( + sprintf('Unexpected end of input at line %d, column %d', $this->line, $this->column), + $this->line, + $this->column, + ), + 'n' => "\n", + default => $char, + }; + $this->next(); + } + return new TokenLocation($string, Span::create($startLine, $startColumn, $this->line, $this->column)); + } + + private function next(): void + { + $shifted = array_shift($this->chars); + if ($shifted === "\n") { + $this->line++; + $this->column = 1; + } else { + $this->column++; + } + } + + private function expect(string $char): void + { + $current = current($this->chars); + if ($current !== $char) { + throw SyntaxError::create( + sprintf("Expected '%s' but got '%s'", $char, $current), + $this->line, + $this->column, + ); + } + $this->next(); + } + + private function readNumber(): TokenLocation + { + $startLine = $this->line; + $startColumn = $this->column; + $isFloat = false; + $number = ''; + $char = current($this->chars); + if ($char === '-') { + $number .= '-'; + $this->next(); + } + $char = current($this->chars); + if ($char === '0') { + $number .= '0'; + $this->next(); + } else { + $number .= $this->readDigits(); + } + $char = current($this->chars); + if ($char === '.') { + $isFloat = true; + $number .= '.'; + $this->next(); + $number .= $this->readDigits(); + } + $char = current($this->chars); + if ($char === 'e' || $char === 'E') { + $number .= 'e'; + $this->next(); + $char = current($this->chars); + if ($char === '+' || $char === '-') { + $number .= $char; + $this->next(); + } + $number .= $this->readDigits(); + } + $token = $isFloat ? (float)$number : (int)$number; + return new TokenLocation($token, Span::create($startLine, $startColumn, $this->line, $this->column)); + } + + private function readDigits(): string + { + $digits = ''; + while (true) { + $char = current($this->chars); + if ($char === false) { + break; + } + if ($char < '0' || $char > '9') { + break; + } + $digits .= $char; + $this->next(); + } + return $digits; + } + + private function skipWhitespace(): void + { + while (true) { + $char = current($this->chars); + if ($char === ' ' || $char === "\t" || $char === "\n" || $char === "\r") { + $this->next(); + continue; + } + break; + } + } + + private function charToken(Token|string|int|float|bool|null $token): TokenLocation + { + return new TokenLocation($token, Span::char($this->line, $this->column)); + } +} diff --git a/src/Type/JsonType.php b/src/Type/JsonType.php index d55efde..12cf0fe 100644 --- a/src/Type/JsonType.php +++ b/src/Type/JsonType.php @@ -70,7 +70,7 @@ public static function union(self $first, self $second, self ...$other): Union protected static function joinPath(string $prefix, string|int $key): string { - return $prefix === '' ? (string)$key : sprintf("%s.%d", $prefix, $key); + return $prefix === '' ? (string)$key : sprintf('%s.%d', $prefix, $key); } /** diff --git a/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php b/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php index f8c4ee9..32f35a0 100644 --- a/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php +++ b/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php @@ -9,10 +9,11 @@ final class InvalidArrayConstructorParamTag { /** + * @phpstan-ignore-next-line phpDoc.parseError * @param class-string items This is not a valid param tag * @param list $items */ - public function __construct(public readonly array $items) // @phpstan-ignore-line + public function __construct(public readonly array $items) { } } diff --git a/tests/unit/Parser/ParserTest.php b/tests/unit/Parser/ParserTest.php new file mode 100644 index 0000000..1defc22 --- /dev/null +++ b/tests/unit/Parser/ParserTest.php @@ -0,0 +1,100 @@ + + */ + public static function provideValidJsonStrings(): iterable + { + $cases = [ + 'true', + 'false', + '""', + '"foo"', + '0', + '42', + '-42', + '3.14', + '-3.14', + '[]', + '{}', + '["foo"]', + '["foo", "bar"]', + '{"foo": "bar"}', + '{"foo": "bar", "baz": "qux"}', + ]; + foreach ($cases as $case) { + yield $case => [$case]; + } + } + + /** + * @return iterable + */ + public static function provideInvalidJsonStrings(): iterable + { + $cases = [ + '', + '{', + '}', + '{"foo"', + '{"foo":', + '{"foo": "bar"', + '{"foo": "bar",', + '{"foo": "bar" "baz": "qux"}', + '{[]: "foo"}', + '{: "foo"}', + '{null: "foo"}', + '{true: "foo"}', + '{false: "foo"}', + '{0: "foo"}', + '{42: "foo"}', + '{-42: "foo"}', + '{3.14: "foo"}', + '{-3.14: "foo"}', + '{[]: "foo"}', + '[', + ']', + '["foo"', + '["foo",', + '["foo" "bar"]', + ]; + foreach ($cases as $case) { + yield $case => [$case]; + } + } + + /** + * @dataProvider provideValidJsonStrings + */ + public function testParityWithNativeFunction(string $json): void + { + $actual = Parser::parse($json); + /** @var mixed $expected */ + $expected = json_decode($json); + + self::assertSame(json_encode($expected), json_encode($actual)); + } + + /** + * @dataProvider provideInvalidJsonStrings + */ + public function testInvalidJsonThrowsException(string $json): void + { + $this->expectException(SyntaxError::class); + + Parser::parse($json); + } +} diff --git a/tests/unit/Parser/TokenizerTest.php b/tests/unit/Parser/TokenizerTest.php new file mode 100644 index 0000000..67de3d0 --- /dev/null +++ b/tests/unit/Parser/TokenizerTest.php @@ -0,0 +1,101 @@ + + */ + public static function provideValidJsonStrings(): iterable + { + $cases = [ + 'null', + 'true', + 'false', + '""', + '"foo"', + '0', + '42', + '-42', + '3.14', + '-3.14', + '[]', + '{}', + '["foo"]', + '["foo", "bar"]', + '{"foo": "bar"}', + '{"foo": "bar", "baz": "qux"}', + '{"a": null, "b": true, "c": false, "d": "", "e": 0, "f": 42, "g": -42, "h": 3.14, "i": -3.14}', + << [$case]; + } + } + + /** + * @return iterable + */ + public static function provideInvalidJsonStrings(): iterable + { + $cases = [ + 'unexpected' => [1, 1], + '"unterminated string' => [1, 21], + '"unterminated string\\' => [1, 22], + "{\n missingstartquote\" => \"\" }" => [2, 3], + ]; + foreach ($cases as $case => [$line, $column]) { + yield $case => [$case, $line, $column]; + } + } + + /** + * @dataProvider provideValidJsonStrings + */ + public function testMatchesNative(string $json): void + { + $expected = json_encode(json_decode($json)); + $actual = implode( + '', + array_map( + static fn(TokenLocation $token) => Token::print($token->token), + Tokenizer::tokenize($json), + ), + ); + + self::assertSame($expected, $actual); + } + + /** + * @dataProvider provideInvalidJsonStrings + */ + public function testSyntaxError(string $json, int $line, int $column): void + { + try { + Tokenizer::tokenize($json); + self::fail('Expected a SyntaxError'); + } catch (SyntaxError $e) { + self::assertSame($line, $e->location->line); + self::assertSame($column, $e->location->column); + } + } +} From c4babbdcbd41be90692413705bebd1665968d044 Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Wed, 5 Mar 2025 22:43:03 +0100 Subject: [PATCH 2/5] Require the mbstring extension --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d707cc2..cb4d6d6 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "license": "MIT", "require": { "php": ">=8.1", - "ext-json": "*" + "ext-json": "*", + "ext-mbstring": "*" }, "require-dev": { "eventjet/coding-standard": "^3.15", From 410e16e2bbfe375249e70d2a591f5ab9e54cd251 Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Wed, 5 Mar 2025 22:44:28 +0100 Subject: [PATCH 3/5] Upgrade to v4 of actions/upload-artifact --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8459a8e..18a35e9 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -174,7 +174,7 @@ jobs: -jmax - name: Save Infection result - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: infection-log-${{ matrix.php }}-${{ matrix.deps }}.txt From 2bd2c505b874d7bb8b9d85f977195e0ba8207ca1 Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Wed, 5 Mar 2025 22:46:42 +0100 Subject: [PATCH 4/5] Delete a duplicate test case --- tests/unit/Parser/ParserTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/Parser/ParserTest.php b/tests/unit/Parser/ParserTest.php index 1defc22..52caa15 100644 --- a/tests/unit/Parser/ParserTest.php +++ b/tests/unit/Parser/ParserTest.php @@ -54,7 +54,6 @@ public static function provideInvalidJsonStrings(): iterable '{"foo": "bar"', '{"foo": "bar",', '{"foo": "bar" "baz": "qux"}', - '{[]: "foo"}', '{: "foo"}', '{null: "foo"}', '{true: "foo"}', From 27c42aaabdcb3b40cb452ebc0ad9d5ce56989f4b Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Wed, 5 Mar 2025 23:30:52 +0100 Subject: [PATCH 5/5] Use the custom parser to decode --- src/Json.php | 86 +++++++++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/src/Json.php b/src/Json.php index fb5bb0c..cc04039 100644 --- a/src/Json.php +++ b/src/Json.php @@ -5,15 +5,17 @@ namespace Eventjet\Json; use BackedEnum; +use Eventjet\Json\Parser\Parser; +use Eventjet\Json\Parser\SyntaxError; use ReflectionClass; use ReflectionNamedType; use ReflectionObject; use ReflectionParameter; use ReflectionProperty; use ReflectionUnionType; +use stdClass; use function array_is_list; -use function array_key_exists; use function array_map; use function assert; use function class_exists; @@ -21,6 +23,7 @@ use function error_get_last; use function explode; use function file; +use function get_debug_type; use function get_object_vars; use function gettype; use function implode; @@ -31,7 +34,6 @@ use function is_object; use function is_string; use function is_subclass_of; -use function json_decode; use function json_encode; use function preg_match; use function property_exists; @@ -123,12 +125,16 @@ private static function getJsonKeyForProperty(ReflectionProperty $property): str */ private static function decodeClass(string $json, object|string $value): object { - $data = json_decode($json, true); + try { + $data = Parser::parse($json); + } catch (SyntaxError $syntaxError) { + throw JsonError::decodeFailed(sprintf('JSON decoding failed: %s', $syntaxError->getMessage()), $syntaxError); + } if ($data === null) { throw JsonError::decodeFailed(error_get_last()['message'] ?? null); } - if (!is_array($data)) { - throw JsonError::decodeFailed(sprintf('Expected JSON object, got %s', gettype($data))); + if (!$data instanceof stdClass) { + throw JsonError::decodeFailed(sprintf('Expected JSON object, got %s', get_debug_type($data))); } /** @psalm-suppress DocblockTypeContradiction */ if (!is_string($value)) { @@ -145,12 +151,14 @@ private static function decodeClass(string $json, object|string $value): object return $object; } - /** - * @param array $data - */ - private static function populateObject(object $object, array $data): void + private static function populateObject(object $object, stdClass $data): void { - /** @var mixed $value */ + /** + * @var array-key $jsonKey + * @var mixed $value + * @psalm-suppress RawObjectIteration + * @phpstan-ignore-next-line foreach.nonIterable stdClass _is_ iterable + */ foreach ($data as $jsonKey => $value) { if (is_int($jsonKey)) { throw JsonError::decodeFailed(sprintf('Expected JSON object, got array at key "%s"', $jsonKey)); @@ -179,12 +187,17 @@ private static function propertyForObjectKeyExists(object $object, string $jsonK private static function populateProperty(object $object, string $jsonKey, mixed $value): void { $property = self::getPropertyNameForJsonKey($object, $jsonKey); + if ($value instanceof stdClass) { + $newValue = self::getPropertyObject($object, $jsonKey); + self::populateObject($newValue, $value); + $value = $newValue; + } if (is_array($value)) { $itemType = self::getArrayPropertyItemType($object, $property); if ($itemType !== null && class_exists($itemType)) { /** @var mixed $item */ foreach ($value as &$item) { - if (!is_array($item)) { + if (!$item instanceof stdClass) { throw JsonError::decodeFailed( sprintf( 'Expected JSON objects for items in property "%s", got %s', @@ -199,11 +212,6 @@ private static function populateProperty(object $object, string $jsonKey, mixed $item = $newItem; } } - if (!array_is_list($value)) { - $newValue = self::getPropertyObject($object, $jsonKey); - self::populateObject($newValue, $value); - $value = $newValue; - } } $object->$property = $value; // @phpstan-ignore-line } @@ -260,9 +268,8 @@ private static function getPropertyObject(object $value, string $key): object /** * @param class-string $class - * @param array $data */ - private static function instantiateClass(string $class, array $data): object + private static function instantiateClass(string $class, stdClass $data): object { $classReflection = new ReflectionClass($class); $constructor = $classReflection->getConstructor(); @@ -271,7 +278,7 @@ private static function instantiateClass(string $class, array $data): object $parameters = $constructor->getParameters(); foreach ($parameters as $parameter) { $name = $parameter->getName(); - if (!array_key_exists($name, $data)) { + if (!property_exists($data, $name)) { if ($parameter->isOptional()) { /** @psalm-suppress MixedAssignment */ $arguments[] = $parameter->getDefaultValue(); @@ -280,14 +287,12 @@ private static function instantiateClass(string $class, array $data): object throw JsonError::decodeFailed(sprintf('Missing required constructor argument "%s"', $name)); } /** @psalm-suppress MixedAssignment */ - $arguments[] = self::createConstructorArgument($parameter, $data[$name]); - unset($data[$name]); + $arguments[] = self::createConstructorArgument($parameter, $data->$name); + unset($data->$name); } } $instance = $classReflection->newInstanceArgs($arguments); - if ($data !== []) { - self::populateObject($instance, $data); - } + self::populateObject($instance, $data); return $instance; } @@ -345,7 +350,7 @@ private static function createConstructorArgumentForNamedType(ReflectionParamete ), ); } - if (!is_array($value)) { + if (!$value instanceof stdClass) { throw JsonError::decodeFailed( sprintf( 'Expected array for parameter "%s", got %s', @@ -500,14 +505,15 @@ private static function createConstructorArgumentForArrayType( if ($value === null && $parameter->allowsNull()) { return null; } - if (!is_array($value)) { - throw JsonError::decodeFailed( - sprintf('Expected array for parameter "%s", got %s', $parameter->getName(), gettype($value)), - ); + if (is_array($value) && array_is_list($value)) { + return self::createConstructorArgumentForListType($parameter, $value); } - return array_is_list($value) - ? self::createConstructorArgumentForListType($parameter, $value) - : self::createConstructorArgumentForMapType($parameter, $value); + if ($value instanceof stdClass) { + return self::createConstructorArgumentForMapType($parameter, $value); + } + throw JsonError::decodeFailed( + sprintf('Expected array for parameter "%s", got %s', $parameter->getName(), gettype($value)), + ); } /** @@ -541,7 +547,7 @@ private static function createConstructorArgumentForListType(ReflectionParameter $items = []; /** @var mixed $item */ foreach ($value as $item) { - if (!is_array($item)) { + if (!$item instanceof stdClass) { throw JsonError::decodeFailed( sprintf( 'Expected JSON objects for items in property "%s", got %s', @@ -557,10 +563,9 @@ private static function createConstructorArgumentForListType(ReflectionParameter } /** - * @param array $value * @return array */ - private static function createConstructorArgumentForMapType(ReflectionParameter $parameter, array $value): array + private static function createConstructorArgumentForMapType(ReflectionParameter $parameter, stdClass $value): array { $paramName = $parameter->getName(); $valueType = self::getMapValueType($parameter); @@ -577,17 +582,22 @@ private static function createConstructorArgumentForMapType(ReflectionParameter ); } if (!class_exists($valueType)) { - return $value; + return (array)$value; } $result = []; + /** + * @var array-key $key + * @var mixed $value + * @phpstan-ignore-next-line foreach.nonIterable stdClass _is_ iterable + */ foreach ($value as $key => $item) { - if (!is_array($item)) { + if (!$item instanceof stdClass) { throw JsonError::decodeFailed( sprintf( 'Expected an array for the value of key "%s" in parameter "%s", got %s', $key, $paramName, - gettype($item), + get_debug_type($item), ), ); }