Lines 93.75% 60 / 64
Functions and Methods 66.66% 6 / 9
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ClassShapeReader 93.75% 60 / 64 66.66% 6 / 9 33.27 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 resolveClassShape 100.00% 1 / 1 100.00% 1 / 1 1
 buildClassShape 100.00% 12 / 12 100.00% 1 / 1 4
 buildFieldShape 90.00% 18 / 20 0.00% 0 / 1 9.08
 buildArrayShape 100.00% 4 / 4 100.00% 1 / 1 2
 describeValueShape 100.00% 17 / 17 100.00% 1 / 1 9
 isPublicPromotedProperty 75.00% 3 / 4 0.00% 0 / 1 3.14
 findWireVariants 75.00% 3 / 4 0.00% 0 / 1 3.14
 convertToWireKey 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Json;
6
7use BackedEnum;
8use LogicException;
9use ReflectionClass;
10use ReflectionEnum;
11use ReflectionIntersectionType;
12use ReflectionNamedType;
13use ReflectionParameter;
14use ReflectionProperty;
15use ReflectionUnionType;
16use Stewart\Contracts\Wire\ListOf;
17use Stewart\Contracts\Wire\WireVariants;
18use Stewart\Runtime\Json\Collection\ValueConverterCollection;
19
20final class ClassShapeReader
21{
22    private const array ANY_JSON = ['array', 'bool', 'float', 'int', 'null', 'string'];
23
24    private const array NUMBER = ['float', 'int'];
25
26    private const array SCALARS = [
27        'string' => ValueKind::String,
28        'int' => ValueKind::Int,
29        'float' => ValueKind::Float,
30        'bool' => ValueKind::Bool,
31    ];
32
33    /** @var array<class-string, ClassShape> */
34    private array $classShapes = [];
35
36    /** @var array<class-string, true> */
37    private array $classesBeingDescribed = [];
38
39    public function __construct(private readonly ValueConverterCollection $converters) {}
40
41    /** @param class-string $class */
42    public function resolveClassShape(string $class): ClassShape
43    {
44        return $this->classShapes[$class] ?? $this->buildClassShape($class);
45    }
46
47    /** @param class-string $class */
48    private function buildClassShape(string $class): ClassShape
49    {
50        if (isset($this->classesBeingDescribed[$class])) {
51            throw new LogicException(\sprintf('%s refers to itself, which the wire cannot carry.', $class));
52        }
53
54        $this->classesBeingDescribed[$class] = true;
55
56        try {
57            $constructor = new ReflectionClass($class)->getConstructor();
58
59            if ($constructor !== null && !$constructor->isPublic()) {
60                throw new LogicException(\sprintf('%s needs a public constructor to be built from the wire.', $class));
61            }
62
63            $fields = array_map(
64                fn(ReflectionParameter $parameter): FieldShape => $this->buildFieldShape($class, $parameter),
65                $constructor?->getParameters() ?? [],
66            );
67
68            return $this->classShapes[$class] = new ClassShape($class, $fields);
69        } finally {
70            unset($this->classesBeingDescribed[$class]);
71        }
72    }
73
74    /** @param class-string $class */
75    private function buildFieldShape(string $class, ReflectionParameter $parameter): FieldShape
76    {
77        $name = $parameter->getName();
78        $where = $class . '::$' . $name;
79
80        if (!self::isPublicPromotedProperty($class, $name)) {
81            throw new LogicException(\sprintf('%s must be a public promoted constructor parameter.', $where));
82        }
83
84        $type = $parameter->getType();
85
86        if ($type instanceof ReflectionUnionType) {
87            $names = array_map(static fn(ReflectionNamedType|ReflectionIntersectionType $part): string => (string) $part, $type->getTypes());
88            sort($names);
89
90            if ($names === self::ANY_JSON) {
91                return new FieldShape($name, $this->convertToWireKey($name), true, new ValueShape(ValueKind::Json));
92            }
93
94            if (array_values(array_diff($names, ['null'])) !== self::NUMBER) {
95                throw new LogicException(\sprintf('%s has a union type the wire cannot carry.', $where));
96            }
97
98            return new FieldShape($name, $this->convertToWireKey($name), $type->allowsNull(), new ValueShape(ValueKind::Number));
99        }
100
101        if (!$type instanceof ReflectionNamedType) {
102            throw new LogicException(\sprintf('%s needs a declared type.', $where));
103        }
104
105        $value = match ($type->getName()) {
106            'mixed' => new ValueShape(ValueKind::Json),
107            'array' => $this->buildArrayShape($parameter, $where),
108            default => $this->describeValueShape($type->getName(), $where),
109        };
110
111        return new FieldShape($name, $this->convertToWireKey($name) . ($value->converter?->keySuffix() ?? ''), $type->allowsNull(), $value);
112    }
113
114    private function buildArrayShape(ReflectionParameter $parameter, string $where): ValueShape
115    {
116        $listOf = $parameter->getAttributes(ListOf::class)[0] ?? null;
117
118        return $listOf === null
119            ? new ValueShape(ValueKind::Map)
120            : new ValueShape(ValueKind::List, element: $this->describeValueShape($listOf->newInstance()->type, $where));
121    }
122
123    public function describeValueShape(string $type, string $where): ValueShape
124    {
125        if (isset(self::SCALARS[$type])) {
126            return new ValueShape(self::SCALARS[$type], $type);
127        }
128
129        $converter = $this->converters->findForClass($type);
130
131        if ($converter !== null) {
132            return new ValueShape(ValueKind::Converted, $type, converter: $converter);
133        }
134
135        if (is_subclass_of($type, JsonFragment::class)) {
136            return new ValueShape(ValueKind::Fragment, $type);
137        }
138
139        if (is_subclass_of($type, BackedEnum::class)) {
140            return new ValueShape(ValueKind::Enum, $type, enumBackingType: (string) new ReflectionEnum($type)->getBackingType());
141        }
142
143        $variants = self::findWireVariants($type);
144
145        if ($variants !== null) {
146            return new ValueShape(ValueKind::Variant, $type, variants: $variants);
147        }
148
149        if (!class_exists($type) || new ReflectionClass($type)->isAbstract()) {
150            throw new LogicException(\sprintf('%s is a %s, which has no converter.', $where, $type));
151        }
152
153        if ($this->resolveClassShape($type)->holdsFragment) {
154            throw new LogicException(\sprintf('%s nests %s, and only a top-level object may hold a fragment.', $where, $type));
155        }
156
157        return new ValueShape(ValueKind::Dto, $type);
158    }
159
160    // A subclass passes inherited fields through to the parent, which promotes them.
161    /** @param class-string $class */
162    private static function isPublicPromotedProperty(string $class, string $name): bool
163    {
164        if (!property_exists($class, $name)) {
165            return false;
166        }
167
168        $property = new ReflectionProperty($class, $name);
169
170        return $property->isPromoted() && $property->isPublic();
171    }
172
173    private static function findWireVariants(string $type): ?WireVariants
174    {
175        if (!class_exists($type) && !interface_exists($type)) {
176            return null;
177        }
178
179        $attribute = new ReflectionClass($type)->getAttributes(WireVariants::class)[0] ?? null;
180
181        return $attribute?->newInstance();
182    }
183
184    private function convertToWireKey(string $propertyName): string
185    {
186        return strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $propertyName));
187    }
188}