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