Skip to content

Custom Parser #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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
@@ -174,7 +174,7 @@ jobs:
-jmax

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

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
13 changes: 7 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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": {
90 changes: 50 additions & 40 deletions src/Json.php
Original file line number Diff line number Diff line change
@@ -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;
@@ -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 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 @@
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));
@@ -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',
@@ -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
}
@@ -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();
@@ -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();
@@ -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;
}

@@ -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',
@@ -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

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

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

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

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

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

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

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

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

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

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

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

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,
@@ -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)),
);
}

/**
@@ -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',
@@ -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);
@@ -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),
),
);
}
2 changes: 1 addition & 1 deletion src/JsonError.php
Original file line number Diff line number Diff line change
@@ -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);
}
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
Oops, something went wrong.
Loading
Oops, something went wrong.