Skip to content
Merged
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
140 changes: 105 additions & 35 deletions src/Metadata/Driver/AnnotationsDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Bdf\Serializer\Metadata\Builder\ClassMetadataBuilder;
use Bdf\Serializer\Metadata\ClassMetadata;
use Bdf\Serializer\Type\Type;
use Bdf\Serializer\Type\TypeExpressionParser;
use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tag;
use phpDocumentor\Reflection\DocBlockFactory;
Expand All @@ -13,6 +14,8 @@
use ReflectionClass;
use ReflectionProperty;

use function explode;

/**
* AnnotationsDriver
*
Expand All @@ -32,13 +35,37 @@ class AnnotationsDriver implements DriverInterface
*/
private $contextFactory;

/**
* All known alias from phpdoc that should be mapped to a serializer type
*
* @var array<string, string>
*/
public $typeMapping = [
'bool' => Type::BOOLEAN,
'false' => Type::BOOLEAN,
'true' => Type::BOOLEAN,
'int' => Type::INTEGER,
'void' => Type::TNULL,
'scalar' => Type::STRING,
'iterable' => Type::TARRAY,
'list' => Type::TARRAY,
'object' => \stdClass::class,
'callback' => 'callable',
'non-empty-string' => Type::STRING,
'non-empty-list' => Type::TARRAY,
'non-empty-array' => Type::TARRAY,
];

/**
* AnnotationsDriver constructor.
*
* @param array<string, string> $typeMapping Additional type mapping
*/
public function __construct()
public function __construct(array $typeMapping = [])
{
$this->docBlockFactory = DocBlockFactory::createInstance();
$this->contextFactory = new ContextFactory();
$this->typeMapping += $typeMapping;
}

