Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ jobs:
-jmax

- name: Save Infection result
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
Copy link

Copilot AI Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirm that the new version (v4) of actions/upload-artifact uses the same configuration parameters as v3, and adjust inputs if the API has changed.

Copilot uses AI. Check for mistakes.
if: always()
with:
name: infection-log-${{ matrix.php }}-${{ matrix.deps }}.txt
Expand Down
13 changes: 7 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-json": "*"
"ext-json": "*",
"ext-mbstring": "*"
},
"require-dev": {
"eventjet/coding-standard": "^3.15",
"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": {
Expand Down
90 changes: 50 additions & 40 deletions src/Json.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@
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;
use function enum_exists;
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;
Expand All @@ -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;
Expand Down Expand Up @@ -123,12 +125,16 @@
*/
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)) {
Expand All @@ -145,12 +151,14 @@
return $object;
}

/**
* @param array<array-key, mixed> $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));
Expand Down Expand Up @@ -179,12 +187,17 @@
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',
Expand All @@ -199,11 +212,6 @@
$item = $newItem;
}
}
if (!array_is_list($value)) {
$newValue = self::getPropertyObject($object, $jsonKey);
self::populateObject($newValue, $value);
$value = $newValue;
}
}
$object->$property = $value; // @phpstan-ignore-line
}
Expand Down Expand Up @@ -260,9 +268,8 @@

/**
* @param class-string $class
* @param array<array-key, mixed> $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();
Expand All @@ -271,7 +278,7 @@
$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();
Expand All @@ -280,14 +287,12 @@
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;
}

Expand Down Expand Up @@ -345,7 +350,7 @@
),
);
}
if (!is_array($value)) {
if (!$value instanceof stdClass) {
throw JsonError::decodeFailed(
sprintf(
'Expected array<string, mixed> for parameter "%s", got %s',
Expand Down Expand Up @@ -485,13 +490,13 @@
if ($result !== 1) {
continue;
}
$useStatements[$matches['alias'] ?? $matches['class']] = ($matches['ns'] ?? '') . $matches['class'];
$useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class'];

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.1, --prefer-lowest)

Escaped Mutant for Mutator "Concat": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class'] . $matches['ns']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.1, --prefer-lowest)

Escaped Mutant for Mutator "Coalesce": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['class'] ?? $matches['alias']] = $matches['ns'] . $matches['class']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.1, --prefer-lowest)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.1)

Escaped Mutant for Mutator "Coalesce": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['class'] ?? $matches['alias']] = $matches['ns'] . $matches['class']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.1)

Escaped Mutant for Mutator "Concat": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class'] . $matches['ns']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.1)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.2, --prefer-lowest)

Escaped Mutant for Mutator "Concat": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class'] . $matches['ns']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.2, --prefer-lowest)

Escaped Mutant for Mutator "Coalesce": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['class'] ?? $matches['alias']] = $matches['ns'] . $matches['class']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.2, --prefer-lowest)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.2)

Escaped Mutant for Mutator "Coalesce": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['class'] ?? $matches['alias']] = $matches['ns'] . $matches['class']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.2)

Escaped Mutant for Mutator "Concat": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class'] . $matches['ns']; } return $useStatements; }

Check warning on line 493 in src/Json.php

View workflow job for this annotation

GitHub Actions / infection (8.2)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ if ($result !== 1) { continue; } - $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class']; + $useStatements[$matches['alias'] ?? $matches['class']] = $matches['class']; } return $useStatements; }
}
return $useStatements;
}

/**
* @return list<mixed> | array<string, mixed>
* @return list<mixed> | array<array-key, mixed>
*/
private static function createConstructorArgumentForArrayType(
ReflectionParameter $parameter,
Expand All @@ -500,14 +505,15 @@
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)),
);
}

/**
Expand Down Expand Up @@ -541,7 +547,7 @@
$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',
Expand All @@ -557,10 +563,9 @@
}

/**
* @param array<array-key, mixed> $value
* @return array<array-key, mixed>
*/
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);
Expand All @@ -577,17 +582,22 @@
);
}
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),
),
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/JsonError.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
12 changes: 12 additions & 0 deletions src/Parser/Location.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Json\Parser;

final class Location
{
public function __construct(public readonly int $line, public readonly int $column)
{
}
}
Loading
Loading