Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.63% covered (danger)
10.63%
22 / 207
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
JsonCodec
10.63% covered (danger)
10.63%
22 / 207
15.38% covered (danger)
15.38%
2 / 13
6002.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 toJsonString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 newFromJsonString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 codecFor
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 addCodecFor
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 addAbbrev
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
7.58
 getAbbrev
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 toJsonArray
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
1190
 newFromJsonArray
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
342
 isArrayMarked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 markArray
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 unmarkArray
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 classEquals
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2declare( strict_types=1 );
3
4/**
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace Wikimedia\JsonCodec;
10
11use Exception;
12use InvalidArgumentException;
13use Psr\Container\ContainerInterface;
14use Psr\Container\NotFoundExceptionInterface;
15use ReflectionClass;
16use stdClass;
17use UnitEnum;
18
19/**
20 * Helper class to serialize/unserialize things to/from JSON.
21 */
22class JsonCodec implements JsonCodecInterface {
23    /** @var ContainerInterface Service container */
24    protected readonly ContainerInterface $serviceContainer;
25
26    /** @var array<class-string,JsonClassCodec> Class codecs */
27    protected array $codecs = [];
28
29    /**
30     * Name of the property where class information is stored; it also
31     * is used to mark "complex" arrays, and as a place to store the contents
32     * of any pre-existing array property that happened to have the same name.
33     */
34    protected const TYPE_ANNOTATION = '_type_';
35
36    /**
37     * Prefix used to distinguish abbreviations from class names.
38     */
39    protected const ABBREV_PREFIX = '@';
40
41    /**
42     * Maps abbreviation names to hint abbreviations.  Keys are prefixed
43     * with self::ABBREV_PREFIX for faster lookup.
44     * @see ::addAbbrev()
45     * @var array<string,Abbrev>
46     */
47    protected array $abbrevToHintMap = [];
48
49    /**
50     * Maps PHP class names to abbreviations
51     * @see ::addAbbrev()
52     * @var array<class-string,Abbrev>
53     */
54    protected array $classToAbbrevMap = [];
55
56    /**
57     * @param ?ContainerInterface $serviceContainer
58     */
59    public function __construct( ?ContainerInterface $serviceContainer = null ) {
60        $this->serviceContainer = $serviceContainer ??
61            // Use an empty container if none is provided.
62            new class implements ContainerInterface {
63                /**
64                 * @param string $id
65                 * @return never
66                 */
67                public function get( $id ) {
68                    throw new class( "not found" ) extends Exception implements NotFoundExceptionInterface {
69                    };
70                }
71
72                /** @inheritDoc */
73                public function has( string $id ): bool {
74                    return false;
75                }
76            };
77        $this->addCodecFor(
78            stdClass::class, JsonStdClassCodec::getInstance()
79        );
80    }
81
82    /**
83     * Recursively converts a given object to a JSON-encoded string.
84     * While serializing the $value JsonCodec delegates to the appropriate
85     * JsonClassCodecs of any classes which implement JsonCodecable.
86     *
87     * If a $classHint is provided and matches the type of the value,
88     * then type information will not be included in the generated JSON;
89     * otherwise an appropriate class name will be added to the JSON to
90     * guide deserialization.
91     *
92     * @param mixed|null $value
93     * @param class-string|Hint|Abbrev|null $classHint An optional hint to
94     *   the type of the encoded object.  If this is provided and matches
95     *   the type of $value, then explicit type information will be omitted
96     *   from the generated JSON, which saves some space.
97     * @return string
98     */
99    public function toJsonString( $value, $classHint = null ): string {
100        return json_encode(
101            $this->toJsonArray( $value, $classHint ),
102            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE |
103            JSON_HEX_TAG | JSON_HEX_AMP
104        );
105    }
106
107    /**
108     * Recursively converts a JSON-encoded string to an object value or scalar.
109     * While deserializing the $json JsonCodec delegates to the appropriate
110     * JsonClassCodecs of any classes which implement JsonCodecable.
111     *
112     * For objects encoded using implicit class information, a "class hint"
113     * can be provided to guide deserialization; this is unnecessary for
114     * objects serialized with explicit classes.
115     *
116     * @param string $json A JSON-encoded string
117     * @param class-string|Hint|Abbrev|null $classHint An optional hint to
118     *   the type of the encoded object.  In the absence of explicit
119     *   type information in the JSON, this will be used as the type of
120     *   the created object.
121     * @return mixed|null
122     */
123    public function newFromJsonString( $json, $classHint = null ) {
124        return $this->newFromJsonArray(
125            json_decode( $json, true ), $classHint
126        );
127    }
128
129    /**
130     * Maintain a cache giving the codec for a given class name.
131     *
132     * Reusing this JsonCodec object will also reuse this cache, which
133     * could improve performance somewhat.
134     *
135     * @param class-string $className
136     * @return ?JsonClassCodec a codec for the class, or null if the class is
137     *   not serializable.
138     */
139    protected function codecFor( string $className ): ?JsonClassCodec {
140        $codec = $this->codecs[$className] ?? null;
141        if ( $codec !== null ) {
142            return $codec;
143        }
144        // Check for class aliases to ensure we don't use split codecs
145        $trueName = ( new ReflectionClass( $className ) )->getName();
146        if ( $trueName !== $className ) {
147            $codec = $this->codecs[$trueName] ?? null;
148            if ( $codec !== null ) {
149                $this->codecs[$className] = $codec;
150                return $codec;
151            }
152            $className = $trueName;
153        }
154        if ( is_a( $className, JsonCodecable::class, true ) ) {
155            $codec = $className::jsonClassCodec( $this, $this->serviceContainer );
156            $this->codecs[$className] = $codec;
157        } elseif ( is_a( $className, UnitEnum::class, true ) ) {
158            $codec = JsonEnumClassCodec::getInstance();
159            $this->codecs[$className] = $codec;
160        }
161        return $codec;
162    }
163
164    /**
165     * Allow the use of a customized encoding for the given class; the given
166     * className need not be a JsonCodecable and if it *does* correspond to
167     * a JsonCodecable it will override the class codec specified by the
168     * JsonCodecable.
169     * @param class-string $className
170     * @param JsonClassCodec $codec A codec to use for $className
171     */
172    public function addCodecFor( string $className, JsonClassCodec $codec ): void {
173        // Resolve aliases
174        $className = ( new ReflectionClass( $className ) )->getName();
175        // Sanity check
176        if ( isset( $this->codecs[$className] ) ) {
177            throw new InvalidArgumentException(
178                "Codec already present for $className"
179            );
180        }
181        $this->codecs[$className] = $codec;
182    }
183
184    /**
185     * This supports cross-platform schemas by decoupling a hint from
186     * the actual PHP class name.  If the `_type_` specified in the
187     * JSON starts with `self::ABBREV_PREFIX` it is looked up in the
188     * abbreviation map to obtain a hint.
189     *
190     * Only abbreviations that map to `class-string` are used for encoding.
191     * If you wish to add an `class-string` abbreviation that is only for
192     * decode, then building a simple Hint with ONLY_FOR_DECODE is sufficient:
193     * ```
194     * $codec->addAbbrev($name, Hint::build($className, HintType::ONLY_FOR_DECODE));
195     * ```
196     * This allows for forward-compatibility with a future encoding which
197     * uses abbreviations (as usual for the ONLY_FOR_DECODE hint), but it
198     * can also be used to allow multiple abbreviations for the same class
199     * name, as long at most one of them is registered without
200     * ONLY_FOR_DECODE.
201     * @param string $name The abbreviation name (unprefixed)
202     * @param class-string|Hint $hint The PHP class name or hint
203     *  to be abbreviated
204     */
205    public function addAbbrev( string $name, string|Hint $hint ): Abbrev {
206        $abbrev = new Abbrev( $name, $hint );
207        // Each abbreviation must map to a single class name for decode,
208        // although we can have a class map to multiple abbreviations for
209        // encode (only the last registered is used).
210        $key = self::ABBREV_PREFIX . $name;
211        $existing = $this->abbrevToHintMap[$key] ?? null;
212        if (
213            $existing !== null &&
214            !$abbrev->isSameAs( $existing )
215        ) {
216            throw new InvalidArgumentException(
217                "conflicting abbreviation for {$name}{$existing->hint} != {$abbrev->hint}"
218            );
219        }
220        $this->abbrevToHintMap[$key] = $abbrev;
221        // Only abbreviations for bare class names (not hints) are serialized.
222        if ( is_string( $hint ) ) {
223            $existing = $this->classToAbbrevMap[$hint] ?? null;
224            if ( $existing !== null && !$abbrev->isSameAs( $existing ) ) {
225                throw new InvalidArgumentException(
226                    "too many abbreviations for {$hint}{$existing->name}{$abbrev->name}"
227                );
228            }
229            $this->classToAbbrevMap[$hint] = $abbrev;
230        }
231        return $abbrev;
232    }
233
234    /**
235     * Return an abbreviation registered for the given abbrevation name.
236     */
237    public function getAbbrev( string $name ): ?Abbrev {
238        $key = self::ABBREV_PREFIX . $name;
239        return $this->abbrevToHintMap[$key] ?? null;
240    }
241
242    /**
243     * Recursively converts a given object to an associative array
244     * which can be json-encoded.  (When embedding an object into
245     * another context it is sometimes useful to have the array
246     * representation rather than the string JSON form of the array;
247     * this can also be useful if you want to pretty-print the result,
248     * etc.)  While converting $value the JsonCodec delegates to the
249     * appropriate JsonClassCodecs of any classes which implement
250     * JsonCodecable.
251     *
252     * If a $classHint is provided and matches the type of the value,
253     * then type information will not be included in the generated JSON;
254     * otherwise an appropriate class name will be added to the JSON to
255     * guide deserialization.
256     *
257     * @param mixed|null $value
258     * @param class-string|Hint|Abbrev|null $classHint An optional hint to
259     *   the type of the encoded object.  If this is provided and matches
260     *   the type of $value, then explicit type information will be omitted
261     *   from the generated JSON, which saves some space.
262     * @return mixed|null
263     */
264    public function toJsonArray( $value, $classHint = null ) {
265        $is_complex = false;
266        $className = 'array';
267        $codec = null;
268
269        // Process class hints
270        $arrayClassHint = null;
271        $forceBraces = null;
272        $allowInherited = false;
273        if ( $classHint instanceof Abbrev ) {
274            $classHint = $classHint->hint;
275        }
276        while ( $classHint instanceof Hint ) {
277            if ( $classHint->modifier === HintType::USE_SQUARE ) {
278                // Allow list-like serializations to use []
279                $classHint = $classHint->parent;
280                $forceBraces = false;
281            } elseif ( $classHint->modifier === HintType::ALLOW_OBJECT ) {
282                // Force empty arrays to serialize as {}
283                $classHint = $classHint->parent;
284                $forceBraces = true;
285            } elseif ( $classHint->modifier === HintType::LIST ) {
286                // Array whose values are the hinted type
287                $arrayClassHint = $classHint->parent;
288                $classHint = 'array';
289            } elseif ( $classHint->modifier === HintType::STDCLASS ) {
290                // stdClass whose values are the hinted type
291                $arrayClassHint = $classHint->parent;
292                $classHint = stdClass::class;
293            } elseif ( $classHint->modifier === HintType::INHERITED ) {
294                // Allow the hint to match subclasses of the hinted class
295                $classHint = $classHint->parent;
296                $allowInherited = true;
297            } elseif ( $classHint->modifier === HintType::ONLY_FOR_DECODE ) {
298                // Don't use this hint for serialization.
299                $classHint = null;
300            } elseif ( $classHint->modifier === HintType::DEFAULT ) {
301                // No-op, included for completeness
302                $classHint = $classHint->parent;
303            } else {
304                throw new InvalidArgumentException( 'bad hint modifier: ' . $classHint->modifier->name );
305            }
306        }
307        if ( is_object( $value ) ) {
308            $className = get_class( $value );
309            $codec = $this->codecFor( $className );
310            if ( $codec !== null ) {
311                $value = $codec->toJsonArray( $value );
312                $is_complex = true;
313            }
314            // Tweak the codec used for class hints if $allowInherited is true
315            // in order to match the codec we would use for deserialization.
316            if (
317                $allowInherited &&
318                $classHint !== null &&
319                is_a( $className, $classHint, true ) &&
320                // extra comparison to let phan know $classHint is not 'array'
321                $classHint !== 'array'
322            ) {
323                $className = $classHint;
324                $codec = $this->codecFor( $className );
325            }
326        } elseif (
327            is_array( $value ) && $this->isArrayMarked( $value )
328        ) {
329            $is_complex = true;
330        }
331        if ( is_array( $value ) ) {
332            // Recursively convert array values to serializable form
333            foreach ( $value as $key => &$v ) {
334                if ( is_object( $v ) || is_array( $v ) ) {
335                    $propClassHint = $arrayClassHint;
336                    $propClassHint ??= ( $codec === null ? null :
337                        $codec->jsonClassHintFor( $className, (string)$key )
338                    );
339                    $v = $this->toJsonArray( $v, $propClassHint );
340                    if (
341                        $propClassHint !== null ||
342                        $this->isArrayMarked( $v )
343                    ) {
344                        // an array which contains complex components is
345                        // itself complex.
346                        $is_complex = true;
347                    }
348                }
349            }
350            // Ok, now mark the array, being careful to transfer away
351            // any fields with the same names as our markers.
352            if ( $is_complex || $classHint !== null ) {
353                if (
354                    $forceBraces === null &&
355                    $className !== 'array' &&
356                    array_is_list( $value )
357                ) {
358                    // Include the type annotation (by clearing the
359                    // hint) if $forceBraces isn't false and it is
360                    // necessary to break up a list.  This ensures that
361                    // all objects have a JSON encoding in the `{...}`
362                    // style, even if they happen to have all-numeric
363                    // keys.
364                    $classHint = null;
365                }
366                // Even if $className === $classHint we may need to record this
367                // array as "complex" (ie, requires recursion to process
368                // individual values during deserialization)
369                $this->markArray(
370                    $value, $className, $classHint
371                );
372                if ( $forceBraces === true && array_is_list( $value ) ) {
373                    // It is somewhat surprising for ::toJsonArray() to return
374                    // an object (rather than an array), but allow this case
375                    // if the class hint expressly asked for it.
376                    $value = (object)$value;
377                }
378            }
379        } elseif ( !is_scalar( $value ) && $value !== null ) {
380            throw new InvalidArgumentException(
381                'Unable to serialize JSON: ' . get_debug_type( $value )
382            );
383        }
384        return $value;
385    }
386
387    /**
388     * Recursively converts an associative array (or scalar) to an
389     * object value (or scalar).  While converting this value JsonCodec
390     * delegates to the appropriate JsonClassCodecs of any classes which
391     * implement JsonCodecable.
392     *
393     * For objects encoded using implicit class information, a "class hint"
394     * can be provided to guide deserialization; this is unnecessary for
395     * objects serialized with explicit classes.
396     *
397     * @param mixed|null $json
398     * @param class-string|Hint|Abbrev|null $classHint An optional hint to
399     *   the type of the encoded object.  In the absence of explicit
400     *   type information in the JSON, this will be used as the type of
401     *   the created object.
402     * @return mixed|null
403     */
404    public function newFromJsonArray( $json, $classHint = null ) {
405        if ( $json instanceof stdClass ) {
406            // We *shouldn't* be given an object... but we might.
407            $json = (array)$json;
408        }
409
410        // Process class hints
411        $arrayClassHint = null;
412        if ( $classHint instanceof Abbrev ) {
413            $classHint = $classHint->hint;
414        }
415        while ( $classHint instanceof Hint ) {
416            if ( $classHint->modifier === HintType::LIST ) {
417                $arrayClassHint = $classHint->parent;
418                $classHint = 'array';
419            } elseif ( $classHint->modifier === HintType::STDCLASS ) {
420                $arrayClassHint = $classHint->parent;
421                $classHint = stdClass::class;
422            } else {
423                $classHint = $classHint->parent;
424            }
425        }
426
427        // Is this an array containing a complex value?
428        if (
429            is_array( $json ) && (
430                $this->isArrayMarked( $json ) || $classHint !== null
431            )
432        ) {
433            // Read out our metadata
434            $className = $this->unmarkArray( $json, $classHint );
435            // Create appropriate codec
436            $codec = null;
437            if ( $className !== 'array' ) {
438                $codec = $this->codecFor( $className );
439                if ( $codec === null ) {
440                    throw new InvalidArgumentException(
441                        "Unable to deserialize JSON for $className"
442                    );
443                }
444            }
445            // Recursively unserialize the array contents.
446            $unserialized = [];
447            foreach ( $json as $key => $value ) {
448                $propClassHint = $arrayClassHint;
449                $propClassHint ??= ( $codec === null ? null :
450                    // phan can't tell that $codec is null when $className is 'array'
451                    // @phan-suppress-next-line PhanUndeclaredClassReference
452                    $codec->jsonClassHintFor( $className, (string)$key )
453                );
454                if ( $value instanceof stdClass ) {
455                    // Again, we *shouldn't* be given an object... but we might.
456                    $value = (array)$value;
457                }
458                if (
459                    is_array( $value ) && (
460                        $this->isArrayMarked( $value ) || $propClassHint !== null
461                    )
462                ) {
463                    $unserialized[$key] = $this->newFromJsonArray( $value, $propClassHint );
464                } else {
465                    $unserialized[$key] = $value;
466                }
467            }
468            // Use a JsonCodec to create the object instance if appropriate.
469            if ( $className === 'array' ) {
470                $json = $unserialized;
471            } else {
472                $json = $codec->newFromJsonArray( $className, $unserialized );
473            }
474        }
475        return $json;
476    }
477
478    // Functions to mark/unmark arrays and record a class name using a
479    // single reserved field, named by self::TYPE_ANNOTATION.  A
480    // subclass can provide alternate implementations of these methods
481    // if it wants to use a different reserved field or else wishes to
482    // reserve more fields/encode certain types more compactly/flag
483    // certain types of values.  For example: a subclass could choose
484    // to discard all hints in `markArray` in order to explicitly mark
485    // all types in preparation for a format change; or all values of
486    // type DocumentFragment might get a marker flag added so they can
487    // be identified without knowledge of the class hint; or perhaps a
488    // separate schema can be used to record class names more
489    // compactly.
490
491    /**
492     * Determine if the given value is "marked"; that is, either
493     * represents a object type encoded using a JsonClassCodec or else
494     * is an array which contains values (or contains arrays
495     * containing values, etc) which are object types. The values of
496     * unmarked arrays are not decoded, in order to speed up the
497     * decoding process.  Arrays may also be marked even if they do
498     * not represent object types (or an array recursively containing
499     * them) if they contain keys that need to be escaped ("false
500     * marks"); as such this method is called both on the raw results
501     * of JsonClassCodec (to check for "false marks") as well as on
502     * encoded arrays (to find "true marks").
503     *
504     * Arrays do not have to be marked if the decoder has a class hint.
505     *
506     * @param array $value An array result from `JsonClassCodec::toJsonArray()`,
507     *  or an array result from `::markArray()`
508     * @return bool Whether the $value is marked
509     */
510    protected function isArrayMarked( array $value ): bool {
511        return array_key_exists( self::TYPE_ANNOTATION, $value );
512    }
513
514    /**
515     * Record a mark in the array, reversibly.
516     *
517     * The mark should record the class name, if it is different from
518     * the class hint.  The result does not need to trigger
519     * `::isArrayMarked` if there is an accurate class hint present,
520     * but otherwise the result should register as marked.  The
521     * provided value may be a "complex" array (one that recursively
522     * contains encoded object) or an array with a "false mark"; in
523     * both cases the provided $className will be `array`.
524     *
525     * @param array &$value An array result from `JsonClassCodec::toJsonArray()`
526     *   or a "complex" array
527     * @param 'array'|class-string $className The name of the class encoded
528     *   by the codec, or else `array` if $value is a "complex" array or a
529     *   "false mark"
530     * @param 'array'|class-string|null $classHint The class name provided as
531     *   a hint to the encoder, and which will be in turn provided as a hint
532     *   to the decoder, or `null` if no hint was provided.  The class hint
533     *   will be `array` when the array is a homogeneous list of objects.
534     */
535    protected function markArray( array &$value, string $className, ?string $classHint ): void {
536        // We're going to use an array key, but first we have to see whether it
537        // was already present in the array we've been given, in which case
538        // we need to escape it (by hoisting into a child array).
539        if ( array_key_exists( self::TYPE_ANNOTATION, $value ) ) {
540            if ( !self::classEquals( $className, $classHint ) ) {
541                $abbrev = $this->classToAbbrevMap[$className] ?? null;
542                $type = ( $abbrev === null ) ? $className :
543                      ( self::ABBREV_PREFIX . $abbrev->name );
544                $value[self::TYPE_ANNOTATION] = [ $value[self::TYPE_ANNOTATION], $type ];
545            } else {
546                // Omit $className since it matches the $classHint, but we still
547                // need to escape the field to make it clear it was marked.
548                // (If the class hint hadn't matched, the proper class name
549                // would be here in an array, and we need to distinguish that
550                // case from the case where the "actual value" is an array.)
551                $value[self::TYPE_ANNOTATION] = [ $value[self::TYPE_ANNOTATION] ];
552            }
553        } elseif (
554            !self::classEquals( $className, $classHint )
555        ) {
556            // Include the type annotation if it doesn't match the hint
557            $abbrev = $this->classToAbbrevMap[$className] ?? null;
558            $type = ( $abbrev === null ) ? $className :
559                  ( self::ABBREV_PREFIX . $abbrev->name );
560            $value[self::TYPE_ANNOTATION] = $type;
561        }
562    }
563
564    /**
565     * Remove a mark from an encoded array, and return an
566     * encoded class name if present.
567     *
568     * The provided array may not trigger `::isArrayMarked` is there
569     * was a class hint provided.
570     *
571     * If the provided array had a "false mark" or recursively
572     * contained objects, the returned class name should be 'array'.
573     *
574     * @param array &$value An encoded array
575     * @param 'array'|class-string|null $classHint The class name provided as a hint to
576     *  the decoder, which was previously provided as a hint to the encoder,
577     *   or `null` if no hint was provided.
578     * @return 'array'|class-string The class name to be used for decoding, or
579     *  'array' if the value was a "complex" or "false mark" array.
580     */
581    protected function unmarkArray( array &$value, ?string $classHint ): string {
582        $abbrevOrClass = $value[self::TYPE_ANNOTATION] ?? null;
583        // Remove our marker and restore the previous state of the
584        // json array (restoring a pre-existing field if needed)
585        if ( is_array( $abbrevOrClass ) ) {
586            [ $oldValue, $abbrevOrClass ] = array_pad( $abbrevOrClass, 2, null );
587            $value[self::TYPE_ANNOTATION] = $oldValue;
588        } else {
589            unset( $value[self::TYPE_ANNOTATION] );
590        }
591        if ( str_starts_with( $abbrevOrClass ?? '', self::ABBREV_PREFIX ) ) {
592            $abbrev = $this->abbrevToHintMap[$abbrevOrClass] ?? null;
593            if ( $abbrev === null ) {
594                throw new InvalidArgumentException(
595                    "Unknown abbreviation: $abbrevOrClass"
596                );
597            }
598            $className = $abbrev->hint;
599            while ( $className instanceof Hint && $className->modifier === HintType::ONLY_FOR_DECODE ) {
600                $className = $className->parent;
601            }
602            if ( !is_string( $className ) ) {
603                throw new InvalidArgumentException(
604                    "Abbreviation used to encode a hint, not a class"
605                );
606            }
607        } else {
608            $className = $abbrevOrClass ?? $classHint;
609        }
610        return $className;
611    }
612
613    /**
614     * Helper function to test two class strings for equality in the presence
615     * of class aliases.
616     *
617     * @param 'array'|class-string $class1
618     * @param 'array'|class-string|null $class2
619     * @return bool True if the arguments refer to the same class
620     */
621    private static function classEquals( string $class1, ?string $class2 ): bool {
622        if ( $class1 === $class2 ) {
623            // Fast path
624            return true;
625        }
626        if ( $class2 === null || $class1 === 'array' || $class2 === 'array' ) {
627            return false;
628        }
629        if ( is_a( $class1, $class2, true ) && is_a( $class2, $class1, true ) ) {
630            // Check aliases
631            return true;
632        }
633        return false;
634    }
635}