@kirigami/php-prepros 1.9.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,39 +3,44 @@
3
3
  declare(strict_types=1);
4
4
 
5
5
  /**
6
- * SchemaValidator
6
+ * SCHEMA — JSON Schema validator backed by the native `jsonk` extension
7
+ * (statically built into @kirigami/php-wasm — see php-kirigami/php-jsonk),
8
+ * with an Ajv-like API.
7
9
  *
8
- * A pure PHP JSON Schema validator (Draft 7 style, similar to Ajv),
9
- * with no external dependencies.
10
+ * jsonk targets draft 2020-12 and takes JSON strings, so this class encodes
11
+ * the PHP schema and data first. The encoding step keeps the schemas written
12
+ * for the previous pure-PHP validator working:
13
+ * - an empty PHP array in a schema position (`'properties' => []`, a `[]`
14
+ * sub-schema) becomes `{}` instead of JSON `[]`;
15
+ * - draft-07 tuple `items` (a list of schemas) becomes `prefixItems`, and
16
+ * `additionalItems` becomes `items`;
17
+ * - `format: url` becomes `format: uri`.
10
18
  *
11
- * Usage example:
19
+ * Errors keep the previous `"path: message"` string format, with paths such
20
+ * as `(root)`, `name` or `tags[1]`.
12
21
  *
13
- * $schema = [
14
- * 'type' => 'object',
15
- * 'required' => ['name', 'age'],
16
- * 'properties' => [
17
- * 'name' => ['type' => 'string', 'minLength' => 1],
18
- * 'age' => ['type' => 'integer', 'minimum' => 0],
19
- * 'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
20
- * ],
21
- * 'additionalProperties' => false,
22
- * ];
22
+ * Kept the old implementation as SCHEMA_LEGACY (schema-legacy.class.php) —
23
+ * untouched, still autoloadable — as a rollback path.
23
24
  *
24
- * $validator = new SchemaValidator($schema);
25
- *
26
- * if (!$validator->isValid(['name' => 'Alice', 'age' => 30])) {
27
- * foreach ($validator->getErrors() as $error) {
28
- * echo $error, PHP_EOL;
29
- * }
30
- * }
25
+ * Usage:
26
+ * $validator = new SCHEMA(['type' => 'object', 'required' => ['name']]);
27
+ * if (!$validator->isValid($data)) print_r($validator->getErrors());
31
28
  */
32
29
  final class SCHEMA
