@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.
@@ -1,22 +1,24 @@
1
1
  <?php
2
2
 
3
3
  /**
4
- * YAML — lightweight, fully static YAML parser.
4
+ * YAML — YAML parser backed by PHP's native `yaml` extension (PECL yaml /
5
+ * libyaml), statically built into @kirigami/php-wasm.
5
6
  *
6
7
  * Usage:
7
8
  * $data = YAML::parse($yamlString); // mappings → stdClass (default)
8
9
  * $data = YAML::parse($yamlString, true); // mappings → associative array
9
10
  * $data = YAML::parseFile('/path/to/config.yaml');
10
11
  *
11
- * Supports:
12
- * - Typed scalars (string, int, float, bool, null)
13
- * - Single and double quotes (with escape sequences)
14
- * - Multi-line blocks (| literal and > folded, with chomping -, +)
15
- * - Multi-line plain scalars (continuation with no indicator)
16
- * - Nested mappings and sequences
17
- * - Inline collections [a, b] and {k: v}
18
- * - Comments (#)
19
- * - Multiple documents separated by ---
12
+ * Follows YAML 1.1 (libyaml's resolver), including its implicit-boolean
13
+ * scalars: not just yes/no/true/false/on/off but also the bare single-letter
14
+ * y/Y/n/N — as a value *or* as a mapping key (the classic "Norway problem":
15
+ * an unquoted `no:` key or a `NO` value resolves to `false`). Quote such
16
+ * scalars (`"y": 2`) in project YAML to keep them as strings. See
17
+ * docs/BUGS.md for the differences found against the previous hand-written
18
+ * parser (now YAML_LEGACY::).
19
+ *
20
+ * `yaml.decode_php` must stay off (it is by default) so a `!php/object` tag
21
+ * in untrusted YAML can't trigger unserialize().
20
22
  */
21
23
  class YAML