/**
Expand All @@ -55,13 +82,15 @@ public function getMetadataForClass(ReflectionClass $class): ?ClassMetadata

// Get all properties annotations from the hierarchy
do {
$templates = $this->getClassTemplates($reflection);

foreach ($this->getClassProperties($reflection) as $property) {
// PHP serialize behavior: we skip the static properties.
if ($property->isStatic()) {
continue;
}

$annotation = $this->getPropertyAnnotations($property);
$annotation = $this->getPropertyAnnotations($property, $templates);

if (isset($annotation['SerializeIgnore'])) {
continue;
Expand Down Expand Up @@ -128,10 +157,11 @@ private function getClassProperties(ReflectionClass $reflection): array
* Get annotations from the property
*
* @param ReflectionProperty $property
* @param array<string, string> $templates The class templates
*
* @return array
*/
private function getPropertyAnnotations(ReflectionProperty $property): array
private function getPropertyAnnotations(ReflectionProperty $property, array $templates): array
{
try {
$tags = $this->docBlockFactory->create($property, $this->contextFactory->createFromReflector($property))->getTags();
Expand All @@ -143,7 +173,7 @@ private function getPropertyAnnotations(ReflectionProperty $property): array

// Tags mapping
foreach ($tags as $tag) {
list($option, $value) = $this->createSerializationTag($tag, $property);
list($option, $value) = $this->createSerializationTag($tag, $property, $templates);

if ($option !== null && !isset($annotations[$option])) {
$annotations[$option] = $value;
Expand All @@ -152,30 +182,65 @@ private function getPropertyAnnotations(ReflectionProperty $property): array

// Adding php type if no precision has been added with annotation
if (PHP_VERSION_ID >= 70400 && ($type = $property->getType()) && $type instanceof \ReflectionNamedType && !isset($annotations['type'])) {
$annotations['type'] = $this->findType($type->getName(), $property);
$annotations['type'] = $this->findType($type->getName(), $property, []); // Templates are not applicable here
}

return $annotations;
}

/**
* Get the class templates
*
* @param ReflectionClass $class
*
* @return array<string, string> The key is the template name, the value is the description
*/
private function getClassTemplates(ReflectionClass $class): array
{
try {
$tags = $this->docBlockFactory->create($class, $this->contextFactory->createFromReflector($class))->getTags();
} catch (\InvalidArgumentException $e) {
$tags = [];
}

$templates = [];

foreach ($tags as $tag) {
if (in_array($tag->getName(), ['template', 'template-covariant', 'template-contravariant', 'psalm-template', 'phpstan-template'], true)) {
/** @var DocBlock\Tags\BaseTag $tag */
$parts = explode(' ', trim((string) $tag), 2);
$type = trim($parts[0]);
$description = trim($parts[1] ?? '');

if ($type !== '') {
$templates[$type] = $description;
$templates[ltrim($class->getNamespaceName() . '\\' . $type, '\\')] = $description;
}
}
}

return $templates;
}

/**
* Create the serialization info
*
* @param Tag $tag
* @param ReflectionProperty $property
* @param array<string, string> $templates The class templates
*
* @return array
*/
private function createSerializationTag($tag, $property): array
private function createSerializationTag($tag, $property, array $templates): array
{
switch ($tag->getName()) {
case 'var':
if ($tag instanceof DocBlock\Tags\InvalidTag) {
return ['type', $this->findType((string) $tag, $property)];
return ['type', $this->findType((string) $tag, $property, $templates)];
}

/** @var DocBlock\Tags\Var_ $tag */
return ['type', $this->findType((string)$tag->getType(), $property)];
return ['type', $this->findType((string)$tag->getType(), $property, $templates)];

case 'since':
/** @var DocBlock\Tags\Since $tag */
Expand All @@ -197,47 +262,52 @@ private function createSerializationTag($tag, $property): array
*
* @param string $var
* @param ReflectionProperty $property
* @param array<string, string> $templates The class templates
*
* @return string
*/
private function findType($var, $property): ?string
private function findType($var, $property, array $templates): ?string
{
// Clear psalm structure notation and generics
$var = preg_replace('/(.*)\{.*\}/u', '$1', $var);
$var = preg_replace('/(.*)<.*>/u', '$1', $var);

// All known alias from phpdoc that should be mapped to a serializer type
$alias = [
'bool' => Type::BOOLEAN,
'false' => Type::BOOLEAN,
'true' => Type::BOOLEAN,
'int' => Type::INTEGER,
'void' => Type::TNULL,
'scalar' => Type::STRING,
'iterable' => Type::TARRAY,
'object' => \stdClass::class,
'callback' => 'callable',
'self' => $property->class,
'$this' => $property->class,
'static' => $property->class,
];

if (strpos($var, '|') === false) {
$var = ltrim($var, '\\');

return isset($alias[$var]) ? $alias[$var] : $var;
}

foreach (explode('|', $var) as $candidate) {
'self' => $property->class,
'$this' => $property->class,
'static' => $property->class,
]
+ $this->typeMapping
+ array_fill_keys(array_keys($templates), Type::MIXED) // Do not resolve the actual template type, use mixed instead
;

foreach (TypeExpressionParser::parseString($var) as $intersection) {
// Only take in account the first type of the intersection
$candidate = $intersection[0][0];
$candidate = ltrim($candidate, '\\');

if (isset($alias[$candidate])) {
$candidate = $alias[$candidate];
}

if ($candidate !== '' && $candidate !== Type::TNULL) {
if ($candidate === '' || $candidate === Type::TNULL) {
continue;
}

// Only support types with at most one simple generic type, so if there is more, we skip it
if (
count($intersection[0]) !== 2 // more than one generic type
|| count($intersection[0][1]) !== 1 // the generic type is an union
|| count($intersection[0][1][0]) !== 1 // the generic type is an intersection
) {
return $candidate;
}

$generic = ltrim($intersection[0][1][0][0][0], '\\');

if (isset($alias[$generic])) {
$generic = $alias[$generic];
}

// Ignore more complex generic types for now
return $candidate . '<' . $generic . '>';
}

// We let here the getMetadataForClass add the default type
Expand Down
153 changes: 153 additions & 0 deletions src/Type/TypeExpressionParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace Bdf\Serializer\Type;

use function array_values;
use function count;
use function in_array;
use function preg_quote;
use function preg_split;

final class TypeExpressionParser
{
public const META_TOKENS = ['&', '|', '{', '}', '<', '>', ',', '[', ']'];

/**
* Parse a type expression string into a structured array.
*
* The first array level represents union types (separated by '|').
* The second array level represents intersection types (separated by '&').
* Each atomic type is represented as an array where the first element is the type name,
* and subsequent elements are arrays of generic type parameters.
* Generic parameters themselves can be union or intersection types (so they follow the same structure).
*
* Example:
* 'int' => [[['int']]]
* 'A&B|C' => [[['A'], ['B']], [['C']]]
* 'Map<K, V>' => [[['Map', [[['K']]], [[['V']]]]]]
*
* @param string $type
* @return array
*/
public static function parseString(string $type): array
{
$state = new TypeExpressionParserState(array_values(preg_split('/([' . preg_quote(implode(self::META_TOKENS)) . '])/', $type, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)));

return self::parseUnionType($state);
}

private static function parseUnionType(TypeExpressionParserState $state): array
{
$types = [];

do {
$types[] = self::parseIntersectionType($state);
} while ($state->consume('|'));

return $types;
}

private static function parseIntersectionType(TypeExpressionParserState $state): array
{
$types = [];

do {
$types[] = self::parseAtomicType($state);
} while ($state->consume('&'));

return $types;
}

private static function parseAtomicType(TypeExpressionParserState $state): array
{
$type = [
$state->isSymbol() ? trim($state->next()) : 'mixed'
];

if ($state->consume('<')) {
array_push($type, ...self::parseGenerics($state));
$state->consume('>');
}

if ($state->consume('{')) {
// array/object shape is ignored for now
$depth = 1;

while ($state->hasMoreTokens() && $depth > 0) {
if ($state->consume('{')) {
++$depth;
} elseif ($state->consume('}')) {
--$depth;
} else {
$state->next();
}
}
}

while ($state->consume('[') && $state->consume(']')) {
$type[0] .= '[]';
}

return $type;
}

private static function parseGenerics(TypeExpressionParserState $state): array
{
$types = [];

do {
$types[] = self::parseUnionType($state);
} while ($state->consume(','));

return $types;
}
}

/**
* @internal
*/
final class TypeExpressionParserState
{
/**
* @var list<string>
*/
public $tokens;

/**
* @var non-negative-int
*/
public $position = 0;

/**
* @param list<string> $tokens
*/
public function __construct(array $tokens)
{
$this->tokens = $tokens;
}

public function isSymbol(): bool
{
return $this->hasMoreTokens() && !in_array($this->tokens[$this->position], TypeExpressionParser::META_TOKENS, true);
}

public function hasMoreTokens(): bool
{
return $this->position < count($this->tokens);
}

public function consume(string $expected): bool
{
if ($this->hasMoreTokens() && $this->tokens[$this->position] === $expected) {
++$this->position;
return true;
}

return false;
}

public function next(): string
{
return $this->tokens[$this->position++];
}
}
Loading