33
30
  {
34
- /** @var array<string,mixed> Schema used as the entry point of isValid(). */
35
- private array $rootSchema;
31
+ /** Keywords whose value is a single sub-schema. */
32
+ private const SUBSCHEMA = [
33
+ 'additionalItems', 'additionalProperties', 'contains', 'else', 'if', 'items',
34
+ 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties',
35
+ ];
36
+
37
+ /** Keywords whose value is a list of sub-schemas. */
38
+ private const SUBSCHEMA_LIST = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
39
+
40
+ /** Keywords whose value maps names to sub-schemas. */
41
+ private const SUBSCHEMA_MAP = ['$defs', 'definitions', 'dependentSchemas', 'patternProperties', 'properties'];
36
42
 
37
- /** @var array<string,mixed> Root document used to resolve local $ref pointers. */
38
- private array $refRoot;
43
+ private string $schemaJson;
39
44
 
40
45
  /** @var string[] */
41
46
  private array $errors = [];
@@ -45,506 +50,128 @@ final class SCHEMA
45
50
  */
46
51
  public function __construct(array $schema)
47
52
  {
48
- $this->rootSchema = $schema;
49
- $this->refRoot = $schema;
53
+ if (!function_exists('jsonk_validate')) {
54
+ throw new RuntimeException('SCHEMA requires the jsonk extension; use SCHEMA_LEGACY without it.');
55
+ }
56
+ $this->schemaJson = json_encode(self::normalize($schema), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
50
57
  }
51
58
 
52
59
  /**
53
60
  * Validates data. Returns true/false, like Ajv's `validate()`.
54
61
  * Errors are available via getErrors().
55
- *
56
- * @param mixed $data
57
62
  */
58
63
  public function isValid(mixed $data): bool
59
64
  {
60
65
  $this->errors = [];
61
- $this->validateAgainst($data, $this->rootSchema, '');
62
-
63
- return count($this->errors) === 0;
64
- }
65
-
66
- /**
67
- * Alias closer to Ajv's semantics (validate($data)).
68
- */
69
- public function validate(mixed $data): bool
70
- {
71
- return $this->isValid($data);
72
- }
73
-
74
- /**
75
- * @return string[] List of error messages (path: message).
76
- */
77
- public function getErrors(): array
78
- {
79
- return $this->errors;
80
- }
81
66
 
82
- private function addError(string $path, string $message): void
83
- {
84
- $p = $path === '' ? '(root)' : $path;
85
- $this->errors[] = "{$p}: {$message}";
86
- }
87
-
88
- /**
89
- * @param mixed $data
90
- * @param array<string,mixed> $schema
91
- */
92
- private function validateAgainst(mixed $data, array $schema, string $path): void
93
- {
94
- // Local $ref (#/definitions/xxx or #/$defs/xxx)
95
- if (isset($schema['$ref'])) {
96
- $resolved = $this->resolveRef((string) $schema['$ref']);
97
- if ($resolved === null) {
98
- $this->addError($path, "reference not found: {$schema['$ref']}");
99
- return;
100
- }
101
- $this->validateAgainst($data, $resolved, $path);
102
- return;
103
- }
104
-
105
- if (isset($schema['const'])) {
106
- if (!$this->looseEquals($data, $schema['const'])) {
107
- $this->addError($path, 'does not match the expected const value');
108
- }
109
- }
110
-
111
- if (isset($schema['enum']) && is_array($schema['enum'])) {
112
- $found = false;
113
- foreach ($schema['enum'] as $candidate) {
114
- if ($this->looseEquals($data, $candidate)) {
115
- $found = true;
116
- break;
117
- }
118
- }
119
- if (!$found) {
120
- $this->addError($path, 'is not one of the allowed enum values');
121
- }
122
- }
123
-
124
- if (isset($schema['type'])) {
125
- $this->validateType($data, $schema['type'], $path);
126
- }
127
-
128
- $this->validateCombinators($data, $schema, $path);
129
-
130
- $type = $this->detectType($data);
131
-
132
- switch ($type) {
133
- case 'string':
134
- $this->validateString($data, $schema, $path);
135
- break;
136
- case 'integer':
137
- case 'number':
138
- $this->validateNumber($data, $schema, $path);
139
- break;
140
- case 'array':
141
- $this->validateArray($data, $schema, $path);
142
- break;
143
- case 'object':
144
- $this->validateObject($data, $schema, $path);
145
- break;
146
- }
147
- }
148
-
149
- /**
150
- * @param array<string,mixed> $schema
151
- */
152
- private function validateCombinators(mixed $data, array $schema, string $path): void
153
- {
154
- if (isset($schema['allOf']) && is_array($schema['allOf'])) {
155
- foreach ($schema['allOf'] as $sub) {
156
- $this->validateAgainst($data, $sub, $path);
157
- }
158
- }
159
-
160
- if (isset($schema['anyOf']) && is_array($schema['anyOf'])) {
161
- $ok = false;
162
- foreach ($schema['anyOf'] as $sub) {
163
- if ($this->isSubSchemaValid($data, $sub)) {
164
- $ok = true;
165
- break;
166
- }
167
- }
168
- if (!$ok) {
169
- $this->addError($path, 'does not satisfy any of the anyOf schemas');
170
- }
171
- }
172
-
173
- if (isset($schema['oneOf']) && is_array($schema['oneOf'])) {
174
- $matches = 0;
175
- foreach ($schema['oneOf'] as $sub) {
176
- if ($this->isSubSchemaValid($data, $sub)) {
177
- $matches++;
178
- }
179
- }
180
- if ($matches !== 1) {
181
- $this->addError($path, "must satisfy exactly one oneOf schema ({$matches} match(es))");
182
- }
183
- }
184
-
185
- if (isset($schema['not'])) {
186
- if ($this->isSubSchemaValid($data, $schema['not'])) {
187
- $this->addError($path, 'must not satisfy the "not" schema');
188
- }
189
- }
190
- }
191
-
192
- /**
193
- * @param array<string,mixed> $schema
194
- */
195
- private function isSubSchemaValid(mixed $data, array $schema): bool
196
- {
197
- $sub = new self($schema);
198
- // $ref pointers inside the sub-schema must keep resolving against
199
- // the original root document, not against the sub-schema itself.
200
- $sub->refRoot = $this->refRoot;
201
- return $sub->isValid($data);
202
- }
203
-
204
- private function validateType(mixed $data, mixed $expected, string $path): void
205
- {
206
- $expectedTypes = is_array($expected) ? $expected : [$expected];
207
- $actual = $this->detectType($data);
208
-
209
- foreach ($expectedTypes as $t) {
210
- if ($t === 'integer' && $actual === 'number' && is_float($data) && floor($data) === $data) {
211
- return;
212
- }
213
- if ($t === $actual) {
214
- return;
215
- }
216
- if ($t === 'number' && $actual === 'integer') {
217
- return;
218
- }
219
- }
220
-
221
- $expectedStr = implode('|', $expectedTypes);
222
- $this->addError($path, "invalid type: expected {$expectedStr}, got {$actual}");
223
- }
224
-
225
- private function detectType(mixed $data): string
226
- {
227
- return match (true) {
228
- $data === null => 'null',
229
- is_bool($data) => 'boolean',
230
- is_int($data) => 'integer',
231
- is_float($data) => (floor($data) === $data ? 'integer' : 'number'),
232
- is_string($data) => 'string',
233
- // A JSON array always decodes to a PHP list array, whether or not
234
- // json_decode() was called with associative=true.
235
- is_array($data) && $this->isList($data) => 'array',
236
- // A JSON object decodes to either an associative array
237
- // (json_decode(..., true)) or a stdClass (json_decode(..., false)).
238
- is_array($data) || is_object($data) => 'object',
239
- default => 'unknown',
240
- };
241
- }
242
-
243
- /**
244
- * True for plain PHP list arrays (sequential integer keys from 0).
245
- * Objects (stdClass or associative arrays) are never lists.
246
- */
247
- private function isList(mixed $arr): bool
248
- {
249
- if (!is_array($arr)) {
67
+ try {
68
+ $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
69
+ } catch (JsonException $e) {
70
+ $this->errors[] = '(root): value cannot be encoded as JSON: ' . $e->getMessage();
250
71
  return false;
251
72
  }
252
- if ($arr === []) {
73
+
74
+ if (jsonk_validate($json, $this->schemaJson)) {
253
75
  return true;
254
76
  }
255
- return array_keys($arr) === range(0, count($arr) - 1);
256
- }
257
-
258
- /**
259
- * True when $data behaves like a JSON object: either a stdClass
260
- * (json_decode(..., false)) or a non-list associative array
261
- * (json_decode(..., true)).
262
- */
263
- private function isObjectLike(mixed $data): bool
264
- {
265
- return is_object($data) || (is_array($data) && !$this->isList($data));
266
- }
267
77
 
268
- /**
269
- * @return string[] Property names of an object-like value, in order.
270
- */
271
- private function objectKeys(mixed $data): array
272
- {
273
- if (is_object($data)) {
274
- return array_keys(get_object_vars($data));
275
- }
276
- if (is_array($data)) {
277
- return array_map('strval', array_keys($data));
78
+ $violations = jsonk_get_last_errors();
79
+ if (!$violations) {
80
+ // Schema-level failure (unresolvable $ref, invalid keyword value…):
81
+ // jsonk reports it through its last error message only.
82
+ $this->errors[] = '(root): ' . jsonk_last_error_msg();
83
+ return false;
278
84
  }
279
- return [];
280
- }
281
85
 
282
- private function objectHas(mixed $data, string $key): bool
283
- {
284
- if (is_object($data)) {
285
- return property_exists($data, $key);
286
- }
287
- if (is_array($data)) {
288
- return array_key_exists($key, $data);
86
+ $decoded = json_decode($json);
87
+ foreach ($violations as $violation) {
88
+ $this->errors[] = self::displayPath($violation['path'], $decoded) . ': ' . $violation['message'];
289
89
  }
290
90
  return false;
291
91
  }
292
92
 
293
- private function objectGet(mixed $data, string $key): mixed
294
- {
295
- if (is_object($data)) {
296
- return $data->{$key};
297
- }
298
- return $data[$key];
299
- }
300
-
301
93
  /**
302
- * @param array<string,mixed> $schema
94
+ * Alias closer to Ajv's semantics (validate($data)).
303
95
  */
304
- private function validateString(mixed $data, array $schema, string $path): void
305
- {
306
- if (!is_string($data)) {
307
- return;
308
- }
309
-
310
- $len = function_exists('mb_strlen') ? mb_strlen($data) : strlen($data);
311
-
312
- if (isset($schema['minLength']) && $len < (int) $schema['minLength']) {
313
- $this->addError($path, "minimum length {$schema['minLength']} not met ({$len})");
314
- }
315
-
316
- if (isset($schema['maxLength']) && $len > (int) $schema['maxLength']) {
317
- $this->addError($path, "maximum length {$schema['maxLength']} exceeded ({$len})");
318
- }
319
-
320
- if (isset($schema['pattern'])) {
321
- $pattern = '/' . str_replace('/', '\/', (string) $schema['pattern']) . '/u';
322
- if (@preg_match($pattern, $data) !== 1) {
323
- $this->addError($path, "does not match pattern {$schema['pattern']}");
324
- }
325
- }
326
-
327
- if (isset($schema['format'])) {
328
- $this->validateFormat($data, (string) $schema['format'], $path);
329
- }
330
- }
331
-
332
- private function validateFormat(string $data, string $format, string $path): void
96
+ public function validate(mixed $data): bool
333
97
  {
334
- $ok = match ($format) {
335
- 'email' => (bool) filter_var($data, FILTER_VALIDATE_EMAIL),
336
- 'uri', 'url' => (bool) filter_var($data, FILTER_VALIDATE_URL),
337
- 'date' => (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $data)
338
- && checkdate((int) substr($data, 5, 2), (int) substr($data, 8, 2), (int) substr($data, 0, 4)),
339
- 'date-time' => (bool) preg_match(
340
- '/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/',
341
- $data
342
- ),
343
- 'ipv4' => (bool) filter_var($data, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4),
344
- 'ipv6' => (bool) filter_var($data, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6),
345
- 'uuid' => (bool) preg_match(
346
- '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
347
- $data
348
- ),
349
- default => true, // Unknown format: does not block, same as Ajv in non-strict mode.
350
- };
351
-
352
- if (!$ok) {
353
- $this->addError($path, "invalid format, expected '{$format}'");
354
- }
98
+ return $this->isValid($data);
355
99
  }
356
100
 
357
101
  /**
358
- * @param array<string,mixed> $schema
102
+ * @return string[] List of error messages (path: message).
359
103
  */
360
- private function validateNumber(mixed $data, array $schema, string $path): void
104
+ public function getErrors(): array
361
105
  {
362
- if (!is_int($data) && !is_float($data)) {
363
- return;
364
- }
365
-
366
- if (isset($schema['minimum']) && $data < $schema['minimum']) {
367
- $this->addError($path, "must be >= {$schema['minimum']}");
368
- }
369
-
370
- if (isset($schema['maximum']) && $data > $schema['maximum']) {
371
- $this->addError($path, "must be <= {$schema['maximum']}");
372
- }
373
-
374
- if (isset($schema['exclusiveMinimum']) && $data <= $schema['exclusiveMinimum']) {
375
- $this->addError($path, "must be > {$schema['exclusiveMinimum']}");
376
- }
377
-
378
- if (isset($schema['exclusiveMaximum']) && $data >= $schema['exclusiveMaximum']) {
379
- $this->addError($path, "must be < {$schema['exclusiveMaximum']}");
380
- }
381
-
382
- if (isset($schema['multipleOf'])) {
383
- $factor = (float) $schema['multipleOf'];
384
- if ($factor > 0) {
385
- $div = $data / $factor;
386
- if (abs($div - round($div)) > 1e-9) {
387
- $this->addError($path, "must be a multiple of {$schema['multipleOf']}");
388
- }
389
- }
390
- }
106
+ return $this->errors;
391
107
  }
392
108
 
393
109
  /**
394
- * @param array<string,mixed> $schema
110
+ * Prepares a schema node for JSON encoding (see the class comment).
395
111
  */
396
- private function validateArray(mixed $data, array $schema, string $path): void
112
+ private static function normalize(mixed $schema): mixed
397
113
  {
398
- if (!is_array($data) || !$this->isList($data)) {
399
- return;
114
+ if (is_object($schema)) {
115
+ $schema = get_object_vars($schema);
400
116
  }
401
-
402
- $count = count($data);
403
-
404
- if (isset($schema['minItems']) && $count < (int) $schema['minItems']) {
405
- $this->addError($path, "must contain at least {$schema['minItems']} item(s)");
406
- }
407
-
408
- if (isset($schema['maxItems']) && $count > (int) $schema['maxItems']) {
409
- $this->addError($path, "must contain at most {$schema['maxItems']} item(s)");
410
- }
411
-
412
- if (($schema['uniqueItems'] ?? false) === true) {
413
- $seen = [];
414
- foreach ($data as $item) {
415
- $key = json_encode($item, JSON_THROW_ON_ERROR);
416
- if (isset($seen[$key])) {
417
- $this->addError($path, 'items must be unique');
418
- break;
419
- }
420
- $seen[$key] = true;
421
- }
117
+ if (!is_array($schema)) {
118
+ return $schema; // Boolean schema, or an invalid value jsonk reports.
422
119
  }
423
120
 
424
- if (isset($schema['items'])) {
425
- $items = $schema['items'];
426
-
427
- if ($this->isList($items) && array_is_list($items)) {
428
- // Tuple mode: items is a list of positional schemas.
429
- foreach ($data as $i => $item) {
430
- if (isset($items[$i])) {
431
- $this->validateAgainst($item, $items[$i], "{$path}[{$i}]");
432
- } elseif (isset($schema['additionalItems']) && $schema['additionalItems'] === false) {
433
- $this->addError("{$path}[{$i}]", 'additional item not allowed');
434
- }
435
- }
436
- } else {
437
- // Standard mode: a single schema applied to every item.
438
- foreach ($data as $i => $item) {
439
- $this->validateAgainst($item, $items, "{$path}[{$i}]");
440
- }
121
+ if (isset($schema['items']) && is_array($schema['items']) && $schema['items'] !== [] && array_is_list($schema['items'])) {
122
+ $schema['prefixItems'] = $schema['items'];
123
+ unset($schema['items']);
124
+ if (array_key_exists('additionalItems', $schema)) {
125
+ $schema['items'] = $schema['additionalItems'];
126
+ unset($schema['additionalItems']);
441
127
  }
442
128
  }
443
- }
444
129
 
445
- /**
446
- * @param array<string,mixed> $schema
447
- */
448
- private function validateObject(mixed $data, array $schema, string $path): void
449
- {
450
- // Accepts a JSON object regardless of how it was decoded:
451
- // associative array (json_decode(..., true)) or stdClass (json_decode(..., false)).
452
- if (!$this->isObjectLike($data)) {
453
- return;
130
+ if (($schema['format'] ?? null) === 'url') {
131
+ $schema['format'] = 'uri';
454
132
  }
455
133
 
456
- $keys = $this->objectKeys($data);
457
-
458
- // required
459
- if (isset($schema['required']) && is_array($schema['required'])) {
460
- foreach ($schema['required'] as $requiredKey) {
461
- if (!$this->objectHas($data, (string) $requiredKey)) {
462
- $this->addError($path, "missing required property: '{$requiredKey}'");
463
- }
134
+ foreach ($schema as $keyword => $value) {
135
+ if (in_array($keyword, self::SUBSCHEMA, true)) {
136
+ $schema[$keyword] = self::normalize($value);
137
+ } elseif (in_array($keyword, self::SUBSCHEMA_LIST, true) && is_array($value)) {
138
+ $schema[$keyword] = array_map(self::normalize(...), array_values($value));
139
+ } elseif (in_array($keyword, self::SUBSCHEMA_MAP, true) && (is_array($value) || is_object($value))) {
140
+ $schema[$keyword] = (object) array_map(self::normalize(...), is_object($value) ? get_object_vars($value) : $value);
141
+ } elseif ($keyword === 'dependentRequired' && is_array($value)) {
142
+ $schema[$keyword] = (object) $value;
464
143
  }
465
144
  }
466
145
 
467
- $properties = $schema['properties'] ?? [];
468
- $patternProperties = $schema['patternProperties'] ?? [];
469
- $additional = $schema['additionalProperties'] ?? true;
470
-
471
- foreach ($keys as $key) {
472
- $value = $this->objectGet($data, $key);
473
- $childPath = $path === '' ? $key : "{$path}.{$key}";
474
- $matched = false;
475
-
476
- if (isset($properties[$key])) {
477
- $matched = true;
478
- $this->validateAgainst($value, $properties[$key], $childPath);
479
- }
480
-
481
- foreach ($patternProperties as $pattern => $subSchema) {
482
- $regex = '/' . str_replace('/', '\/', (string) $pattern) . '/u';
483
- if (@preg_match($regex, $key) === 1) {
484
- $matched = true;
485
- $this->validateAgainst($value, $subSchema, $childPath);
486
- }
487
- }
488
-
489
- if (!$matched) {
490
- if ($additional === false) {
491
- $this->addError($childPath, 'additional property not allowed');
492
- } elseif (is_array($additional)) {
493
- $this->validateAgainst($value, $additional, $childPath);
494
- }
495
- }
496
- }
497
-
498
- $count = count($keys);
499
-
500
- if (isset($schema['minProperties']) && $count < (int) $schema['minProperties']) {
501
- $this->addError($path, "must have at least {$schema['minProperties']} propert(y/ies)");
502
- }
503
-
504
- if (isset($schema['maxProperties']) && $count > (int) $schema['maxProperties']) {
505
- $this->addError($path, "must have at most {$schema['maxProperties']} propert(y/ies)");
506
- }
146
+ // An object, even when empty or keyed 0..n.
147
+ return (object) $schema;
507
148
  }
508
149
 
509
150
  /**
510
- * Resolves a local reference such as "#/definitions/foo" or "#/$defs/foo".
511
- *
512
- * @return array<string,mixed>|null
151
+ * Converts a jsonk JSON Pointer ("/tags/1") to the previous validator's
152
+ * path format ("tags[1]"), using the data to tell array indexes from
153
+ * object keys.
513
154
  */
514
- private function resolveRef(string $ref): ?array
155
+ private static function displayPath(string $pointer, mixed $data): string
515
156
  {
516
- if (!str_starts_with($ref, '#/')) {
517
- return null; // External references are not supported.
518
- }
157
+ $path = '';
158
+ $node = $data;
519
159
 
520
- $segments = explode('/', substr($ref, 2));
521
- $current = $this->refRoot;
522
-
523
- foreach ($segments as $segment) {
524
- $segment = str_replace(['~1', '~0'], ['/', '~'], $segment);
525
- if (!is_array($current) || !array_key_exists($segment, $current)) {
526
- return null;
160
+ foreach (explode('/', ltrim($pointer, '/')) as $segment) {
161
+ if ($segment === '') {
162
+ continue;
527
163
  }
528
- $current = $current[$segment];
529
- }
530
-
531
- return is_array($current) ? $current : null;
532
- }
533
-
534
- private function looseEquals(mixed $a, mixed $b): bool
535
- {
536
- $aStructured = is_array($a) || is_object($a);
537
- $bStructured = is_array($b) || is_object($b);
164
+ $segment = str_replace(['~1', '~0'], ['/', '~'], $segment);
538
165
 
539
- if ($aStructured && $bStructured) {
540
- try {
541
- // Compares structural (JSON) equality, so an associative array
542
- // and a stdClass with the same content are considered equal.
543
- return json_encode($a, JSON_THROW_ON_ERROR) === json_encode($b, JSON_THROW_ON_ERROR);
544
- } catch (\JsonException) {
545
- return false;
166
+ if (is_array($node)) {
167
+ $path .= "[{$segment}]";
168
+ $node = $node[(int) $segment] ?? null;
169
+ } else {
170
+ $path .= $path === '' ? $segment : ".{$segment}";
171
+ $node = is_object($node) ? ($node->{$segment} ?? null) : null;
546
172
  }
547
173
  }
548
- return $a === $b;
174
+
175
+ return $path === '' ? '(root)' : $path;
549
176
  }
550
- }
177
+ }