22
24
  {
@@ -28,22 +30,25 @@ class YAML
28
30
 
29
31
  public static function parse(string $yaml, bool $assoc = false): mixed
30
32
  {
31
- $yaml = str_replace(["\r\n", "\r"], "\n", $yaml);
32
- $lines = explode("\n", $yaml);
33
- $pos = 0;
33
+ $ndocs = 0;
34
+ $error = null;
34
35
 
35
- $documents = [];
36
+ set_error_handler(function (int $errno, string $errstr) use (&$error) {
37
+ $error = $errstr;
38
+ return true;
39
+ });
40
+ $result = yaml_parse($yaml, -1, $ndocs);
41
+ restore_error_handler();
36
42
 
37
- while ($pos < count($lines)) {
38
- $line = $lines[$pos];
39
- if (preg_match('/^---/', $line)) { $pos++; continue; }
40
- if (preg_match('/^\.\.\./', $line)) { $pos++; break; }
43
+ if ($result === false) {
44
+ throw new \RuntimeException($error ?? 'Failed to parse YAML.');
45
+ }
41
46
 
42
- $doc = self::parseBlock($lines, $pos, 0, $assoc);
43
- $documents[] = $doc;
47
+ if ($ndocs <= 1) {
48
+ return self::convert($result[0] ?? null, $assoc);
44
49
  }
45
50
 
46
- return count($documents) === 1 ? $documents[0] : $documents;
51
+ return array_map(fn($doc) => self::convert($doc, $assoc), $result);
47
52
  }
48
53
 
49
54
  public static function parseFile(string $path, bool $assoc = false): mixed
@@ -186,399 +191,29 @@ class YAML
186
191
  }
187
192
 
188
193
  // -------------------------------------------------------------------------
189
- // Recursive parsing
190
- // -------------------------------------------------------------------------
191
-
192
- private static function parseBlock(array $lines, int &$pos, int $indent, bool $assoc = false): mixed
193
- {
194
- self::skipEmptyAndComments($lines, $pos);
195
-
196
- if ($pos >= count($lines)) return null;
197
-
198
- $line = $lines[$pos];
199
- $lineIndent = self::getIndent($line);
200
- $trimmed = ltrim($line);
201
-
202
- if (str_starts_with($trimmed, '- ') || $trimmed === '-') {
203
- return self::parseSequence($lines, $pos, $lineIndent, $assoc);
204
- }
205
-
206
- if (self::isMapping($trimmed)) {
207
- return self::parseMapping($lines, $pos, $lineIndent, $assoc);
208
- }
209
-
210
- return null;
211
- }
212
-
213
- private static function parseMapping(array $lines, int &$pos, int $indent, bool $assoc = false): array|object
214
- {
215
- $result = [];
216
-
217
- while ($pos < count($lines)) {
218
- self::skipEmptyAndComments($lines, $pos);
219
- if ($pos >= count($lines)) break;
220
-
221
- $line = $lines[$pos];
222
- $lineIndent = self::getIndent($line);
223
- $trimmed = ltrim($line);
224
-
225
- if ($lineIndent < $indent) break;
226
- if ($lineIndent > $indent) break;
227
- if (preg_match('/^(---|\.\.\.)\s*$/', $trimmed)) break;
228
- if (!self::isMapping($trimmed)) break;
229
-
230
- [$key, $rest] = self::splitKeyValue($trimmed);
231
- $pos++;
232
-
233
- if ($rest === null) {
234
- // Value on the following lines: sub-block or multi-line plain scalar
235
- self::skipEmptyAndComments($lines, $pos);
236
- if ($pos < count($lines)) {
237
- $nextIndent = self::getIndent($lines[$pos]);
238
- if ($nextIndent > $indent) {
239
- $nextTrimmed = ltrim($lines[$pos]);
240
- // Plain scalar if it's neither a mapping nor a sequence
241
- if (!self::isMapping($nextTrimmed) && !str_starts_with($nextTrimmed, '- ')) {
242
- $result[$key] = self::parseScalar(
243
- self::collectPlainScalar($lines, $pos, $indent, '')
244
- );
245
- } else {
246
- $result[$key] = self::parseBlock($lines, $pos, $nextIndent, $assoc);
247
- }
248
- } else {
249
- $result[$key] = null;
250
- }
251
- } else {
252
- $result[$key] = null;
253
- }
254
- } elseif ($rest === '|' || $rest === '|-' || $rest === '|+') {
255
- $result[$key] = self::parseLiteralBlock($lines, $pos, $indent, $rest);
256
- } elseif ($rest === '>' || $rest === '>-' || $rest === '>+') {
257
- $result[$key] = self::parseFoldedBlock($lines, $pos, $indent, $rest);
258
- } elseif ($rest !== '' && ($rest[0] === '[' || $rest[0] === '{')) {
259
- $result[$key] = self::parseInlineCollection($rest, $assoc);
260
- } else {
261
- // Inline scalar, may be followed by continuation lines
262
- $result[$key] = self::parseScalar(
263
- self::collectPlainScalar($lines, $pos, $indent, $rest)
264
- );
265
- }
266
- }
267
-
268
- return $assoc ? $result : (object) $result;
269
- }
270
-
271
- private static function parseSequence(array $lines, int &$pos, int $indent, bool $assoc = false): array
272
- {
273
- $result = [];
274
-
275
- while ($pos < count($lines)) {
276
- self::skipEmptyAndComments($lines, $pos);
277
- if ($pos >= count($lines)) break;
278
-
279
- $line = $lines[$pos];
280
- $lineIndent = self::getIndent($line);
281
- $trimmed = ltrim($line);
282
-
283
- if ($lineIndent < $indent) break;
284
- if ($lineIndent > $indent) break;
285
- if (preg_match('/^(---|\.\.\.)\s*$/', $trimmed)) break;
286
- if (!str_starts_with($trimmed, '- ') && $trimmed !== '-') break;
287
-
288
- $itemContent = $trimmed === '-' ? '' : substr($trimmed, 2);
289
- $pos++;
290
-
291
- if ($itemContent === '') {
292
- self::skipEmptyAndComments($lines, $pos);
293
- if ($pos < count($lines)) {
294
- $nextIndent = self::getIndent($lines[$pos]);
295
- $result[] = $nextIndent > $indent
296
- ? self::parseBlock($lines, $pos, $nextIndent, $assoc)
297
- : null;
298
- } else {
299
- $result[] = null;
300
- }
301
- } elseif (self::isMapping($itemContent)) {
302
- // Inline mapping inside the sequence:
303
- // Rebuild a virtual array of lines by prefixing the first key
304
- // with fakeIndent, then delegate entirely to parseMapping to get
305
- // all of its logic (|, >, plain scalars, sub-blocks…).
306
- $fakeIndent = $lineIndent + 2;
307
- $fakePrefix = str_repeat(' ', $fakeIndent);
308
- $virtualLines = array_merge(
309
- [$fakePrefix . $itemContent],
310
- array_slice($lines, $pos)
311
- );
312
- $vPos = 0;
313
- $itemMap = self::parseMapping($virtualLines, $vPos, $fakeIndent, $assoc);
314
- $pos += max(0, $vPos - 1);
315
- $result[] = $itemMap;
316
- } elseif ($itemContent[0] === '[' || $itemContent[0] === '{') {
317
- $result[] = self::parseInlineCollection($itemContent, $assoc);
318
- } else {
319
- $result[] = self::parseScalar($itemContent);
320
- }
321
- }
322
-
323
- return $result;
324
- }
325
-
326
- // -------------------------------------------------------------------------
327
- // Multi-line plain scalar
194
+ // yaml_parse() → stdClass/array shape
328
195
  // -------------------------------------------------------------------------
329
196
 
330
197
  /**
331
- * Collects a scalar that may continue on lines more indented than $parentIndent.
332
- * Continuation lines are joined with a space (implicit folding).
198
+ * yaml_parse() always returns plain PHP arrays (associative for mappings,
199
+ * indexed for sequences). $assoc=false wants mappings as stdClass instead
200
+ * — this walks the tree and converts them, leaving sequences as arrays
201
+ * (recursing into their items).
333
202
  */
334
- private static function collectPlainScalar(array $lines, int &$pos, int $parentIndent, string $first): string
335
- {
336
- $parts = $first !== '' ? [trim($first)] : [];
337
-
338
- while ($pos < count($lines)) {
339
- $raw = $lines[$pos];
340
- $trimmed = trim($raw);
341
-
342
- // Blank line: end of the scalar
343
- if ($trimmed === '') break;
344
-
345
- $lineIndent = self::getIndent($raw);
346
-
347
- // Back to the parent indentation or less: end
348
- if ($lineIndent <= $parentIndent) break;
349
-
350
- // Comment alone on the line: end
351
- if (str_starts_with($trimmed, '#')) break;
352
-
353
- // It's a mapping or a sequence: end
354
- if (self::isMapping($trimmed) || str_starts_with($trimmed, '- ')) break;
355
-
356
- $parts[] = $trimmed;
357
- $pos++;
358
- }
359
-
360
- return implode(' ', $parts);
361
- }
362
-
363
- // -------------------------------------------------------------------------
364
- // Multi-line blocks
365
- // -------------------------------------------------------------------------
366
-
367
- private static function parseLiteralBlock(array $lines, int &$pos, int $parentIndent, string $indicator): string
368
- {
369
- $blockLines = [];
370
- $blockIndent = null;
371
- $chomping = self::getChomping($indicator);
372
-
373
- while ($pos < count($lines)) {
374
- $raw = $lines[$pos];
375
-
376
- if (trim($raw) === '') {
377
- $blockLines[] = '';
378
- $pos++;
379
- continue;
380
- }
381
-
382
- $lineIndent = self::getIndent($raw);
383
- if ($blockIndent === null) {
384
- if ($lineIndent <= $parentIndent) break;
385
- $blockIndent = $lineIndent;
386
- }
387
- if ($lineIndent < $blockIndent) break;
388
-
389
- $blockLines[] = substr($raw, $blockIndent);
390
- $pos++;
391
- }
392
-
393
- return self::applyChomping(implode("\n", $blockLines), $chomping);
394
- }
395
-
396
- private static function parseFoldedBlock(array $lines, int &$pos, int $parentIndent, string $indicator): string
397
- {
398
- $blockLines = [];
399
- $blockIndent = null;
400
- $chomping = self::getChomping($indicator);
401
-
402
- while ($pos < count($lines)) {
403
- $raw = $lines[$pos];
404
-
405
- if (trim($raw) === '') {
406
- $blockLines[] = '';
407
- $pos++;
408
- continue;
409
- }
410
-
411
- $lineIndent = self::getIndent($raw);
412
- if ($blockIndent === null) {
413
- if ($lineIndent <= $parentIndent) break;
414
- $blockIndent = $lineIndent;
415
- }
416
- if ($lineIndent < $blockIndent) break;
417
-
418
- $blockLines[] = rtrim(substr($raw, $blockIndent));
419
- $pos++;
420
- }
421
-
422
- $folded = '';
423
- $count = count($blockLines);
424
- for ($i = 0; $i < $count; $i++) {
425
- if ($blockLines[$i] === '') {
426
- $folded .= "\n";
427
- } elseif ($i < $count - 1 && $blockLines[$i + 1] !== '') {
428
- $folded .= $blockLines[$i] . ' ';
429
- } else {
430
- $folded .= $blockLines[$i];
431
- }
432
- }
433
-
434
- return self::applyChomping(rtrim($folded, ' '), $chomping);
435
- }
436
-
437
- // -------------------------------------------------------------------------
438
- // Inline collections [a, b] {k: v}
439
- // -------------------------------------------------------------------------
440
-
441
- private static function parseInlineCollection(string $raw, bool $assoc = false): mixed
442
- {
443
- $raw = trim($raw);
444
- if ($raw[0] === '[') return self::parseInlineSequence($raw, $assoc);
445
- if ($raw[0] === '{') return self::parseInlineMapping($raw, $assoc);
446
- return self::parseScalar($raw);
447
- }
448
-
449
- private static function parseInlineSequence(string $raw, bool $assoc = false): array
450
- {
451
- $inner = trim(substr($raw, 1, strrpos($raw, ']') - 1));
452
- if ($inner === '') return [];
453
-
454
- return array_map(
455
- fn($item) => self::parseInlineCollection(trim($item), $assoc),
456
- self::splitInline($inner)
457
- );
458
- }
459
-
460
- private static function parseInlineMapping(string $raw, bool $assoc = false): array|object
461
- {
462
- $inner = trim(substr($raw, 1, strrpos($raw, '}') - 1));
463
- if ($inner === '') return $assoc ? [] : new \stdClass();
464
-
465
- $result = [];
466
- foreach (self::splitInline($inner) as $pair) {
467
- $colonPos = strpos($pair, ':');
468
- if ($colonPos === false) continue;
469
- $k = trim(substr($pair, 0, $colonPos));
470
- $v = trim(substr($pair, $colonPos + 1));
471
- $result[$k] = self::parseInlineCollection($v, $assoc);
472
- }
473
- return $assoc ? $result : (object) $result;
474
- }
475
-
476
- private static function splitInline(string $str): array
477
- {
478
- $parts = [];
479
- $depth = 0;
480
- $current = '';
481
- $inSingle = false;
482
- $inDouble = false;
483
-
484
- for ($i = 0, $len = strlen($str); $i < $len; $i++) {
485
- $c = $str[$i];
486
-
487
- if ($c === "'" && !$inDouble) { $inSingle = !$inSingle; $current .= $c; continue; }
488
- if ($c === '"' && !$inSingle) { $inDouble = !$inDouble; $current .= $c; continue; }
489
- if ($inSingle || $inDouble) { $current .= $c; continue; }
490
-
491
- if ($c === '[' || $c === '{') { $depth++; $current .= $c; continue; }
492
- if ($c === ']' || $c === '}') { $depth--; $current .= $c; continue; }
493
-
494
- if ($c === ',' && $depth === 0) { $parts[] = $current; $current = ''; continue; }
495
- $current .= $c;
496
- }
497
-
498
- if ($current !== '') $parts[] = $current;
499
- return $parts;
500
- }
501
-
502
- // -------------------------------------------------------------------------
503
- // Scalars
504
- // -------------------------------------------------------------------------
505
-
506
- private static function parseScalar(string $value): mixed
203
+ private static function convert(mixed $node, bool $assoc): mixed
507
204
  {
508
- $value = trim($value);
509
-
510
- if (!preg_match('/^[\'"]/', $value)) {
511
- $value = rtrim(preg_replace('/(^|\s)#.*$/', '', $value));
205
+ if ($assoc || !is_array($node)) {
206
+ return $node;
512
207
  }
513
208
 
514
- if ($value === '') return null;
515
-
516
- if (str_starts_with($value, '"') && str_ends_with($value, '"') && strlen($value) >= 2) {
517
- return stripcslashes(substr($value, 1, -1));
209
+ if (array_is_list($node)) {
210
+ return array_map(fn($v) => self::convert($v, $assoc), $node);
518
211
  }
519
212
 
520
- if (str_starts_with($value, "'") && str_ends_with($value, "'") && strlen($value) >= 2) {
521
- return str_replace("''", "'", substr($value, 1, -1));
213
+ $obj = new \stdClass();
214
+ foreach ($node as $key => $value) {
215
+ $obj->$key = self::convert($value, $assoc);
522
216
  }
523
-
524
- if (in_array(strtolower($value), ['~', 'null'], true)) return null;
525
- if (in_array(strtolower($value), ['true', 'yes', 'on'], true)) return true;
526
- if (in_array(strtolower($value), ['false', 'no', 'off'], true)) return false;
527
- if (preg_match('/^-?\d+$/', $value)) return (int) $value;
528
- if (preg_match('/^-?\d+\.\d*([eE][+-]?\d+)?$/', $value)) return (float) $value;
529
- if (in_array(strtolower($value), ['.inf', '+.inf'], true)) return INF;
530
- if (strtolower($value) === '-.inf') return -INF;
531
- if (strtolower($value) === '.nan') return NAN;
532
-
533
- return $value;
534
- }
535
-
536
- // -------------------------------------------------------------------------
537
- // Helpers
538
- // -------------------------------------------------------------------------
539
-
540
- private static function getIndent(string $line): int
541
- {
542
- return strlen($line) - strlen(ltrim($line));
543
- }
544
-
545
- private static function isMapping(string $trimmed): bool
546
- {
547
- return (bool) preg_match('/^(?:"[^"]*"|\'[^\']*\'|[^:\'"\[\{]+):\s?/', $trimmed);
548
- }
549
-
550
- private static function splitKeyValue(string $line): array
551
- {
552
- if (preg_match('/^("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\']|\'\')*\'|[^:]+?):\s*(.*)$/', $line, $m)) {
553
- $key = self::parseScalar($m[1]);
554
- $val = trim($m[2]) !== '' ? trim($m[2]) : null;
555
- return [$key, $val];
556
- }
557
- return [$line, null];
558
- }
559
-
560
- private static function skipEmptyAndComments(array $lines, int &$pos): void
561
- {
562
- while ($pos < count($lines)) {
563
- $t = trim($lines[$pos]);
564
- if ($t === '' || str_starts_with($t, '#')) $pos++;
565
- else break;
566
- }
567
- }
568
-
569
- private static function getChomping(string $indicator): string
570
- {
571
- if (str_ends_with($indicator, '-')) return 'strip';
572
- if (str_ends_with($indicator, '+')) return 'keep';
573
- return 'clip';
574
- }
575
-
576
- private static function applyChomping(string $text, string $chomping): string
577
- {
578
- return match ($chomping) {
579
- 'strip' => rtrim($text, "\n"),
580
- 'keep' => $text,
581
- default => rtrim($text, "\n") . "\n",
582
- };
217
+ return $obj;
583
218
  }
584
- }
219
+ }