Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.50% covered (success)
95.50%
403 / 422
81.58% covered (warning)
81.58%
31 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZObjectUtils
95.50% covered (success)
95.50%
403 / 422
81.58% covered (warning)
81.58%
31 / 38
190
0.00% covered (danger)
0.00%
0 / 1
 isValidSerialisedZObject
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 isValidZObject
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 isValidZObjectList
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 isValidZObjectResolver
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 isValidZObjectRecord
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 canonicalize
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
17
 orderZKeyIDs
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
11
 canonicalizeZRecord
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 comparableString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 filterZMultilingualStringsToLanguage
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
10
 getPreferredMonolingualObject
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 isTypeEqualTo
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 isValidZObjectReference
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isNullReference
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValidOrNullZObjectReference
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValidZObjectKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValidZObjectGlobalKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getZObjectReferenceFromKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIterativeList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRequiredZids
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 getLabelOfReference
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getLabelOfGlobalKey
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getLabelOfLocalKey
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 getLabelOfErrorTypeKey
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 getLabelOfTypeKey
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 getLabelOfFunctionArgument
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 extractHumanReadableZObject
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
11
 isCompatibleType
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getZid
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 makeCacheKeyFromZObject
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 readTestFile
n/a
0 / 0
n/a
0 / 0
1
 makeTypeFingerprint
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 replaceNullReferencePlaceholder
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 dereferenceZFunction
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
11.66
 getFunctionZidOrNull
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 encodeStringParamForNetwork
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decodeStringParamFromNetwork
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorsFromMetadata
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * WikiLambda ZObject utilities class
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda;
12
13use JsonException;
14use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
15use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
16use MediaWiki\Extension\WikiLambda\ZObjects\ZFunction;
17use MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall;
18use MediaWiki\Extension\WikiLambda\ZObjects\ZObject;
19use MediaWiki\Extension\WikiLambda\ZObjects\ZPersistentObject;
20use MediaWiki\Extension\WikiLambda\ZObjects\ZReference;
21use MediaWiki\Extension\WikiLambda\ZObjects\ZType;
22use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList;
23use MediaWiki\Language\Language;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\Title\Title;
26use Normalizer;
27use stdClass;
28use Transliterator;
29
30class ZObjectUtils {
31    /**
32     * @param string $input
33     * @return bool
34     */
35    public static function isValidSerialisedZObject( string $input ): bool {
36        // ZObject := String | List | Record
37        // String  := "Character*" // to be specific, as in JSON / ECMA-404
38        // List    := [(ZObject(,ZObject)*)]
39        // Record  := { "Z1K1": ZObject(, "Key": ZObject)* }
40        // Key     := ZNumberKNumber | KNumber
41        $status = true;
42
43        // Encoded inputs which don't start with {, or [, are instead read as strings.
44        if ( $input !== '' && ( $input[0] === '{' || $input[0] === '[' ) ) {
45            try {
46                $evaluatedInput = json_decode( $input, false, 512, JSON_THROW_ON_ERROR );
47            } catch ( JsonException ) {
48                return false;
49            }
50
51            try {
52                $status = self::isValidZObject( $evaluatedInput );
53            } catch ( ZErrorException ) {
54                $status = false;
55            }
56        }
57
58        return $status;
59    }
60
61    /**
62     * @param string|array|stdClass $input
63     * @return bool
64     * @throws ZErrorException
65     */
66    public static function isValidZObject( $input ): bool {
67        if ( is_string( $input ) ) {
68            return true;
69        }
70
71        if ( is_array( $input ) ) {
72            return self::isValidZObjectList( $input );
73        }
74
75        if ( is_object( $input ) ) {
76            return self::isValidZObjectRecord( $input );
77        }
78
79        // Fall through: invalid format error
80        throw new ZErrorException(
81            ZErrorFactory::createZErrorInstance(
82                ZErrorTypeRegistry::Z_ERROR_INVALID_FORMAT,
83                [
84                    'data' => $input
85                ]
86            )
87        );
88    }
89
90    /**
91     * @param array $input
92     * @return bool
93     * @throws ZErrorException
94     */
95    public static function isValidZObjectList( array $input ): bool {
96        if ( !$input ) {
97            throw new ZErrorException(
98                ZErrorFactory::createZErrorInstance(
99                    ZErrorTypeRegistry::Z_ERROR_UNDEFINED_LIST_TYPE,
100                    [
101                        'data' => $input
102                    ]
103                )
104            );
105        }
106
107        $listType = array_shift( $input );
108
109        if ( !self::isValidZObjectResolver( $listType ) ) {
110            throw new ZErrorException(
111                ZErrorFactory::createZErrorInstance(
112                    ZErrorTypeRegistry::Z_ERROR_WRONG_LIST_TYPE,
113                    [
114                        'data' => $listType
115                    ]
116                )
117            );
118        }
119
120        foreach ( $input as $index => $value ) {
121            try {
122                self::isValidZObject( $value );
123            } catch ( ZErrorException $e ) {
124                throw new ZErrorException(
125                    ZErrorFactory::createArrayElementZError( (string)$index, $e->getZError() )
126                );
127            }
128        }
129        return true;
130    }
131
132    /**
133     * @param mixed $input
134     * @return bool
135     * @throws ZErrorException
136     */
137    public static function isValidZObjectResolver( $input ): bool {
138        if ( is_string( $input ) ) {
139            return self::isValidZObjectReference( $input );
140        }
141
142        if ( is_object( $input ) ) {
143            try {
144                self::isValidZObjectRecord( $input );
145            } catch ( ZErrorException ) {
146                return false;
147            }
148            $resolverType = $input->{ ZTypeRegistry::Z_OBJECT_TYPE };
149            if ( ( $resolverType === ZTypeRegistry::Z_REFERENCE ) ||
150                ( $resolverType === ZTypeRegistry::Z_FUNCTIONCALL ) ||
151                ( $resolverType === ZTypeRegistry::Z_ARGUMENTREFERENCE ) ) {
152                return true;
153            }
154        }
155
156        return false;
157    }
158
159    /**
160     * @param stdClass $input
161     * @return bool
162     * @throws ZErrorException
163     */
164    public static function isValidZObjectRecord( stdClass $input ): bool {
165        $objectVars = get_object_vars( $input );
166
167        if ( !array_key_exists( ZTypeRegistry::Z_OBJECT_TYPE, $objectVars ) ) {
168            // Each ZObject must define its type.
169            throw new ZErrorException(
170                ZErrorFactory::createZErrorInstance(
171                    ZErrorTypeRegistry::Z_ERROR_MISSING_TYPE,
172                    [
173                        'data' => $input
174                    ]
175                )
176            );
177        }
178
179        foreach ( $input as $key => $value ) {
180            // Check wellformedness of the key
181            if ( !self::isValidZObjectKey( $key ) ) {
182                throw new ZErrorException(
183                    ZErrorFactory::createZErrorInstance(
184                        ZErrorTypeRegistry::Z_ERROR_INVALID_KEY,
185                        [
186                            'dataPointer' => [ $key ]
187                        ]
188                    )
189                );
190            }
191            // Check general wellformedness of the value
192            try {
193                self::isValidZObject( $value );
194            } catch ( ZErrorException $e ) {
195                throw new ZErrorException( ZErrorFactory::createKeyValueZError( $key, $e->getZError() ) );
196            }
197        }
198        return true;
199    }
200
201    /**
202     * Canonicalizes a ZObject.
203     *
204     * @param string|array|stdClass $input decoded JSON object for a valid ZObject
205     * @return string|array|stdClass canonical decoded JSON object of same ZObject
206     */
207    public static function canonicalize( $input ) {
208        if ( is_array( $input ) ) {
209            return array_map( [ self::class, 'canonicalize' ], $input );
210        }
211
212        if ( is_object( $input ) ) {
213            $outputObj = self::canonicalizeZRecord( $input );
214            $output = get_object_vars( $outputObj );
215
216            if ( array_key_exists( ZTypeRegistry::Z_OBJECT_TYPE, $output ) ) {
217                $type = self::canonicalize( $output[ ZTypeRegistry::Z_OBJECT_TYPE ] );
218
219                if ( is_string( $type ) ) {
220                    // Type is ZString
221                    if ( $type === ZTypeRegistry::Z_STRING
222                        && array_key_exists( ZTypeRegistry::Z_STRING_VALUE, $output )
223                        && !self::isValidZObjectReference( $output[ ZTypeRegistry::Z_STRING_VALUE ] ) ) {
224                        return self::canonicalize( $output[ ZTypeRegistry::Z_STRING_VALUE ] );
225                    }
226
227                    // Type is a ZReference
228                    if ( $type === ZTypeRegistry::Z_REFERENCE
229                        && array_key_exists( ZTypeRegistry::Z_REFERENCE_VALUE, $output )
230                        && self::isValidZObjectReference( $output[ ZTypeRegistry::Z_REFERENCE_VALUE ] ) ) {
231                        return self::canonicalize( $output[ ZTypeRegistry::Z_REFERENCE_VALUE ] );
232                    }
233                }
234
235                // Type is a Typed list
236                if ( is_object( $type ) ) {
237                    $typeVars = get_object_vars( $type );
238                    if (
239                        array_key_exists( ZTypeRegistry::Z_OBJECT_TYPE, $typeVars )
240                        && $typeVars[ ZTypeRegistry::Z_OBJECT_TYPE ] === ZTypeRegistry::Z_FUNCTIONCALL
241                        && array_key_exists( ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION, $typeVars )
242                        && $typeVars[ ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION ] === ZTypeRegistry::Z_FUNCTION_TYPED_LIST
243                    ) {
244                        $itemType = $typeVars[ ZTypeRegistry::Z_FUNCTION_TYPED_LIST_TYPE ];
245                        $typedListArray = [ $itemType ];
246                        if ( array_key_exists( 'K1', $output ) ) {
247                            array_push( $typedListArray, $output['K1'], ...array_slice( $output['K2'], 1 ) );
248                        }
249                        return $typedListArray;
250                    }
251                }
252            }
253            return $outputObj;
254        }
255
256        return $input;
257    }
258
259    /**
260     * Compares IDs of ZKeys in an order.
261     *
262     * First come global ZIDs, then local ones. The globals are sorted first
263     * numerically by the Z-Number, and then by the K-Number.
264     *
265     * @param string $left left key for comparision
266     * @param string $right right key for comparision
267     * @return int whether left is smaller (-1) than right or not (+1)
268     */
269    public static function orderZKeyIDs( string $left, string $right ): int {
270        if ( $left === $right ) {
271            return 0;
272        }
273        if ( $left[0] === 'Z' && $right[0] === 'K' ) {
274            return -1;
275        }
276        if ( $left[0] === 'K' && $right[0] === 'Z' ) {
277            return 1;
278        }
279        $leftkpos = strpos( $left, 'K' );
280        $rightkpos = strpos( $right, 'K' );
281        if ( $leftkpos === 0 ) {
282            $leftzid = 0;
283        } else {
284            $leftzid = intval( substr( $left, 1, $leftkpos - 1 ) );
285        }
286        if ( $rightkpos === 0 ) {
287            $rightzid = 0;
288        } else {
289            $rightzid = intval( substr( $right, 1, $rightkpos - 1 ) );
290        }
291        if ( $leftzid < $rightzid ) {
292            return -1;
293        }
294        if ( $leftzid > $rightzid ) {
295            return 1;
296        }
297        $leftkid = intval( substr( $left, $leftkpos + 1 ) );
298        $rightkid = intval( substr( $right, $rightkpos + 1 ) );
299        if ( $leftkid < $rightkid ) {
300            return -1;
301        }
302        return 1;
303    }
304
305    /**
306     * Canonicalizes a record-like ZObject.
307     *
308     * This trims and sorts the keys.
309     *
310     * @param stdClass $input The decoded JSON object of a well-formed ZObject
311     * @return stdClass Canonical decoded JSON object representing the same ZObject
312     */
313    public static function canonicalizeZRecord( stdClass $input ): stdClass {
314        $record = get_object_vars( $input );
315        $record = array_combine( array_map( 'trim', array_keys( $record ) ), $record );
316
317        $type = self::canonicalize( $record[ ZTypeRegistry::Z_OBJECT_TYPE ] ?? null );
318
319        uksort( $record, [ self::class, 'orderZKeyIDs' ] );
320        $record = array_map( [ self::class, 'canonicalize' ], $record );
321        return (object)$record;
322    }
323
324    /**
325     * Normalise and down-cast a label for database comparison by normalising Unicode, lower-casing,
326     * and collapsing accents.
327     *
328     * TODO (T362250): To consider further changes; is this sufficient for all use cases and languages?
329     *
330     * @param string $input The input
331     * @return string
332     */
333    public static function comparableString( string $input ): string {
334        // First, lower-case the input (in a multi-byte-aware manner)
335        $output = mb_strtolower( $input );
336
337        // This Transliterator removes Latin accents but e.g. retains Han characters as-is.
338        // Specifically, it does canonical decomposition (NFD); removes non-spacing marks like accents;
339        // then recomposes, e.g. for Korean Hangul syllables.
340        // TODO (T362250): Replace with a language-aware transliterator?
341        $transliterator = Transliterator::create( 'NFD; [:Nonspacing Mark:] Remove; NFC;' );
342        $output = $transliterator->transliterate( mb_strtolower( Normalizer::normalize( $output ) ) );
343
344        return $output;
345    }
346
347    /**
348     * Filters ZObject to preferred language.
349     *
350     * Given a ZObject, reduces all its ZMultilingualStrings to
351     * only the preferred language or fallbacks.
352     *
353     * @param array|stdClass|string $input decoded JSON object for a ZObject
354     * @param string[] $languages array of language Zids
355     * @return string|array|stdClass same ZObject with only selected Monolingual
356     * string for each of its Multilingual strings
357     */
358    public static function filterZMultilingualStringsToLanguage( $input, array $languages = [] ) {
359        if ( is_string( $input ) ) {
360            return $input;
361        }
362
363        if ( is_array( $input ) ) {
364            return array_map( function ( $item ) use ( $languages ) {
365                return self::filterZMultilingualStringsToLanguage( $item, $languages );
366            }, $input );
367        }
368
369        // For each key of the input ZObject
370        foreach ( $input as $index => $value ) {
371            // Apply language filter to every item of the array or object
372            $input->$index = self::filterZMultilingualStringsToLanguage( $value, $languages );
373
374            // If the value is a string, and the type is ZMonolingualString,
375            // select the preferred language out of the available ZMonolingualStrings
376            if (
377                is_string( $value ) &&
378                $index === ZTypeRegistry::Z_OBJECT_TYPE &&
379                $value === ZTypeRegistry::Z_MULTILINGUALSTRING
380            ) {
381                $input->{ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE} = self::getPreferredMonolingualObject(
382                    $input->{ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE},
383                    $languages,
384                    ZTypeRegistry::Z_MONOLINGUALSTRING_LANGUAGE
385                );
386                break;
387            }
388
389            // If the value is a string, and the type is ZMonolingualStringSet,
390            // select the preferred language out of the available ZMonolingualStringSets
391            if (
392                is_string( $value ) &&
393                $index === ZTypeRegistry::Z_OBJECT_TYPE &&
394                $value === ZTypeRegistry::Z_MULTILINGUALSTRINGSET
395            ) {
396                $input->{ZTypeRegistry::Z_MULTILINGUALSTRINGSET_VALUE} = self::getPreferredMonolingualObject(
397                    $input->{ZTypeRegistry::Z_MULTILINGUALSTRINGSET_VALUE},
398                    $languages,
399                    ZTypeRegistry::Z_MONOLINGUALSTRINGSET_LANGUAGE
400                );
401                break;
402            }
403        }
404        return $input;
405    }
406
407    /**
408     * Filters Monolingual Strings and Stringsets to the preferred language.
409     *
410     * Returns the preferred Monolingual String/Stringset of a Multilingual
411     * String/Stringset given an array of preferred languages.
412     *
413     * @param array $multilingual decoded JSON for a Multilingual String/Stringset value
414     * @param string[] $languages array of language Zids
415     * @param string $key Identifies the key that contains the language in the monolingual object, Z11K1 or Z31K1
416     * @return array same Multilingual String/Stringset value with only one item of the preferred language
417     */
418    private static function getPreferredMonolingualObject( array $multilingual, array $languages, string $key ): array {
419        // Ignore first item in the canonical form array; this is a string representing the type
420        $itemType = array_shift( $multilingual );
421
422        $availableLangs = [];
423        $selectedIndex = 0;
424
425        if ( !$multilingual ) {
426            return [ $itemType ];
427        }
428
429        foreach ( $multilingual as $value ) {
430            $availableLangs[] = $value->{$key};
431        }
432
433        foreach ( $languages as $lang ) {
434            $index = array_search( $lang, $availableLangs );
435            if ( $index !== false ) {
436                $selectedIndex = $index;
437                break;
438            }
439        }
440
441        return [ $itemType, $multilingual[ $selectedIndex ] ];
442    }
443
444    /**
445     * Asserts whether two types are equivalent
446     *
447     * @param stdClass|string $type1
448     * @param stdClass|string $type2
449     * @return bool
450     */
451    public static function isTypeEqualTo( $type1, $type2 ) {
452        // Not the same type
453        if ( gettype( $type1 ) !== gettype( $type2 ) ) {
454            return false;
455        }
456
457        // If they are both strings, return identity
458        if ( is_string( $type1 ) ) {
459            return $type1 === $type2;
460        }
461
462        // If they are both objects, compare their keys
463        $typeArr1 = (array)$type1;
464        $typeArr2 = (array)$type2;
465        if ( count( $typeArr1 ) !== count( $typeArr2 ) ) {
466            return false;
467        }
468        foreach ( $typeArr1 as $key => $value ) {
469            if ( !array_key_exists( $key, $typeArr2 ) ) {
470                return false;
471            }
472            if ( !self::isTypeEqualTo( $value, $typeArr2[ $key ] ) ) {
473                return false;
474            }
475        }
476        return true;
477    }
478
479    /**
480     * Is the input a ZObject reference key (e.g. Z1 or Z12345)?
481     *
482     * @param string $input
483     * @return bool
484     */
485    public static function isValidZObjectReference( string $input ): bool {
486        return preg_match( "/^Z[1-9]\d*$/", $input );
487    }
488
489    /**
490     * Is the input a null reference (Z0)?
491     *
492     * @param string $input
493     * @return bool
494     */
495    public static function isNullReference( string $input ): bool {
496        return ( $input === ZTypeRegistry::Z_NULL_REFERENCE );
497    }
498
499    /**
500     * Is the input a ZObject reference key (e.g. Z1 or Z12345)?
501     *
502     * @param string $input
503     * @return bool
504     */
505    public static function isValidOrNullZObjectReference( string $input ): bool {
506        return ( self::isValidZObjectReference( $input ) || self::isNullReference( $input ) );
507    }
508
509    /**
510     * Is the input a valid possible identifier across WMF projects?
511     *
512     * @param string $input
513     * @return bool
514     */
515    public static function isValidId( string $input ): bool {
516        return preg_match( "/^[A-Z][1-9]\d*$/", $input );
517    }
518
519    /**
520     * Is the input a ZObject reference key (e.g. Z1K1 or K12345)?
521     *
522     * @param string $input
523     * @return bool
524     */
525    public static function isValidZObjectKey( string $input ): bool {
526        return preg_match( "/^\s*(Z[1-9]\d*)?K\d+\s*$/", $input );
527    }
528
529    /**
530     * Is the input a global ZObject reference key (e.g. Z1K1)?
531     *
532     * @param string $input
533     * @return bool
534     */
535    public static function isValidZObjectGlobalKey( string $input ): bool {
536        return preg_match( "/^\s*Z[1-9]\d*K\d+\s*$/", $input );
537    }
538
539    /**
540     * Split out the ZObject reference from a given global reference key (e.g. 'Z1' from 'Z1K1').
541     *
542     * @param string $input
543     * @return string
544     */
545    public static function getZObjectReferenceFromKey( string $input ): string {
546        preg_match( "/^\s*(Z[1-9]\d*)?(K\d+)\s*$/", $input, $matches );
547        return $matches[1] ?? '';
548    }
549
550    /**
551     * Given an array or a ZTypedList, returns an array that can be iterated over
552     *
553     * @param array|ZTypedList $list
554     * @return array
555     */
556    public static function getIterativeList( $list ): array {
557        if ( $list instanceof ZTypedList ) {
558            return $list->getAsArray();
559        }
560        return $list;
561    }
562
563    /**
564     * @param string|array|\stdClass $zobject
565     * @return array
566     */
567    public static function getRequiredZids( $zobject ): array {
568        $zids = [];
569
570        // If $zobject is a reference, add to the array
571        if ( is_string( $zobject ) ) {
572            if ( self::isValidZObjectReference( $zobject ) ) {
573                $zids[] = $zobject;
574            }
575        }
576
577        // If $zobject is an array, get required Zids from each element
578        if ( is_array( $zobject ) ) {
579            foreach ( $zobject as $item ) {
580                $zids = array_merge( $zids, self::getRequiredZids( $item ) );
581            }
582        }
583
584        // If $zobject is an object, get required Zids from keys and values
585        if ( is_object( $zobject ) ) {
586            foreach ( $zobject as $key => $value ) {
587                // Add the reference part of the key. Do not add empty string if local key.
588                $ref = self::getZObjectReferenceFromKey( $key );
589                if ( $ref ) {
590                    $zids[] = $ref;
591                }
592                // Recursively add other references in the value
593                $zids = array_merge( $zids, self::getRequiredZids( $value ) );
594            }
595        }
596
597        return array_values( array_unique( $zids ) );
598    }
599
600    /**
601     * Returns the natural language label of a given Zid in the language
602     * passed as parameter or available fallback languages. If not available,
603     * returns the non-translated Zid.
604     *
605     * @param string $zid
606     * @param ZPersistentObject $zobject
607     * @param Language $lang
608     * @return string
609     */
610    public static function getLabelOfReference( $zid, $zobject, $lang ): string {
611        $labels = $zobject->getLabels();
612        $label = $labels->buildStringForLanguage( $lang )->fallbackWithEnglish()->getString();
613
614        if ( $label === null ) {
615            return $zid;
616        }
617
618        return $label;
619    }
620
621    /**
622     * Returns the natural language label of a given type key, function argument or error key
623     * in the language passed as parameter or available fallback languages. If not available,
624     * returns the untranslated key Id.
625     *
626     * @param string $key
627     * @param ZPersistentObject $zobject
628     * @param Language $lang
629     * @return string
630     */
631    public static function getLabelOfGlobalKey( $key, $zobject, $lang ): string {
632        $ztype = $zobject->getInternalZType();
633
634        if ( $ztype === ZTypeRegistry::Z_TYPE ) {
635            return self::getLabelOfTypeKey( $key, $zobject, $lang );
636        }
637
638        if ( $ztype === ZTypeRegistry::Z_FUNCTION ) {
639            return self::getLabelOfFunctionArgument( $key, $zobject, $lang );
640        }
641
642        if ( $ztype === ZTypeRegistry::Z_ERRORTYPE ) {
643            return self::getLabelOfErrorTypeKey( $key, $zobject, $lang );
644        }
645
646        // Not a type nor an error type, return untranslated key Id
647        return $key;
648    }
649
650    /**
651     * @param string $key
652     * @param \stdClass $zobject
653     * @param ZPersistentObject[] $data
654     * @param Language $lang
655     * @return string
656     */
657    public static function getLabelOfLocalKey( $key, $zobject, $data, $lang ): string {
658        $type = $zobject->{ ZTypeRegistry::Z_OBJECT_TYPE };
659
660        // If type is a reference, desambiguate and find the key in its type definition
661        if ( is_string( $type ) && ( array_key_exists( $type, $data ) ) ) {
662            $globalKey = "$type$key";
663            $translatedKey = self::getLabelOfGlobalKey( $globalKey, $data[ $type ], $lang );
664            if ( $translatedKey !== $globalKey ) {
665                return $translatedKey;
666            }
667        }
668
669        // If type is a function call,
670        if ( is_object( $type ) && property_exists( $type, ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION ) ) {
671            $function = $type->{ ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION };
672
673            // Builtin Z885: we can build the global keys with error type Zid
674            if ( is_string( $function ) && ( $function === ZTypeRegistry::Z_FUNCTION_ERRORTYPE_TO_TYPE ) ) {
675                $errorType = $type->{ ZTypeRegistry::Z_FUNCTION_ERRORTYPE_TYPE };
676                if ( array_key_exists( $errorType, $data ) ) {
677                    $globalKey = "$errorType$key";
678                    $translatedKey = self::getLabelOfErrorTypeKey( $globalKey, $data[ $errorType ], $lang );
679                    if ( $translatedKey !== $globalKey ) {
680                        return $translatedKey;
681                    }
682                }
683            }
684
685            // Builtin Z881: we don't need to translate keys
686            // TODO (T301451): Non builtins, request function call execution from the orchestrator
687        }
688
689        return $key;
690    }
691
692    /**
693     * Returns the natural language label of a given ZKey in the language
694     * passed as parameter or available fallback languages. If not available,
695     * returns the non-translated ZKey.
696     *
697     * @param string $key
698     * @param ZPersistentObject $zobject
699     * @param Language $lang
700     * @return string
701     */
702    public static function getLabelOfErrorTypeKey( $key, $zobject, $lang ): string {
703        $keys = $zobject->getInnerZObject()->getValueByKey( ZTypeRegistry::Z_ERRORTYPE_KEYS );
704
705        if ( !is_array( $keys ) && !( $keys instanceof ZTypedList ) ) {
706            return $key;
707        }
708
709        foreach ( self::getIterativeList( $keys ) as $zkey ) {
710            if ( $zkey->getKeyId() === $key ) {
711                $labels = $zkey->getKeyLabel();
712
713                if ( $labels === null ) {
714                    return $key;
715                }
716
717                $label = $labels->buildStringForLanguage( $lang )->fallbackWithEnglish()->getString();
718                if ( $label === null ) {
719                    return $key;
720                }
721
722                return $label;
723            }
724        }
725
726        // Key not found
727        return $key;
728    }
729
730    /**
731     * Returns the natural language label of a given ZKey in the language
732     * passed as parameter or available fallback languages. If not available,
733     * returns the non-translated ZKey.
734     *
735     * @param string $key
736     * @param ZPersistentObject $zobject
737     * @param Language $lang
738     * @return string
739     */
740    public static function getLabelOfTypeKey( $key, $zobject, $lang ): string {
741        $ztype = $zobject->getInnerZObject();
742        if ( !( $ztype instanceof ZType ) ) {
743            return $key;
744        }
745
746        $zkey = $ztype->getZKey( $key );
747        if ( $zkey === null ) {
748            return $key;
749        }
750
751        $labels = $zkey->getKeyLabel();
752        if ( $labels === null ) {
753            return $key;
754        }
755
756        $label = $labels->buildStringForLanguage( $lang )->fallbackWithEnglish()->getString();
757        if ( $label === null ) {
758            return $key;
759        }
760
761        return $label;
762    }
763
764    /**
765     * Returns the natural language label of a given ZArgument in the language
766     * passed as parameter or available fallback languages. If not available,
767     * returns the non-translated ZKey.
768     *
769     *
770     * @param string $key
771     * @param ZPersistentObject $zobject
772     * @param Language $lang
773     * @return string
774     */
775    public static function getLabelOfFunctionArgument( $key, $zobject, $lang ): string {
776        $zfunction = $zobject->getInnerZObject();
777        if ( !( $zfunction instanceof ZFunction ) ) {
778            return $key;
779        }
780
781        $zargs = $zfunction->getArgumentDeclarations();
782        foreach ( $zargs as $zarg ) {
783            $zargid = $zarg->getValueByKey( ZTypeRegistry::Z_ARGUMENTDECLARATION_ID );
784
785            if ( $key === $zargid->getZValue() ) {
786                $labels = $zarg->getValueByKey( ZTypeRegistry::Z_ARGUMENTDECLARATION_LABEL );
787                $label = $labels->buildStringForLanguage( $lang )->fallbackWithEnglish()->getString();
788                if ( $label === null ) {
789                    return $key;
790                }
791                return $label;
792            }
793        }
794
795        // key not found in argument list
796        return $key;
797    }
798
799    /**
800     * Translates a serialized ZObject from Zids and ZKeys to natural language in the
801     * language passed as parameter or available fallback languages.
802     *
803     * @param stdClass|array|string $zobject
804     * @param ZPersistentObject[] $data
805     * @param Language $lang
806     * @return stdClass|array|string
807     * @throws ZErrorException
808     */
809    public static function extractHumanReadableZObject( $zobject, $data, $lang ) {
810        if ( is_string( $zobject ) ) {
811            if ( self::isValidZObjectReference( $zobject ) ) {
812                if ( array_key_exists( $zobject, $data ) ) {
813                    return self::getLabelOfReference( $zobject, $data[ $zobject ], $lang );
814                }
815            }
816            return $zobject;
817        }
818
819        if ( is_array( $zobject ) ) {
820            return array_map( function ( $item ) use ( $data, $lang ) {
821                return self::extractHumanReadableZObject( $item, $data, $lang );
822            }, $zobject );
823        }
824
825        if ( !is_object( $zobject ) ) {
826            // Fall through: invalid syntax error
827            throw new ZErrorException(
828                ZErrorFactory::createZErrorInstance(
829                    ZErrorTypeRegistry::Z_ERROR_INVALID_SYNTAX, [
830                        'input' => var_export( $zobject, true ),
831                        'message' => __METHOD__ . ' received a ZObject with invalid syntax'
832                    ] )
833            );
834        }
835
836        $labelized = [];
837        foreach ( $zobject as $key => $value ) {
838            // Labelize key:
839            if ( self::isValidZObjectGlobalKey( $key ) ) {
840                // If $key is a global key (ZnK1, ZnK2...), typeZid contains the
841                // Zid where we can find the key definition.
842                $typeZid = self::getZObjectReferenceFromKey( $key );
843                if ( array_key_exists( $typeZid, $data ) ) {
844                    $labelizedKey = self::getLabelOfGlobalKey( $key, $data[ $typeZid ], $lang );
845                } else {
846                    $labelizedKey = $key;
847                }
848            } else {
849                // If $key is local, get the type from $zobject[ Z1K1 ]
850                $labelizedKey = self::getLabelOfLocalKey( $key, $zobject, $data, $lang );
851            }
852
853            // Labelize value:
854            $labelizedValue = in_array( $key, ZTypeRegistry::IGNORE_KEY_VALUES_FOR_LABELLING )
855                ? $value
856                : self::extractHumanReadableZObject( $value, $data, $lang );
857
858            // Exception: labelized key already exists
859            if ( array_key_exists( $labelizedKey, $labelized ) ) {
860                $labelized[ "$labelizedKey ($key)" ] = $labelizedValue;
861            } else {
862                $labelized[ $labelizedKey ] = $labelizedValue;
863            }
864
865        }
866        return (object)$labelized;
867    }
868
869    /**
870     * @param ZObject $accepted The ZObject we accept (typically a ZReference)
871     * @param ZObject $input A ZObject we're looking to evaluate whether it's compatible
872     * @return bool True if the types are compatible
873     */
874    public static function isCompatibleType( ZObject $accepted, ZObject $input ): bool {
875        // Do we accept anything? If so, go ahead.
876        if ( $accepted->getZValue() === 'Z1' ) {
877            return true;
878        }
879
880        // Are we being given a reference? If so, go ahead.
881        // TODO (T318588): Dereference this to see if it is actually to an allowed object?
882        if ( $input instanceof ZReference ) {
883            return true;
884        }
885
886        // Are we being given a function call? If so, go ahead.
887        // TODO (T318588): Execute this to see if it is actually to an allowed object?
888        if ( $input instanceof ZFunctionCall ) {
889            return true;
890        }
891
892        // Do we exactly match by type what's accepted? If so, go ahead.
893        if ( $accepted->getZValue() === $input->getZTypeObject()->getZValue() ) {
894            return true;
895        }
896
897        // Otherwise, no.
898        return false;
899    }
900
901    /**
902     * Get the ZID of the input if it's a persistent ZObject or a reference to one.
903     *
904     * @param mixed $zobject The ZObject to examine for
905     * @return string The ZID of the given ZObject, or Z0
906     */
907    public static function getZid( $zobject ): string {
908        // Return value if reference:
909        if ( $zobject instanceof ZReference ) {
910            return $zobject->getZValue();
911        }
912        // Return identity if persistent object:
913        if ( $zobject instanceof ZPersistentObject ) {
914            return $zobject->getZid();
915        }
916        // Use placeholder ZID for non-persisted objects.
917        return ZTypeRegistry::Z_NULL_REFERENCE;
918    }
919
920    /**
921     * Walk a given input ZObject, and make a cache key constructed of its keys and values, with any
922     * ZObject referenced being expanded to also include its revision ID.
923     *
924     * E.g. { "Z1K1": "Z7", "Z7K1": "Z801", "Z801K1": "Hey" } => 'Z1K1|Z7#1,Z7K1|Z801#2,Z801K1|Hey'
925     *
926     * TODO (T338245): Is this cache key too broad? Can we simplify?
927     *
928     * TODO (T338246): When a Z7/Function call, we also need to poison the key with the revision ID of the
929     * relevant implementation, but we don't know which was selected, as that's the call of the
930     * function orchestrator.
931     *
932     * @param \stdClass|array $query
933     * @return string response object returned by orchestrator
934     */
935    public static function makeCacheKeyFromZObject( $query ): string {
936        $accumulator = '';
937
938        foreach ( $query as $key => $value ) {
939            $accumulator .= $key . '|';
940            if ( is_array( $value ) || is_object( $value ) ) {
941                $accumulator .= self::makeCacheKeyFromZObject( $value );
942            } elseif ( is_scalar( $value ) ) {
943                $accumulator .= $value;
944                // Special-case: If this is a ZObject reference, also append the object's revision ID to cache-bust
945                if ( is_string( $value ) && self::isValidZObjectReference( $value ) ) {
946                    $accumulator .= '#' . Title::newFromDBkey( $value )->getLatestRevID();
947                }
948            }
949            $accumulator .= ',';
950        }
951
952        return $accumulator;
953    }
954
955    /**
956     * Reads file contents from test data directory.
957     * @param string $fileName
958     * @return string file contents
959     * @codeCoverageIgnore
960     */
961    public static function readTestFile( $fileName ): string {
962        $baseDir = __DIR__ .
963            DIRECTORY_SEPARATOR .
964            '..' .
965            DIRECTORY_SEPARATOR .
966            'tests' .
967            DIRECTORY_SEPARATOR .
968            'phpunit' .
969            DIRECTORY_SEPARATOR .
970            'test_data';
971        $fullFile = $baseDir . DIRECTORY_SEPARATOR . $fileName;
972        return file_get_contents( $fullFile );
973    }
974
975    /**
976     * Given a ZObject representing a type, return a string encoding of the type.
977     *
978     * N.B. Currently this only works for ZIDs and Z7s (Z_FUNCTIONCALL). It returns null
979     * for anything else.
980     *
981     * @param stdClass|string $typeStringOrObject
982     * @return string|null
983     */
984    public static function makeTypeFingerprint( $typeStringOrObject ): ?string {
985        if ( is_string( $typeStringOrObject ) ) {
986            return $typeStringOrObject;
987        }
988
989        $logger = LoggerFactory::getInstance( 'WikiLambda' );
990        if ( !is_object( $typeStringOrObject ) ||
991            !property_exists( $typeStringOrObject, ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION ) ) {
992            $logger->warning(
993                __METHOD__ . ' Unable to make fingerprint for given type',
994                [ 'typeStringOrObject' => $typeStringOrObject ]
995            );
996            return null;
997        }
998
999        // We're in a function call-defined type, not a reference
1000        $callInputTypes = [];
1001        foreach ( $typeStringOrObject as $key => $value ) {
1002            if ( $key === ZTypeRegistry::Z_OBJECT_TYPE || $key === ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION ) {
1003                continue;
1004            }
1005            // Call ourselves in case the inner value is also a function call
1006            $callInputTypes[$key] = static::makeTypeFingerprint( $value );
1007            if ( $callInputTypes[$key] === null ) {
1008                return null;
1009            }
1010        }
1011        // Ensure that keys are re-ordered to the logical sequence regardless of input order
1012        rsort( $callInputTypes, SORT_NATURAL );
1013        return $typeStringOrObject->{ ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION }
1014           . '(' . implode( ',', $callInputTypes ) . ')';
1015    }
1016
1017    /**
1018     * Replaces all the instances of "Z0" references and keys with the newly assigned
1019     * Zid. If the ZObject has a code key/Z16K2, it also replaces instances of unquoted
1020     * Z0s inside of the submitted code.
1021     *
1022     * @param string $data
1023     * @param string $zid
1024     * @return string
1025     */
1026    public static function replaceNullReferencePlaceholder( $data, $zid ): string {
1027        // Replace references and keys:
1028        $zPlaceholderRegex = '/\"' . ZTypeRegistry::Z_NULL_REFERENCE . '(K[1-9]\d*)?\"/';
1029        $zObjectString = preg_replace( $zPlaceholderRegex, "\"$zid$1\"", $data );
1030
1031        // Match code value and replace Z0s inside of it
1032        // Use preg_replace_callback to avoid preg_replace interpreting backslashes in replacement
1033        $codeRegex = '/\"' . preg_quote( ZTypeRegistry::Z_CODE_CODE, '/' ) . '\":(\s*)\"((?:\\\\\"|[^\"])*)\"/';
1034        $z0Regex = '/' . preg_quote( ZTypeRegistry::Z_NULL_REFERENCE, '/' ) . '(K[1-9]\d*)?/';
1035        $zObjectString = preg_replace_callback(
1036            $codeRegex,
1037            static function ( $matches ) use ( $zid, $z0Regex ) {
1038                // $matches[0] is the full match: "Z16K2":"...code..."
1039                // $matches[1] is the whitespace between colon and value
1040                // $matches[2] is the code content (without the outer quotes)
1041                $whitespace = $matches[1];
1042                $codeContent = $matches[2];
1043                // Replace Z0 references in the code content only
1044                $newCodeContent = preg_replace( $z0Regex, "$zid$1", $codeContent );
1045                // Reconstruct the JSON key-value pair, preserving the original structure and whitespace
1046                return '"' . ZTypeRegistry::Z_CODE_CODE . '":' . $whitespace . '"' . $newCodeContent . '"';
1047            },
1048
1049            $zObjectString
1050        );
1051        return $zObjectString;
1052    }
1053
1054    /**
1055     * Walks a ZObject tree (a ZFunctionCall) and replaces references of a given
1056     * function with a given literal object.
1057     *
1058     * @param ZFunctionCall|stdClass $functionCall
1059     * @param string $functionZid
1060     * @param ZFunction|stdClass $functionObj
1061     * @return string|array|stdClass
1062     */
1063    public static function dereferenceZFunction( $functionCall, $functionZid, $functionObj ) {
1064        $call = ( $functionCall instanceof ZObject ) ? $functionCall->getSerialized() : $functionCall;
1065        $function = ( $functionObj instanceof ZObject ) ? $functionObj->getSerialized() : $functionObj;
1066
1067        // Early exit condition
1068        if ( $call === $functionZid ) {
1069            return $function;
1070        }
1071
1072        // If is array, recurse over each item
1073        if ( is_array( $call ) ) {
1074            foreach ( $call as $index => $item ) {
1075                $call[$index] = self::dereferenceZFunction( $item, $functionZid, $function );
1076            }
1077            return $call;
1078        }
1079
1080        // If is object, find Z7K1 key
1081        if ( $call instanceof \stdClass ) {
1082            $vars = get_object_vars( $call );
1083
1084            // Edge case: check for normal reference
1085            if ( isset( $vars[ 'Z9K1'] ) && ( $vars[ 'Z9K1' ] === $functionZid ) ) {
1086                return $function;
1087            }
1088
1089            // Else, iterate through values and replace canonical
1090            // references or recurse over the rest of the keys
1091            foreach ( $vars as $key => $value ) {
1092                if ( $value === $functionZid ) {
1093                    $call->$key = $function;
1094                } else {
1095                    $call->$key = self::dereferenceZFunction( $value, $functionZid, $function );
1096                }
1097            }
1098        }
1099        return $call;
1100    }
1101
1102    /**
1103     * Given a function call in its canonical form, returns the top level function Zid:
1104     * * when it's a referenced function, returns the zid of that reference,
1105     * * when it's a literal function, returns the terminal value of Z8K5.
1106     * If no valid Zid was found, returns null.
1107     *
1108     * @param stdClass $functionCall
1109     * @return string|null
1110     */
1111    public static function getFunctionZidOrNull( $functionCall ) {
1112        if ( property_exists( $functionCall, 'Z7K1' ) ) {
1113            if ( gettype( $functionCall->Z7K1 ) === 'string' ) {
1114                return $functionCall->Z7K1;
1115            } elseif (
1116                is_object( $functionCall->Z7K1 ) &&
1117                property_exists( $functionCall->Z7K1, 'Z8K5' )
1118            ) {
1119                return $functionCall->Z7K1->Z8K5;
1120            }
1121        }
1122        return null;
1123    }
1124
1125    public static function encodeStringParamForNetwork( string $input ): string {
1126        return str_replace( [ '+', '/', '=' ], [ '-', '_', '' ], base64_encode( $input ) );
1127    }
1128
1129    public static function decodeStringParamFromNetwork( string $input ): string {
1130        return base64_decode( str_replace( [ '-', '_' ], [ '+', '/' ], $input ) );
1131    }
1132
1133    /**
1134     * Given the content of a Z22/Response Envelope object, decoded as an array,
1135     * returns the value of the 'errors' key if it exists and null if it doesn't.
1136     *
1137     * @param stdClass $metadata
1138     * @return stdClass|null
1139     */
1140    public static function getErrorsFromMetadata( $metadata ) {
1141        if ( !property_exists( $metadata, 'K1' ) ) {
1142            return null;
1143        }
1144
1145        $typedList = $metadata->K1;
1146        if ( !is_array( $typedList ) || count( $typedList ) <= 1 ) {
1147            return null;
1148        }
1149
1150        $mapItems = array_slice( $typedList, 1 );
1151        foreach ( $mapItems as $item ) {
1152            if ( $item->K1 === 'errors' ) {
1153                return $item->K2;
1154            }
1155        }
1156
1157        return null;
1158    }
1159}