@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,598 @@
1
+ <?php
2
+
3
+ /**
4
+ * YAML_LEGACY — lightweight, fully static, pure-PHP YAML parser.
5
+ *
6
+ * Retired in favor of YAML:: (native `yaml`/libyaml extension, ~7x faster —
7
+ * see docs/STATUS.md and docs/DECISIONS.md in the repo root). Kept here as a
8
+ * reference implementation and an easy rollback path; not autoloaded or used
9
+ * anywhere by default. Known to mis-parse compact nested sequences
10
+ * (`- - item`) — see docs/BUGS.md before reviving it.
11
+ *
12
+ * Usage:
13
+ * $data = YAML_LEGACY::parse($yamlString); // mappings → stdClass (default)
14
+ * $data = YAML_LEGACY::parse($yamlString, true); // mappings → associative array
15
+ * $data = YAML_LEGACY::parseFile('/path/to/config.yaml');
16
+ *
17
+ * Supports:
18
+ * - Typed scalars (string, int, float, bool, null)
19
+ * - Single and double quotes (with escape sequences)
20
+ * - Multi-line blocks (| literal and > folded, with chomping -, +)
21
+ * - Multi-line plain scalars (continuation with no indicator)
22
+ * - Nested mappings and sequences
23
+ * - Inline collections [a, b] and {k: v}
24
+ * - Comments (#)
25
+ * - Multiple documents separated by ---
26
+ */
27
+ class YAML_LEGACY
28
+ {
29
+ private function __construct() {}
30
+
31
+ // -------------------------------------------------------------------------
32
+ // Public entry points
33
+ // -------------------------------------------------------------------------
34
+
35
+ public static function parse(string $yaml, bool $assoc = false): mixed
36
+ {
37
+ $yaml = str_replace(["\r\n", "\r"], "\n", $yaml);
38
+ $lines = explode("\n", $yaml);
39
+ $pos = 0;
40
+
41
+ $documents = [];
42
+
43
+ while ($pos < count($lines)) {
44
+ $line = $lines[$pos];
45
+ if (preg_match('/^---/', $line)) { $pos++; continue; }
46
+ if (preg_match('/^\.\.\./', $line)) { $pos++; break; }
47
+
48
+ $doc = self::parseBlock($lines, $pos, 0, $assoc);
49
+ $documents[] = $doc;
50
+ }
51
+
52
+ return count($documents) === 1 ? $documents[0] : $documents;
53
+ }
54
+
55
+ public static function parseFile(string $path, bool $assoc = false): mixed
56
+ {
57
+ if (!is_readable($path)) {
58
+ throw new \RuntimeException("Cannot read file: $path");
59
+ }
60
+ return self::parse(file_get_contents($path), $assoc);
61
+ }
62
+
63
+ /**
64
+ * Loads a YAML or JSON file, then walks the result recursively and replaces
65
+ * any string value that matches a relative path to an existing YAML/JSON
66
+ * file with that file's deserialized content.
67
+ *
68
+ * Each included file is itself resolved relative to its own directory, and
69
+ * so on (recursive).
70
+ *
71
+ * If the string doesn't end in .yml/.yaml/.json, or the resolved file
72
+ * doesn't exist, the value is kept as-is.
73
+ *
74
+ * Circular references (e.g. A → B → A) throw a RuntimeException.
75
+ *
76
+ * Usage:
77
+ * $data = YAML_LEGACY::loadFile('/path/to/config.yaml');
78
+ * $data = YAML_LEGACY::loadFile('/path/to/config.yaml', true); // assoc arrays
79
+ *
80
+ * @param string $path Path to the root file (YAML or JSON).
81
+ * @param bool $assoc true → mappings as arrays, false → stdClass.
82
+ * @return mixed
83
+ */
84
+ public static function loadFile(string $path, bool $assoc = false): mixed
85
+ {
86
+ $absolute = realpath($path);
87
+ if ($absolute === false || !is_readable($absolute)) {
88
+ throw new \RuntimeException("Cannot read file: $path");
89
+ }
90
+
91
+ return self::loadFileRecursive($absolute, $assoc, []);
92
+ }
93
+
94
+ // -------------------------------------------------------------------------
95
+ // Private methods for loadFile
96
+ // -------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Loads and resolves a file, propagating the list of ancestors to detect
100
+ * cycles.
101
+ *
102
+ * @param string $absolute Canonical absolute path of the file to load.
103
+ * @param bool $assoc
104
+ * @param string[] $ancestors Absolute paths of the files currently being processed.
105
+ * @return mixed
106
+ */
107
+ private static function loadFileRecursive(string $absolute, bool $assoc, array $ancestors): mixed
108
+ {
109
+ if (in_array($absolute, $ancestors, true)) {
110
+ throw new \RuntimeException(
111
+ "Circular reference detected: " . implode(' → ', $ancestors) . " → $absolute"
112
+ );
113
+ }
114
+
115
+ $ext = strtolower(pathinfo($absolute, PATHINFO_EXTENSION));
116
+ $raw = file_get_contents($absolute);
117
+ $dir = dirname($absolute);
118
+
119
+ if ($ext === 'json') {
120
+ $data = json_decode($raw, $assoc, 512, JSON_THROW_ON_ERROR);
121
+ } else {
122
+ // .yml, .yaml or any other extension treated as YAML
123
+ $data = self::parse($raw, $assoc);
124
+ }
125
+
126
+ return self::resolveNode($data, $dir, $assoc, [...$ancestors, $absolute]);
127
+ }
128
+
129
+ /**
130
+ * Recursively walks a PHP value (stdClass object, array, string, scalar)
131
+ * and resolves references to external files.
132
+ *
133
+ * @param mixed $node
134
+ * @param string $dir Directory of the file that contains this node.
135
+ * @param bool $assoc
136
+ * @param string[] $ancestors
137
+ * @return mixed
138
+ */
139
+ private static function resolveNode(mixed $node, string $dir, bool $assoc, array $ancestors): mixed
140
+ {
141
+ if (is_string($node)) {
142
+ return self::resolveString($node, $dir, $assoc, $ancestors);
143
+ }
144
+
145
+ if (is_array($node)) {
146
+ foreach ($node as $key => $value) {
147
+ $node[$key] = self::resolveNode($value, $dir, $assoc, $ancestors);
148
+ }
149
+ return $node;
150
+ }
151
+
152
+ if ($node instanceof \stdClass) {
153
+ foreach ($node as $key => $value) {
154
+ $node->$key = self::resolveNode($value, $dir, $assoc, $ancestors);
155
+ }
156
+ return $node;
157
+ }
158
+
159
+ // int, float, bool, null → returned as-is
160
+ return $node;
161
+ }
162
+
163
+ /**
164
+ * If the string points to an existing YAML/JSON file (path relative to the
165
+ * $dir directory), loads that file recursively. Otherwise returns the
166
+ * original string.
167
+ *
168
+ * @param string $str
169
+ * @param string $dir
170
+ * @param bool $assoc
171
+ * @param string[] $ancestors
172
+ * @return mixed
173
+ */
174
+ private static function resolveString(string $str, string $dir, bool $assoc, array $ancestors): mixed
175
+ {
176
+ $trimmed = trim($str);
177
+
178
+ // Quick filter on the extension
179
+ if (!preg_match('/\.(ya?ml|json)$/i', $trimmed)) {
180
+ return $str;
181
+ }
182
+
183
+ // Resolve the path relative to the parent file's directory
184
+ $candidate = $dir . DIRECTORY_SEPARATOR . $trimmed;
185
+ $absolute = realpath($candidate);
186
+
187
+ if ($absolute === false || !is_readable($absolute)) {
188
+ return $str; // File not found → plain string
189
+ }
190
+
191
+ return self::loadFileRecursive($absolute, $assoc, $ancestors);
192
+ }
193
+
194
+ // -------------------------------------------------------------------------
195
+ // Recursive parsing
196
+ // -------------------------------------------------------------------------
197
+
198
+ private static function parseBlock(array $lines, int &$pos, int $indent, bool $assoc = false): mixed
199
+ {
200
+ self::skipEmptyAndComments($lines, $pos);
201
+
202
+ if ($pos >= count($lines)) return null;
203
+
204
+ $line = $lines[$pos];
205
+ $lineIndent = self::getIndent($line);
206
+ $trimmed = ltrim($line);
207
+
208
+ if (str_starts_with($trimmed, '- ') || $trimmed === '-') {
209
+ return self::parseSequence($lines, $pos, $lineIndent, $assoc);
210
+ }
211
+
212
+ if (self::isMapping($trimmed)) {
213
+ return self::parseMapping($lines, $pos, $lineIndent, $assoc);
214
+ }
215
+
216
+ return null;
217
+ }
218
+
219
+ private static function parseMapping(array $lines, int &$pos, int $indent, bool $assoc = false): array|object
220
+ {
221
+ $result = [];
222
+
223
+ while ($pos < count($lines)) {
224
+ self::skipEmptyAndComments($lines, $pos);
225
+ if ($pos >= count($lines)) break;
226
+
227
+ $line = $lines[$pos];
228
+ $lineIndent = self::getIndent($line);
229
+ $trimmed = ltrim($line);
230
+
231
+ if ($lineIndent < $indent) break;
232
+ if ($lineIndent > $indent) break;
233
+ if (preg_match('/^(---|\.\.\.)\s*$/', $trimmed)) break;
234
+ if (!self::isMapping($trimmed)) break;
235
+
236
+ [$key, $rest] = self::splitKeyValue($trimmed);
237
+ $pos++;
238
+
239
+ if ($rest === null) {
240
+ // Value on the following lines: sub-block or multi-line plain scalar
241
+ self::skipEmptyAndComments($lines, $pos);
242
+ if ($pos < count($lines)) {
243
+ $nextIndent = self::getIndent($lines[$pos]);
244
+ if ($nextIndent > $indent) {
245
+ $nextTrimmed = ltrim($lines[$pos]);
246
+ // Plain scalar if it's neither a mapping nor a sequence
247
+ if (!self::isMapping($nextTrimmed) && !str_starts_with($nextTrimmed, '- ')) {
248
+ $result[$key] = self::parseScalar(
249
+ self::collectPlainScalar($lines, $pos, $indent, '')
250
+ );
251
+ } else {
252
+ $result[$key] = self::parseBlock($lines, $pos, $nextIndent, $assoc);
253
+ }
254
+ } else {
255
+ $result[$key] = null;
256
+ }
257
+ } else {
258
+ $result[$key] = null;
259
+ }
260
+ } elseif ($rest === '|' || $rest === '|-' || $rest === '|+') {
261
+ $result[$key] = self::parseLiteralBlock($lines, $pos, $indent, $rest);
262
+ } elseif ($rest === '>' || $rest === '>-' || $rest === '>+') {
263
+ $result[$key] = self::parseFoldedBlock($lines, $pos, $indent, $rest);
264
+ } elseif ($rest !== '' && ($rest[0] === '[' || $rest[0] === '{')) {
265
+ $result[$key] = self::parseInlineCollection($rest, $assoc);
266
+ } else {
267
+ // Inline scalar, may be followed by continuation lines
268
+ $result[$key] = self::parseScalar(
269
+ self::collectPlainScalar($lines, $pos, $indent, $rest)
270
+ );
271
+ }
272
+ }
273
+
274
+ return $assoc ? $result : (object) $result;
275
+ }
276
+
277
+ private static function parseSequence(array $lines, int &$pos, int $indent, bool $assoc = false): array
278
+ {
279
+ $result = [];
280
+
281
+ while ($pos < count($lines)) {
282
+ self::skipEmptyAndComments($lines, $pos);
283
+ if ($pos >= count($lines)) break;
284
+
285
+ $line = $lines[$pos];
286
+ $lineIndent = self::getIndent($line);
287
+ $trimmed = ltrim($line);
288
+
289
+ if ($lineIndent < $indent) break;
290
+ if ($lineIndent > $indent) break;
291
+ if (preg_match('/^(---|\.\.\.)\s*$/', $trimmed)) break;
292
+ if (!str_starts_with($trimmed, '- ') && $trimmed !== '-') break;
293
+
294
+ $itemContent = $trimmed === '-' ? '' : substr($trimmed, 2);
295
+ $pos++;
296
+
297
+ if ($itemContent === '') {
298
+ self::skipEmptyAndComments($lines, $pos);
299
+ if ($pos < count($lines)) {
300
+ $nextIndent = self::getIndent($lines[$pos]);
301
+ $result[] = $nextIndent > $indent
302
+ ? self::parseBlock($lines, $pos, $nextIndent, $assoc)
303
+ : null;
304
+ } else {
305
+ $result[] = null;
306
+ }
307
+ } elseif (self::isMapping($itemContent)) {
308
+ // Inline mapping inside the sequence:
309
+ // Rebuild a virtual array of lines by prefixing the first key
310
+ // with fakeIndent, then delegate entirely to parseMapping to get
311
+ // all of its logic (|, >, plain scalars, sub-blocks…).
312
+ $fakeIndent = $lineIndent + 2;
313
+ $fakePrefix = str_repeat(' ', $fakeIndent);
314
+ $virtualLines = array_merge(
315
+ [$fakePrefix . $itemContent],
316
+ array_slice($lines, $pos)
317
+ );
318
+ $vPos = 0;
319
+ $itemMap = self::parseMapping($virtualLines, $vPos, $fakeIndent, $assoc);
320
+ $pos += max(0, $vPos - 1);
321
+ $result[] = $itemMap;
322
+ } elseif ($itemContent[0] === '[' || $itemContent[0] === '{') {
323
+ $result[] = self::parseInlineCollection($itemContent, $assoc);
324
+ } else {
325
+ $result[] = self::parseScalar($itemContent);
326
+ }
327
+ }
328
+
329
+ return $result;
330
+ }
331
+
332
+ // -------------------------------------------------------------------------
333
+ // Multi-line plain scalar
334
+ // -------------------------------------------------------------------------
335
+
336
+ /**
337
+ * Collects a scalar that may continue on lines more indented than $parentIndent.
338
+ * Continuation lines are joined with a space (implicit folding).
339
+ */
340
+ private static function collectPlainScalar(array $lines, int &$pos, int $parentIndent, string $first): string
341
+ {
342
+ $parts = $first !== '' ? [trim($first)] : [];
343
+
344
+ while ($pos < count($lines)) {
345
+ $raw = $lines[$pos];
346
+ $trimmed = trim($raw);
347
+
348
+ // Blank line: end of the scalar
349
+ if ($trimmed === '') break;
350
+
351
+ $lineIndent = self::getIndent($raw);
352
+
353
+ // Back to the parent indentation or less: end
354
+ if ($lineIndent <= $parentIndent) break;
355
+
356
+ // Comment alone on the line: end
357
+ if (str_starts_with($trimmed, '#')) break;
358
+
359
+ // It's a mapping or a sequence: end
360
+ if (self::isMapping($trimmed) || str_starts_with($trimmed, '- ')) break;
361
+
362
+ $parts[] = $trimmed;
363
+ $pos++;
364
+ }
365
+
366
+ return implode(' ', $parts);
367
+ }
368
+
369
+ // -------------------------------------------------------------------------
370
+ // Multi-line blocks
371
+ // -------------------------------------------------------------------------
372
+
373
+ private static function parseLiteralBlock(array $lines, int &$pos, int $parentIndent, string $indicator): string
374
+ {
375
+ $blockLines = [];
376
+ $blockIndent = null;
377
+ $chomping = self::getChomping($indicator);
378
+
379
+ while ($pos < count($lines)) {
380
+ $raw = $lines[$pos];
381
+
382
+ if (trim($raw) === '') {
383
+ $blockLines[] = '';
384
+ $pos++;
385
+ continue;
386
+ }
387
+
388
+ $lineIndent = self::getIndent($raw);
389
+ if ($blockIndent === null) {
390
+ if ($lineIndent <= $parentIndent) break;
391
+ $blockIndent = $lineIndent;
392
+ }
393
+ if ($lineIndent < $blockIndent) break;
394
+
395
+ $blockLines[] = substr($raw, $blockIndent);
396
+ $pos++;
397
+ }
398
+
399
+ return self::applyChomping(implode("\n", $blockLines), $chomping);
400
+ }
401
+
402
+ private static function parseFoldedBlock(array $lines, int &$pos, int $parentIndent, string $indicator): string
403
+ {
404
+ $blockLines = [];
405
+ $blockIndent = null;
406
+ $chomping = self::getChomping($indicator);
407
+
408
+ while ($pos < count($lines)) {
409
+ $raw = $lines[$pos];
410
+
411
+ if (trim($raw) === '') {
412
+ $blockLines[] = '';
413
+ $pos++;
414
+ continue;
415
+ }
416
+
417
+ $lineIndent = self::getIndent($raw);
418
+ if ($blockIndent === null) {
419
+ if ($lineIndent <= $parentIndent) break;
420
+ $blockIndent = $lineIndent;
421
+ }
422
+ if ($lineIndent < $blockIndent) break;
423
+
424
+ $blockLines[] = rtrim(substr($raw, $blockIndent));
425
+ $pos++;
426
+ }
427
+
428
+ $folded = '';
429
+ $count = count($blockLines);
430
+ for ($i = 0; $i < $count; $i++) {
431
+ if ($blockLines[$i] === '') {
432
+ $folded .= "\n";
433
+ } elseif ($i < $count - 1 && $blockLines[$i + 1] !== '') {
434
+ $folded .= $blockLines[$i] . ' ';
435
+ } else {
436
+ $folded .= $blockLines[$i];
437
+ }
438
+ }
439
+
440
+ return self::applyChomping(rtrim($folded, ' '), $chomping);
441
+ }
442
+
443
+ // -------------------------------------------------------------------------
444
+ // Inline collections [a, b] {k: v}
445
+ // -------------------------------------------------------------------------
446
+
447
+ private static function parseInlineCollection(string $raw, bool $assoc = false): mixed
448
+ {
449
+ $raw = trim($raw);
450
+ if ($raw[0] === '[') return self::parseInlineSequence($raw, $assoc);
451
+ if ($raw[0] === '{') return self::parseInlineMapping($raw, $assoc);
452
+ return self::parseScalar($raw);
453
+ }
454
+
455
+ private static function parseInlineSequence(string $raw, bool $assoc = false): array
456
+ {
457
+ $inner = trim(substr($raw, 1, strrpos($raw, ']') - 1));
458
+ if ($inner === '') return [];
459
+
460
+ return array_map(
461
+ fn($item) => self::parseInlineCollection(trim($item), $assoc),
462
+ self::splitInline($inner)
463
+ );
464
+ }
465
+
466
+ private static function parseInlineMapping(string $raw, bool $assoc = false): array|object
467
+ {
468
+ $inner = trim(substr($raw, 1, strrpos($raw, '}') - 1));
469
+ if ($inner === '') return $assoc ? [] : new \stdClass();
470
+
471
+ $result = [];
472
+ foreach (self::splitInline($inner) as $pair) {
473
+ $colonPos = strpos($pair, ':');
474
+ if ($colonPos === false) continue;
475
+ $k = trim(substr($pair, 0, $colonPos));
476
+ $v = trim(substr($pair, $colonPos + 1));
477
+ $result[$k] = self::parseInlineCollection($v, $assoc);
478
+ }
479
+ return $assoc ? $result : (object) $result;
480
+ }
481
+
482
+ private static function splitInline(string $str): array
483
+ {
484
+ $parts = [];
485
+ $depth = 0;
486
+ $current = '';
487
+ $inSingle = false;
488
+ $inDouble = false;
489
+
490
+ for ($i = 0, $len = strlen($str); $i < $len; $i++) {
491
+ $c = $str[$i];
492
+
493
+ if ($c === "'" && !$inDouble) { $inSingle = !$inSingle; $current .= $c; continue; }
494
+ if ($c === '"' && !$inSingle) { $inDouble = !$inDouble; $current .= $c; continue; }
495
+ if ($inSingle || $inDouble) { $current .= $c; continue; }
496
+
497
+ if ($c === '[' || $c === '{') { $depth++; $current .= $c; continue; }
498
+ if ($c === ']' || $c === '}') { $depth--; $current .= $c; continue; }
499
+
500
+ if ($c === ',' && $depth === 0) { $parts[] = $current; $current = ''; continue; }
501
+ $current .= $c;
502
+ }
503
+
504
+ if ($current !== '') $parts[] = $current;
505
+ return $parts;
506
+ }
507
+
508
+ // -------------------------------------------------------------------------
509
+ // Scalars
510
+ // -------------------------------------------------------------------------
511
+
512
+ private static function parseScalar(string $value): mixed
513
+ {
514
+ $value = trim($value);
515
+
516
+ if (!preg_match('/^[\'"]/', $value)) {
517
+ $value = rtrim(preg_replace('/(^|\s)#.*$/', '', $value));
518
+ }
519
+
520
+ if ($value === '') return null;
521
+
522
+ if (str_starts_with($value, '"') && str_ends_with($value, '"') && strlen($value) >= 2) {
523
+ return stripcslashes(substr($value, 1, -1));
524
+ }
525
+
526
+ if (str_starts_with($value, "'") && str_ends_with($value, "'") && strlen($value) >= 2) {
527
+ return str_replace("''", "'", substr($value, 1, -1));
528
+ }
529
+
530
+ if (in_array(strtolower($value), ['~', 'null'], true)) return null;
531
+ if (in_array(strtolower($value), ['true', 'yes', 'on'], true)) return true;
532
+ if (in_array(strtolower($value), ['false', 'no', 'off'], true)) return false;
533
+ if (preg_match('/^-?\d+$/', $value)) return (int) $value;
534
+ if (preg_match('/^-?\d+\.\d*([eE][+-]?\d+)?$/', $value)) return (float) $value;
535
+ if (in_array(strtolower($value), ['.inf', '+.inf'], true)) return INF;
536
+ if (strtolower($value) === '-.inf') return -INF;
537
+ if (strtolower($value) === '.nan') return NAN;
538
+
539
+ return $value;
540
+ }
541
+
542
+ // -------------------------------------------------------------------------
543
+ // Helpers
544
+ // -------------------------------------------------------------------------
545
+
546
+ private static function getIndent(string $line): int
547
+ {
548
+ return strlen($line) - strlen(ltrim($line));
549
+ }
550
+
551
+ private static function isMapping(string $trimmed): bool
552
+ {
553
+ return (bool) preg_match('/^(?:"[^"]*"|\'[^\']*\'|[^:\'"\[\{]+):\s?/', $trimmed);
554
+ }
555
+
556
+ private static function splitKeyValue(string $line): array
557
+ {
558
+ if (preg_match('/^("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\']|\'\')*\'|[^:]+?):\s*(.*)$/', $line, $m)) {
559
+ $key = self::parseScalar($m[1]);
560
+ $val = trim($m[2]) !== '' ? trim($m[2]) : null;
561
+ return [$key, $val];
562
+ }
563
+ return [$line, null];
564
+ }
565
+
566
+ private static function skipEmptyAndComments(array $lines, int &$pos): void
567
+ {
568
+ while ($pos < count($lines)) {
569
+ $t = trim($lines[$pos]);
570
+ if ($t === '' || str_starts_with($t, '#')) $pos++;
571
+ else break;
572
+ }
573
+ }
574
+
575
+ private static function getChomping(string $indicator): string
576
+ {
577
+ if (str_ends_with($indicator, '-')) return 'strip';
578
+ if (str_ends_with($indicator, '+')) return 'keep';
579
+ return 'clip';
580
+ }
581
+
582
+ private static function applyChomping(string $text, string $chomping): string
583
+ {
584
+ // Normalise chomping to match the behaviour observed from the
585
+ // native-backed YAML:: parser (libyaml). Historically the legacy
586
+ // parser preserved all trailing newlines for '+' (keep), while the
587
+ // native parser returns a single trailing newline. To avoid diffs
588
+ // between YAML_LEGACY:: and YAML:: users relying on the latter by
589
+ // default, normalise 'keep' to produce the same single trailing
590
+ // newline as the default (clip) behaviour.
591
+ return match ($chomping) {
592
+ 'strip' => rtrim($text, "\n"),
593
+ // Keep '+' -> normalise to a single trailing newline (clip-like)
594
+ 'keep' => rtrim($text, "\n") . "\n",
595
+ default => rtrim($text, "\n") . "\n",
596
+ };
597
+ }
598
+ }