From 9111d93bbc8fb1953cdbaea34507ec0fc19d5207 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 10 Nov 2026 12:22:09 +0000 Subject: [PATCH 1/9] Fast YAML --- src/Yaml/CannotFastParse.php | 14 ++ src/Yaml/FastParser.php | 420 ++++++++++++++++++++++++++++++++++ src/Yaml/FastParserFrame.php | 23 ++ src/Yaml/Yaml.php | 2 +- tests/Yaml/FastParserTest.php | 229 ++++++++++++++++++ 5 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 src/Yaml/CannotFastParse.php create mode 100644 src/Yaml/FastParser.php create mode 100644 src/Yaml/FastParserFrame.php create mode 100644 tests/Yaml/FastParserTest.php diff --git a/src/Yaml/CannotFastParse.php b/src/Yaml/CannotFastParse.php new file mode 100644 index 00000000000..713f576e9b6 --- /dev/null +++ b/src/Yaml/CannotFastParse.php @@ -0,0 +1,14 @@ + null, '~' => null, 'true' => true, 'false' => false]; + + public function __construct(private Parser $symfony = new Parser) + { + } + + public function parse(string $yaml, int $flags = 0): mixed + { + // Flags change how values are interpreted, which we leave to Symfony. + if ($flags === 0) { + try { + return $this->parseBlocks($yaml); + } catch (CannotFastParse) { + // + } + } + + return $this->symfony->parse($yaml, $flags); + } + + private function parseBlocks(string $yaml): array + { + if (str_contains($yaml, "\t") || str_contains($yaml, "\r") || str_starts_with($yaml, "\xEF\xBB\xBF") || ! preg_match('//u', $yaml)) { + throw new CannotFastParse; + } + + $lines = explode("\n", $yaml); + + // Front matter arrives with its opening "---" line, which Symfony ignores. + if ($lines[0] === '---') { + array_shift($lines); + } + + $count = count($lines); + + Inline::initialize(0); + + $root = null; + $stack = []; + + // When a line ends a key or sequence item with no value, its value is whatever + // nested block follows. This holds that line's indentation until we know. + $pending = null; + + $i = 0; + + while ($i < $count) { + $line = $lines[$i]; + $trimmed = ltrim($line, ' '); + + if ($trimmed === '') { + $i++; + + continue; + } + + $indent = strlen($line) - strlen($trimmed); + $c = $trimmed[0]; + + if (str_contains('#&*!?%@`{[|>', $c) + || ($c === '"' || $c === "'") && ! $this->startsWithQuotedKey($trimmed) + || str_starts_with($trimmed, '---') + || str_starts_with($trimmed, '...') + ) { + throw new CannotFastParse; + } + + $isSequenceItem = $c === '-' && (! isset($trimmed[1]) || $trimmed[1] === ' '); + + if ($pending !== null) { + [$pendingIndent, $pendingIsKey] = $pending; + $pending = null; + + // A key's value may also be a sequence at the key's own indentation. + $isUnindentedSequence = $pendingIsKey && $indent === $pendingIndent && $isSequenceItem; + + if ($indent > $pendingIndent || $isUnindentedSequence) { + $stack[] = new FastParserFrame($indent, $isSequenceItem, $isUnindentedSequence); + } else { + $this->setLastValue($stack, null); + } + } + + if ($stack === []) { + if ($indent !== 0) { + throw new CannotFastParse; + } + + $stack[] = new FastParserFrame(0, $isSequenceItem); + } + + // Close any collections this line has moved out of. + while (true) { + $top = end($stack); + + if ($indent > $top->indent) { + // Indented without a key or sequence item opening a block. + throw new CannotFastParse; + } + + if ($indent === $top->indent && ! ($top->unindentedSequence && ! $isSequenceItem)) { + break; + } + + $this->close($stack, $root); + + if ($stack === []) { + throw new CannotFastParse; + } + } + + $frame = end($stack); + + if ($isSequenceItem) { + if (! $frame->isSequence) { + throw new CannotFastParse; + } + + $rest = ltrim(substr($trimmed, 1), ' '); + + if ($rest === '') { + $frame->data[] = null; + $pending = [$indent, false]; + $i++; + + continue; + } + + if ($rest[0] === '-' && (! isset($rest[1]) || $rest[1] === ' ') + || str_starts_with($rest, '---') + || str_starts_with($rest, '...') + ) { + throw new CannotFastParse; + } + + if ($this->isCompactMapping($rest)) { + // "- key: value" starts a mapping indented to the key. Re-read the + // line as the first key of that mapping. + $frame->data[] = null; + $childIndent = $indent + strlen($trimmed) - strlen($rest); + $stack[] = new FastParserFrame($childIndent, false, compact: true); + $lines[$i] = str_repeat(' ', $childIndent).$rest; + + continue; + } + + $frame->data[] = $this->scalar($rest, $lines, $i); + $i++; + + continue; + } + + if ($frame->isSequence) { + throw new CannotFastParse; + } + + [$key, $value] = $this->splitKey($trimmed); + + if (array_key_exists($key, $frame->data)) { + throw new CannotFastParse; // Let Symfony report the duplicate key. + } + + $frame->lastKey = $key; + + if ($value === '') { + $frame->data[$key] = null; + $pending = [$indent, true]; + $i++; + + continue; + } + + if ($value[0] === '|') { + // Inside "- key: value" items, Symfony keeps or drops a block scalar's final + // newline depending on what follows the item. Statamic doesn't write that + // form, so we leave it to Symfony. + foreach ($stack as $open) { + if ($open->compact) { + throw new CannotFastParse; + } + } + + [$frame->data[$key], $i] = $this->blockScalar($value, $lines, $i, $indent); + + continue; + } + + $frame->data[$key] = $this->scalar($value, $lines, $i); + $i++; + } + + if ($pending !== null) { + $this->setLastValue($stack, null); + } + + while ($stack !== []) { + $this->close($stack, $root); + } + + if (! is_array($root)) { + throw new CannotFastParse; + } + + return $root; + } + + /** + * Close the innermost collection and set it as its parent's latest value. + */ + private function close(array &$stack, ?array &$root): void + { + $frame = array_pop($stack); + + if ($stack === []) { + $root = $frame->data; + + return; + } + + $this->setLastValue($stack, $frame->data); + } + + private function setLastValue(array $stack, mixed $value): void + { + $frame = end($stack); + + $frame->data[$frame->isSequence ? array_key_last($frame->data) : $frame->lastKey] = $value; + } + + private function startsWithQuotedKey(string $line): bool + { + return (bool) preg_match('/^(?:'.Inline::REGEX_QUOTED_STRING.') *:(?: |$)/u', $line); + } + + /** + * Whether a sequence item's value is a mapping, using the same check as Symfony. + */ + private function isCompactMapping(string $value): bool + { + return (bool) preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P.+?))?\s*$#u', $value); + } + + /** + * Split a "key: value" line, interpreting the key the same way Symfony does. + */ + private function splitKey(string $line): array + { + // Most keys are plain words, which Symfony would return as they are. + $pos = strpos($line, ':'); + + if ($pos > 0 + && (! isset($line[$pos + 1]) || $line[$pos + 1] === ' ') + && strspn($line, self::WORD_CHARS, 0, $pos) === $pos + && ! ctype_digit($line[0]) && $line[0] !== '-' + && ! array_key_exists(strtolower(substr($line, 0, $pos)), self::RESERVED) + ) { + return [substr($line, 0, $pos), trim(substr($line, $pos + 1), ' ')]; + } + + if (! preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\[\{!].*?) *\:(?: ++(?P.+))?$#u', rtrim($line, ' '), $matches)) { + throw new CannotFastParse; + } + + if (str_contains($matches['key'], ' #') && ! in_array($matches['key'][0], ['"', "'"], true)) { + throw new CannotFastParse; + } + + $key = Inline::parseScalar($matches['key']); + + // Symfony rejects other key types, and "<<" is a merge key. + if (! is_string($key) && ! is_int($key) || $key === '<<') { + throw new CannotFastParse; + } + + return [$key, $matches['value'] ?? '']; + } + + /** + * Interpret a value that fits on a single line. + */ + private function scalar(string $value, array $lines, int $i): mixed + { + $value = rtrim($value, ' '); + $c = $value[0]; + $quoted = $c === '"' || $c === "'"; + + if (str_contains('&*!|>#?', $c) + // Symfony checks for stray characters after a flow collection, which Inline doesn't. + || $c === '{' && ! str_ends_with($value, '}') + || $c === '[' && ! str_ends_with($value, ']') + // Symfony rejects some plain values containing a colon. + || ! $quoted && (str_contains($value, ': ') || str_ends_with($value, ':')) + // A plain value that continues onto more indented lines. + || ! $quoted && $c !== '{' && $c !== '[' && $this->nextLineIsMoreIndented($lines, $i) + ) { + throw new CannotFastParse; + } + + // Shortcuts for the most common values, where Symfony's result is certain. + $length = strlen($value); + + if ($quoted && $length > 1 && $value[$length - 1] === $c && strpos($value, $c, 1) === $length - 1 && ($c === "'" || ! str_contains($value, '\\'))) { + return substr($value, 1, -1); + } + + if (array_key_exists($lower = strtolower($value), self::RESERVED)) { + return self::RESERVED[$lower]; + } + + if (ctype_alpha($c) && strspn($value, self::PLAIN_CHARS) === $length) { + return $value; + } + + if ($length < 19 && ($value === '0' || preg_match('/^-?[1-9][0-9]*$/', $value))) { + return (int) $value; + } + + try { + $parsed = Inline::parse($value); + } catch (ParseException) { + throw new CannotFastParse; // e.g. a quoted string spanning multiple lines. + } + + if ($parsed instanceof TaggedValue || is_object($parsed)) { + throw new CannotFastParse; + } + + return $parsed; + } + + private function nextLineIsMoreIndented(array $lines, int $i): bool + { + $indent = strlen($lines[$i]) - strlen(ltrim($lines[$i], ' ')); + + for ($j = $i + 1, $count = count($lines); $j < $count; $j++) { + $trimmed = ltrim($lines[$j], ' '); + + if ($trimmed !== '') { + return strlen($lines[$j]) - strlen($trimmed) > $indent; + } + } + + return false; + } + + /** + * Parse a literal block scalar by handing it to Symfony as a small document of its + * own, so that chomping and indentation follow Symfony's rules exactly. + * + * @return array{mixed, int} The value, and the index of the line after the block. + */ + private function blockScalar(string $header, array $lines, int $i, int $indent): array + { + $header = rtrim($header, ' '); + + if ($header !== '|' && $header !== '|-' && $header !== '|+') { + throw new CannotFastParse; // Indentation indicators, comments, etc. + } + + $block = ['k: '.$header]; + $count = count($lines); + + for ($j = $i + 1; $j < $count; $j++) { + $line = $lines[$j]; + $trimmed = ltrim($line, ' '); + + if ($trimmed !== '' && strlen($line) - strlen($trimmed) <= $indent) { + break; + } + + // Keep any spaces beyond the key's indentation, even on otherwise blank lines. + $block[] = strlen($line) > $indent ? substr($line, $indent) : ''; + } + + try { + // The block's last line only ends with a newline if more lines followed it. + $value = $this->symfony->parse(implode("\n", $block).($j < $count ? "\n" : ''))['k']; + } catch (Throwable) { + throw new CannotFastParse; + } + + return [$value, $j]; + } +} diff --git a/src/Yaml/FastParserFrame.php b/src/Yaml/FastParserFrame.php new file mode 100644 index 00000000000..268ba726e83 --- /dev/null +++ b/src/Yaml/FastParserFrame.php @@ -0,0 +1,23 @@ +yaml->parse($str); + $yaml = (new FastParser)->parse($str); } catch (\Exception $e) { throw $this->viewException($e, $str); } diff --git a/tests/Yaml/FastParserTest.php b/tests/Yaml/FastParserTest.php new file mode 100644 index 00000000000..e143ad8ba4a --- /dev/null +++ b/tests/Yaml/FastParserTest.php @@ -0,0 +1,229 @@ +assertParsesTheSameAsSymfony($yaml); + } + + public static function documentProvider() + { + return collect([ + 'empty' => '', + 'only whitespace' => " \n\n", + 'flat mapping' => "title: Hello\ncount: 3\npublished: true\n", + 'no trailing newline' => 'title: Hello', + 'front matter marker' => "---\ntitle: Hello\n", + 'nested mappings' => "seo:\n title: Hello\n meta:\n robots: noindex\nafter: 1\n", + 'sequence of scalars' => "tags:\n - one\n - two\n", + 'unindented sequence' => "tags:\n- one\n- two\nafter: 1\n", + 'sequence of mappings' => "items:\n -\n type: text\n text: Hi\n -\n type: image\n", + 'compact sequence items' => "items:\n - type: text\n text: Hi\n - type: image\n", + 'nested sequences' => "a:\n -\n -\n - deep\n", + 'empty values' => "a:\nb:\n c:\nd: 1\n", + 'empty sequence item' => "a:\n -\n - x\n", + 'blank lines between keys' => "a: 1\n\n\nb:\n\n c: 2\n", + 'trailing spaces' => "a: 1 \nb: \n c: 2 \n", + 'bard' => "content:\n -\n type: paragraph\n attrs:\n textAlign: left\n content:\n -\n type: text\n marks:\n -\n type: link\n attrs:\n href: 'https://example.com'\n rel: null\n text: 'a link'\n", + + 'quoted strings' => "a: 'single'\nb: \"double\"\nc: 'it''s'\nd: \"esc\\\"aped\"\ne: \"tab\\there\"\nf: ''\ng: \"\"\n", + 'special values' => "a: null\nb: Null\nc: ~\nd: TRUE\ne: false\nf: yes\ng: no\nh: on\n", + 'numbers' => "a: 0\nb: -1\nc: 012\nd: 0x1F\ne: 1.5\nf: 1e3\ng: .inf\nh: .NaN\ni: 9223372036854775807\nj: 99999999999999999999\nk: 1_000\n", + 'dates and times' => "a: 2024-01-01\nb: 2024-01-01 10:00:00\nc: 10:30\nd: 1:30\n", + 'urls and paths' => "a: https://example.com/a?b=c\nb: images/foo.jpg\nc: foo:bar\n", + 'unicode' => "a: café\nb: 日本語\nc: 😀\n", + 'comments after values' => "a: foo #comment\nb: foo#bar\n", + 'quoted keys' => "'quoted key': 1\n\"another: key\": 2\n", + 'numeric keys' => "1: one\n2: two\n1.5: float\n", + 'reserved keys' => "true: 1\n", + 'flow collections' => "a: { }\nb: []\nc: [a, b]\nd: { a: 1, b: [2] }\n", + + 'literal block' => "a: |\n line one\n line two\nb: 1\n", + 'literal block, strip' => "a: |-\n line one\n line two\nb: 1\n", + 'literal block, keep' => "a: |+\n line one\n\nb: 1\n", + 'literal block, last in document' => "a: |\n text\n", + 'literal block, no final newline' => "a: |\n text", + 'literal block, nested' => "a:\n b: |\n text\n c: 1\n", + 'literal block, blank lines with spaces' => "a: |-\n one \n \n \n two\n \nb: 1\n", + 'literal block, more indented lines' => "a: |\n one\n indented\n two\n", + 'literal block, in compact item' => "- name: x\n b: |\n text\n- name: y\n", + 'literal block, in compact item, blank line' => "- name: x\n with:\n b: |\n text\n\n- name: y\n", + 'folded block' => "a: >\n one\n two\n", + + 'comment lines' => "# comment\na: 1\n # indented comment\nb: 2\n", + 'anchors and aliases' => "a: &anchor\n b: 1\nc: *anchor\n", + 'merge keys' => "a: &anchor\n b: 1\nc:\n <<: *anchor\n d: 2\n", + 'tags' => "a: !!str 1\nb: !custom foo\n", + 'multi-line plain scalar' => "a: one\n two\nb: 1\n", + 'multi-line quoted scalar' => "a: 'one\n two'\nb: 1\n", + 'multi-line flow collection' => "a: [one,\n two]\nb: 1\n", + 'document end marker' => "a: 1\n...\n", + 'windows line endings' => "a: 1\r\nb: 2\r\n", + + 'top level sequence' => "- a\n- b\n", + 'top level scalar' => 'just a string', + 'invalid: tab indentation' => "a:\n\tb: 1\n", + 'invalid: duplicate keys' => "a: 1\na: 2\n", + 'invalid: colon in plain value' => "a: foo: bar\n", + 'invalid: stray characters after flow collection' => "a: [a, b]0\n", + 'invalid: complex mapping' => "a:\n - ? complex\n", + 'invalid: unexpected indentation' => "a: 1\n b: 2\n", + 'invalid: mapping in sequence' => "a:\n - x\n b: 1\n", + 'invalid: utf-8' => "a: \xff\n", + ])->mapWithKeys(fn ($yaml, $name) => [$name => [$yaml]])->all(); + } + + #[Test] + public function it_uses_the_fast_path_for_content_that_statamic_writes() + { + $data = [ + 'id' => '0a1b2c3d-4e5f-6789-abcd-ef0123456789', + 'title' => "It's a title: with a colon", + 'published' => true, + 'count' => 3, + 'nothing' => null, + 'empty' => [], + 'tags' => ['one', 'two'], + 'text' => "Line one\nLine two\n\n indented", + 'content' => [ + ['type' => 'paragraph', 'content' => [['type' => 'text', 'marks' => [['type' => 'bold']], 'text' => 'Hello']]], + ['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => ['type' => 'image', 'image' => ['a.jpg']]]], + ], + ]; + + $yaml = Yaml::dump($data, 100, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + + $this->assertSame($data, $this->parseWithoutFallingBack($yaml)); + $this->assertSame($data, $this->parseWithoutFallingBack("---\n".$yaml)); + } + + #[Test] + public function it_parses_randomly_generated_values_the_same_as_symfony() + { + mt_srand(1); + + for ($i = 0; $i < 1000; $i++) { + $value = $this->randomScalar(); + + $this->assertParsesTheSameAsSymfony("k: $value\n"); + $this->assertParsesTheSameAsSymfony("k:\n - $value\n - x\n"); + $this->assertParsesTheSameAsSymfony("$value: x\n"); + } + } + + #[Test] + public function it_parses_randomly_generated_documents_the_same_as_symfony() + { + mt_srand(1); + + for ($i = 0; $i < 150; $i++) { + $data = ['root' => $this->randomValue(5), 'other' => $this->randomValue(3)]; + + foreach ([[100, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK], [100, 4, 0], [2, 2, 0]] as [$inline, $indent, $flags]) { + $yaml = Yaml::dump($data, $inline, $indent, $flags); + + $this->assertParsesTheSameAsSymfony($yaml); + $this->assertParsesTheSameAsSymfony(str_replace("\n", "\n\n", $yaml)); + $this->assertParsesTheSameAsSymfony(rtrim($yaml, "\n")); + } + } + } + + private function assertParsesTheSameAsSymfony(string $yaml): void + { + $expected = $this->attempt(fn () => (new Parser)->parse($yaml)); + $actual = $this->attempt(fn () => (new FastParser)->parse($yaml)); + + $this->assertSame($expected, $actual, 'Failed parsing: '.json_encode($yaml)); + } + + private function attempt(callable $parse): array + { + try { + // Serialized, so that types must match exactly and NAN equals NAN. + return ['result' => serialize($parse())]; + } catch (ParseException $e) { + return ['error' => $e->getMessage(), 'line' => $e->getParsedLine()]; + } catch (\Throwable $e) { + return ['error' => get_class($e).': '.$e->getMessage()]; + } + } + + private function parseWithoutFallingBack(string $yaml): array + { + try { + return (new ReflectionMethod(FastParser::class, 'parseBlocks'))->invoke(new FastParser, $yaml); + } catch (CannotFastParse) { + $this->fail('The fast parser fell back to Symfony for: '.json_encode($yaml)); + } + } + + // Block scalar headers ("|", ">") are left out, as Symfony emits warnings for some + // random combinations. They're covered by the document provider instead. + private const ATOMS = [ + '0', '00', '007', '0o17', '0x1F', '-0', '+1', '1', '-1', '9223372036854775807', '99999999999999999999', '1.5', '1e3', '.5', '.inf', '-.inf', '.NaN', + 'true', 'True', 'FALSE', 'null', 'Null', '~', 'yes', 'no', 'on', 'y', '2024-01-01', '2024-01-01 10:00:00', '2024-01-01T10:00:00Z', '10:30', '1:30', '1_000', + 'foo', 'foo-bar', 'foo_bar', 'foo.bar', 'foo/bar', 'e', 'e1', 'images/foo.jpg', 'https://example.com/a?b=c', 'foo:bar', 'foo: bar', 'foo #bar', 'foo#bar', '#foo', + "'single'", "'it''s'", "''", '""', '"double"', '"esc\\"aped"', '"uni\\u00e9"', "'a\"b'", "'unterminated", '"unterminated', + '{ }', '[]', '[a, b]', '{ a: 1 }', '&anchor foo', '*alias', '!tag foo', '!!str 1', '? complex', '@at', '`tick', '%pct', + 'café', '日本語', 'with space', '-', '- x', '---', '...', ':', 'a:', ',', '<<', '\\', 'v1.2.3', '-foo', '.foo', '_foo', + ]; + + private function randomScalar(): string + { + $atom = self::ATOMS[mt_rand(0, count(self::ATOMS) - 1)]; + + // Sometimes join two together to make stranger combinations. + return mt_rand(0, 4) === 0 ? $atom.self::ATOMS[mt_rand(0, count(self::ATOMS) - 1)] : $atom; + } + + private function randomValue(int $depth): mixed + { + $r = mt_rand(0, 9); + + if ($depth > 0 && $r < 3) { + return array_map(fn () => $this->randomValue($depth - 1), range(1, mt_rand(1, 4))); + } + + if ($depth > 0 && $r < 6) { + $map = []; + + for ($i = mt_rand(1, 4); $i > 0; $i--) { + $map[preg_replace('/\s/', '_', $this->randomScalar())] = $this->randomValue($depth - 1); + } + + return $map; + } + + return match (mt_rand(0, 8)) { + 0 => mt_rand(-1000, 1000), + 1 => (bool) mt_rand(0, 1), + 2 => null, + 3 => mt_rand() / 7, + 4 => "line one\nline two".(mt_rand(0, 1) ? "\n" : ''), + 5 => "a \n\n \n\nb", + 6 => " indented first\nsecond\n\n", + 7 => '', + default => $this->randomScalar(), + }; + } +} From 2cd4207d25b8cd1c6891f45a8d65520d727944b8 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 10 Nov 2026 12:27:33 +0000 Subject: [PATCH 2/9] fallback to symfony on deep nesting --- src/Yaml/FastParser.php | 10 ++++++++++ tests/Yaml/FastParserTest.php | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Yaml/FastParser.php b/src/Yaml/FastParser.php index 7845e943973..38047781e2d 100644 --- a/src/Yaml/FastParser.php +++ b/src/Yaml/FastParser.php @@ -36,6 +36,12 @@ class FastParser */ private const RESERVED = ['null' => null, '~' => null, 'true' => true, 'false' => false]; + /** + * Deeper documents are left to Symfony, which applies its own nesting limit. This is + * well below Symfony's default limit, and far deeper than real content goes. + */ + private const MAX_DEPTH = 64; + public function __construct(private Parser $symfony = new Parser) { } @@ -145,6 +151,10 @@ private function parseBlocks(string $yaml): array } } + if (count($stack) > self::MAX_DEPTH) { + throw new CannotFastParse; + } + $frame = end($stack); if ($isSequenceItem) { diff --git a/tests/Yaml/FastParserTest.php b/tests/Yaml/FastParserTest.php index e143ad8ba4a..0a52943dcd4 100644 --- a/tests/Yaml/FastParserTest.php +++ b/tests/Yaml/FastParserTest.php @@ -79,6 +79,8 @@ public static function documentProvider() 'document end marker' => "a: 1\n...\n", 'windows line endings' => "a: 1\r\nb: 2\r\n", + 'deep nesting' => self::nested(70), + 'nesting beyond symfony\'s limit' => self::nested(130), 'top level sequence' => "- a\n- b\n", 'top level scalar' => 'just a string', 'invalid: tab indentation' => "a:\n\tb: 1\n", @@ -92,6 +94,17 @@ public static function documentProvider() ])->mapWithKeys(fn ($yaml, $name) => [$name => [$yaml]])->all(); } + private static function nested(int $depth): string + { + $yaml = "root:\n"; + + for ($i = 1; $i <= $depth; $i++) { + $yaml .= str_repeat(' ', $i)."level$i:\n"; + } + + return $yaml; + } + #[Test] public function it_uses_the_fast_path_for_content_that_statamic_writes() { From 6b99c0c02ea41d374568a9abbb7c8c04a8a3b8de Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 10 Nov 2026 14:07:50 +0000 Subject: [PATCH 3/9] Some more improvements /cc @DanielDarrenJones --- src/Yaml/FastParser.php | 694 +++++++++++++++++++++++----------- src/Yaml/FastParserFrame.php | 23 -- tests/Yaml/FastParserTest.php | 23 ++ 3 files changed, 493 insertions(+), 247 deletions(-) delete mode 100644 src/Yaml/FastParserFrame.php diff --git a/src/Yaml/FastParser.php b/src/Yaml/FastParser.php index 38047781e2d..9ce0f45a9b2 100644 --- a/src/Yaml/FastParser.php +++ b/src/Yaml/FastParser.php @@ -2,10 +2,8 @@ namespace Statamic\Yaml; -use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Inline; use Symfony\Component\Yaml\Parser; -use Symfony\Component\Yaml\Tag\TaggedValue; use Throwable; /** @@ -15,9 +13,9 @@ * the block's lines and parses them again with a new parser, so each line is scanned once * per level of nesting. Content like Bard is deeply nested, which makes that expensive. * - * This parser walks the lines once, keeping a stack of the open mappings and sequences. - * Simple keys and values are read directly, and everything else is handed to Symfony's - * own inline parser, so values are interpreted exactly as Symfony would interpret them. + * This parser walks the lines once, descending into each nested block as it meets it. + * Plain keys and the most common values are read directly. Any other key or value is + * handed to Symfony's inline parser, so it is read exactly as Symfony would read it. * * Anything outside the subset it understands (comments, anchors, tags, multi-line flow * collections or plain scalars, invalid YAML, etc.) makes it give up, and the whole @@ -27,390 +25,628 @@ */ class FastParser { - private const WORD_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; + private const WORD = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; - private const PLAIN_CHARS = self::WORD_CHARS.'./'; + private const HEX = '0123456789abcdef-'; /** - * Plain scalars that Symfony gives a special meaning to, by their lowercase form. + * First characters of a plain value that Symfony always reads as a string, + * apart from null, true and false. */ - private const RESERVED = ['null' => null, '~' => null, 'true' => true, 'false' => false]; + private const PLAIN = [ + 'a' => 1, 'b' => 1, 'c' => 1, 'd' => 1, 'e' => 1, 'f' => 1, 'g' => 1, 'h' => 1, 'i' => 1, 'j' => 1, 'k' => 1, 'l' => 1, 'm' => 1, + 'n' => 1, 'o' => 1, 'p' => 1, 'q' => 1, 'r' => 1, 's' => 1, 't' => 1, 'u' => 1, 'v' => 1, 'w' => 1, 'x' => 1, 'y' => 1, 'z' => 1, + 'A' => 1, 'B' => 1, 'C' => 1, 'D' => 1, 'E' => 1, 'F' => 1, 'G' => 1, 'H' => 1, 'I' => 1, 'J' => 1, 'K' => 1, 'L' => 1, 'M' => 1, + 'N' => 1, 'O' => 1, 'P' => 1, 'Q' => 1, 'R' => 1, 'S' => 1, 'T' => 1, 'U' => 1, 'V' => 1, 'W' => 1, 'X' => 1, 'Y' => 1, 'Z' => 1, + '_' => 1, '/' => 1, '(' => 1, '$' => 1, '=' => 1, ';' => 1, '^' => 1, '<' => 1, + ]; + + private const RESERVED = ['null' => null, 'true' => true, 'false' => false]; /** - * Deeper documents are left to Symfony, which applies its own nesting limit. This is - * well below Symfony's default limit, and far deeper than real content goes. + * Deeper indentation is left to Symfony, which applies its own nesting limit. */ - private const MAX_DEPTH = 64; + private const MAX_INDENT = 100; - public function __construct(private Parser $symfony = new Parser) - { - } + /** + * Parsed keys are remembered for the life of the process. This caps how many, so + * long-running processes (Octane, queue workers) don't grow without limit. + */ + private const MAX_CACHED_KEYS = 2000; + + /** Raw key text => parsed key, for keys already proven safe. */ + private static array $keys = []; + + private static ?CannotFastParse $cannot = null; + + private static ?Parser $symfony = null; + + private array $lines = []; + + private int $count = 0; + + /** The current line. */ + private int $p = 0; + + /** Lines Symfony strips from the top (the "---" marker and blank lines). */ + private int $lead = 0; + + /** How many "- key: value" mappings are open. */ + private int $compact = 0; + + /** Whether the document has tabs, which are only understood inside literal blocks. */ + private bool $tabs = false; public function parse(string $yaml, int $flags = 0): mixed { // Flags change how values are interpreted, which we leave to Symfony. if ($flags === 0) { try { - return $this->parseBlocks($yaml); + return (new static)->parseBlocks($yaml); } catch (CannotFastParse) { // } } - return $this->symfony->parse($yaml, $flags); + return self::symfony()->parse($yaml, $flags); + } + + private static function symfony(): Parser + { + // Symfony's parser resets its state on every parse, so one can be reused. + return self::$symfony ??= new Parser; } private function parseBlocks(string $yaml): array { - if (str_contains($yaml, "\t") || str_contains($yaml, "\r") || str_starts_with($yaml, "\xEF\xBB\xBF") || ! preg_match('//u', $yaml)) { - throw new CannotFastParse; + if ($yaml === '' || strpbrk($yaml, "\r\0\x0B") !== false || str_starts_with($yaml, "\xEF\xBB\xBF") || ! preg_match('//u', $yaml)) { + $this->fail(); } - $lines = explode("\n", $yaml); + $this->lead = 0; + $this->compact = 0; // Front matter arrives with its opening "---" line, which Symfony ignores. - if ($lines[0] === '---') { - array_shift($lines); + if (str_starts_with($yaml, "---\n")) { + $yaml = substr($yaml, 4); + $this->lead = 1; } - $count = count($lines); + $this->tabs = str_contains($yaml, "\t"); + $this->lines = $lines = explode("\n", $yaml); + $this->count = $count = count($lines); Inline::initialize(0); - $root = null; - $stack = []; + $p = 0; - // When a line ends a key or sequence item with no value, its value is whatever - // nested block follows. This holds that line's indentation until we know. - $pending = null; + while ($p < $count && $lines[$p] === '') { + $p++; + } - $i = 0; + $this->p = $p; + $this->lead += $p; - while ($i < $count) { - $line = $lines[$i]; - $trimmed = ltrim($line, ' '); + if ($p >= $count || $lines[$p][0] === ' ') { + $this->fail(); + } - if ($trimmed === '') { - $i++; + $result = $this->isSequenceItem($lines[$p], 0) + ? $this->parseSequence(0) + : $this->parseMapping(0, null); - continue; - } + // Anything left over (such as a less indented line) is for Symfony to explain. + if ($this->p < $count) { + $this->fail(); + } - $indent = strlen($line) - strlen($trimmed); - $c = $trimmed[0]; + return $result; + } - if (str_contains('#&*!?%@`{[|>', $c) - || ($c === '"' || $c === "'") && ! $this->startsWithQuotedKey($trimmed) - || str_starts_with($trimmed, '---') - || str_starts_with($trimmed, '...') - ) { - throw new CannotFastParse; + private function fail(): never + { + // Reused, as building an exception's stack trace is the slowest part of giving up. + throw self::$cannot ??= new CannotFastParse; + } + + private function isSequenceItem(string $line, int $indent): bool + { + return $line[$indent] === '-' && (! isset($line[$indent + 1]) || $line[$indent + 1] === ' '); + } + + /** + * @param string|null $line The first line, when it isn't the current line as written ("- key: value"). + */ + private function parseMapping(int $indent, ?string $line): array + { + $data = []; + $keys = 0; + $lines = $this->lines; + $count = $this->count; + $p = $this->p; + $tabs = $this->tabs; + + while (true) { + if ($line === null) { + if ($p >= $count) { + break; + } + + $line = $lines[$p]; } - $isSequenceItem = $c === '-' && (! isset($trimmed[1]) || $trimmed[1] === ' '); + $i = strspn($line, ' '); - if ($pending !== null) { - [$pendingIndent, $pendingIsKey] = $pending; - $pending = null; + if ($i !== $indent || ! isset($line[$i])) { + if (! isset($line[$i])) { + if ($i) { + $this->fail(); // Whitespace-only lines can change how Symfony reads what follows. + } - // A key's value may also be a sequence at the key's own indentation. - $isUnindentedSequence = $pendingIsKey && $indent === $pendingIndent && $isSequenceItem; + $p++; + $line = null; - if ($indent > $pendingIndent || $isUnindentedSequence) { - $stack[] = new FastParserFrame($indent, $isSequenceItem, $isUnindentedSequence); - } else { - $this->setLastValue($stack, null); + continue; } - } - if ($stack === []) { - if ($indent !== 0) { - throw new CannotFastParse; + if ($i < $indent) { + break; } - $stack[] = new FastParserFrame(0, $isSequenceItem); + $this->fail(); } - // Close any collections this line has moved out of. - while (true) { - $top = end($stack); + if ($tabs && str_contains($line, "\t")) { + $this->fail(); + } - if ($indent > $top->indent) { - // Indented without a key or sequence item opening a block. - throw new CannotFastParse; - } + $pos = strpos($line, ':', $i); - if ($indent === $top->indent && ! ($top->unindentedSequence && ! $isSequenceItem)) { - break; - } + if ($pos !== false && (! isset($line[$pos + 1]) || $line[$pos + 1] === ' ') + && ($key = self::$keys[$raw = substr($line, $i, $pos - $i)] ?? $this->key($raw)) !== null + ) { + $value = isset($line[$pos + 2]) ? trim(substr($line, $pos + 2), ' ') : ''; + } else { + [$key, $value] = $this->splitKey(substr($line, $i)); + } - $this->close($stack, $root); + $p++; + $line = null; - if ($stack === []) { - throw new CannotFastParse; - } + if ($value === '') { + // The value is the nested block that follows, if there is one. + $this->p = $p; + $data[$key] = $this->child($indent, true); + $p = $this->p; + } elseif (isset(self::PLAIN[$c = $value[0]])) { + $data[$key] = strpbrk($value, ':#') !== false + ? $this->inline($value, $c) + : $this->plain($value); + } elseif ($c === "'" && $value[-1] === "'" && isset($value[1]) && strpos($value, "'", 1) === strlen($value) - 1) { + $data[$key] = substr($value, 1, -1); + } elseif ($c === '|') { + $this->p = $p; + $data[$key] = $this->literal($value, $indent); + $p = $this->p; + } else { + $data[$key] = $this->scalar($value, $c); } - if (count($stack) > self::MAX_DEPTH) { - throw new CannotFastParse; + if (count($data) !== ++$keys) { + $this->fail(); // A duplicate key: let Symfony report it. } + } - $frame = end($stack); + $this->p = $p; - if ($isSequenceItem) { - if (! $frame->isSequence) { - throw new CannotFastParse; - } + return $data; + } - $rest = ltrim(substr($trimmed, 1), ' '); + private function parseSequence(int $indent): array + { + $data = []; + $lines = $this->lines; + $count = $this->count; + $p = $this->p; + $tabs = $this->tabs; + + while ($p < $count) { + $line = $lines[$p]; + $i = strspn($line, ' '); + + if ($i !== $indent || ! isset($line[$i])) { + if (! isset($line[$i])) { + if ($i) { + $this->fail(); + } - if ($rest === '') { - $frame->data[] = null; - $pending = [$indent, false]; - $i++; + $p++; continue; } - if ($rest[0] === '-' && (! isset($rest[1]) || $rest[1] === ' ') - || str_starts_with($rest, '---') - || str_starts_with($rest, '...') - ) { - throw new CannotFastParse; + if ($i < $indent) { + break; } - if ($this->isCompactMapping($rest)) { - // "- key: value" starts a mapping indented to the key. Re-read the - // line as the first key of that mapping. - $frame->data[] = null; - $childIndent = $indent + strlen($trimmed) - strlen($rest); - $stack[] = new FastParserFrame($childIndent, false, compact: true); - $lines[$i] = str_repeat(' ', $childIndent).$rest; + $this->fail(); + } - continue; - } + if ($line[$i] !== '-') { + break; + } - $frame->data[] = $this->scalar($rest, $lines, $i); - $i++; + if (! isset($line[$i + 1])) { + // A dash on its own: the item is the block that follows. + $this->p = $p + 1; + $data[] = $this->child($indent, false); + $p = $this->p; continue; } - if ($frame->isSequence) { - throw new CannotFastParse; + if ($line[$i + 1] !== ' ' || $tabs && str_contains($line, "\t")) { + $this->fail(); } - [$key, $value] = $this->splitKey($trimmed); + $rest = trim(substr($line, $i + 2), ' '); + + if ($rest === '') { + $this->p = $p + 1; + $data[] = $this->child($indent, false); + $p = $this->p; - if (array_key_exists($key, $frame->data)) { - throw new CannotFastParse; // Let Symfony report the duplicate key. + continue; } - $frame->lastKey = $key; + $c = $rest[0]; - if ($value === '') { - $frame->data[$key] = null; - $pending = [$indent, true]; - $i++; + if ($c === '-' && (! isset($rest[1]) || $rest[1] === ' ' || str_starts_with($rest, '---')) || $c === '.' && str_starts_with($rest, '...')) { + $this->fail(); // "- - x", or document markers. + } + + if ((str_contains($rest, ': ') || $rest[-1] === ':') + && preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P.+?))?\s*$#u', $rest) + ) { + // "- key: value" starts a mapping indented to the key, with this line as its first. + $offset = strlen(rtrim($line, ' ')) - strlen($rest); + $this->p = $p; + $this->compact++; + $data[] = $this->parseMapping($offset, str_repeat(' ', $offset).$rest); + $this->compact--; + $p = $this->p; continue; } - if ($value[0] === '|') { - // Inside "- key: value" items, Symfony keeps or drops a block scalar's final - // newline depending on what follows the item. Statamic doesn't write that - // form, so we leave it to Symfony. - foreach ($stack as $open) { - if ($open->compact) { - throw new CannotFastParse; - } - } + $p++; + $data[] = $this->scalar($rest, $c); + } - [$frame->data[$key], $i] = $this->blockScalar($value, $lines, $i, $indent); + $this->p = $p; - continue; + return $data; + } + + /** + * The nested block that follows a key or sequence item with no value of its own. + */ + private function child(int $indent, bool $allowUnindentedSequence): ?array + { + $p = $this->p; + + if ($p >= $this->count) { + return null; + } + + $line = $this->lines[$p]; + + if ($line === '') { + // Symfony reads an empty value followed by a blank line differently depending on + // what comes after it (an unindented list is an error), unless only blank lines follow. + while ($p < $this->count && $this->lines[$p] === '') { + $p++; + } + + if ($p < $this->count) { + $this->fail(); } - $frame->data[$key] = $this->scalar($value, $lines, $i); - $i++; + $this->p = $p; + + return null; } - if ($pending !== null) { - $this->setLastValue($stack, null); + $i = strspn($line, ' '); + + if (! isset($line[$i])) { + $this->fail(); } - while ($stack !== []) { - $this->close($stack, $root); + if ($i > $indent) { + if ($i > self::MAX_INDENT) { + $this->fail(); + } + + return $this->isSequenceItem($line, $i) + ? $this->parseSequence($i) + : $this->parseMapping($i, null); } - if (! is_array($root)) { - throw new CannotFastParse; + // A key's value may also be a sequence at the key's own indentation. + if ($allowUnindentedSequence && $i === $indent && $this->isSequenceItem($line, $i)) { + return $this->parseSequence($indent); } - return $root; + return null; } /** - * Close the innermost collection and set it as its parent's latest value. + * A plain key not seen before, or null when the line needs the full key pattern. */ - private function close(array &$stack, ?array &$root): void + private function key(string $raw): string|int|null { - $frame = array_pop($stack); + if ($raw === '') { + return null; + } + + $c = $raw[0]; + + // Plain words are returned as they are, unless they could be a number, + // a sequence item or a reserved word. + if (strspn($raw, self::WORD) === strlen($raw)) { + if ($c !== '-' && ($c < '0' || $c > '9') && ! array_key_exists(strtolower($raw), self::RESERVED)) { + return $this->rememberKey($raw, $raw); + } - if ($stack === []) { - $root = $frame->data; + if ($c === '-' && ($raw === '-' || str_starts_with($raw, '---'))) { + return null; + } + } - return; + if (str_contains(' \'"[{!#&*?%@`|>~.', $c) || str_contains($raw, ' ') || str_starts_with($raw, '---')) { + return null; } - $this->setLastValue($stack, $frame->data); - } + try { + $key = Inline::parseScalar($raw); + } catch (Throwable) { + $this->fail(); + } - private function setLastValue(array $stack, mixed $value): void - { - $frame = end($stack); + // Symfony rejects other key types, and "<<" is a merge key. + if (! is_string($key) && ! is_int($key) || $raw === '<<') { + $this->fail(); + } - $frame->data[$frame->isSequence ? array_key_last($frame->data) : $frame->lastKey] = $value; + return $this->rememberKey($raw, $key); } - private function startsWithQuotedKey(string $line): bool + private function rememberKey(string $raw, string|int $key): string|int { - return (bool) preg_match('/^(?:'.Inline::REGEX_QUOTED_STRING.') *:(?: |$)/u', $line); - } + if (count(self::$keys) >= self::MAX_CACHED_KEYS) { + self::$keys = []; + } - /** - * Whether a sequence item's value is a mapping, using the same check as Symfony. - */ - private function isCompactMapping(string $value): bool - { - return (bool) preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P.+?))?\s*$#u', $value); + return self::$keys[$raw] = $key; } /** - * Split a "key: value" line, interpreting the key the same way Symfony does. + * Split a "key: value" line the way Symfony does, for keys the fast path doesn't cover. */ private function splitKey(string $line): array { - // Most keys are plain words, which Symfony would return as they are. - $pos = strpos($line, ':'); - - if ($pos > 0 - && (! isset($line[$pos + 1]) || $line[$pos + 1] === ' ') - && strspn($line, self::WORD_CHARS, 0, $pos) === $pos - && ! ctype_digit($line[0]) && $line[0] !== '-' - && ! array_key_exists(strtolower(substr($line, 0, $pos)), self::RESERVED) + $c = $line[0]; + + if (str_contains('#&*!?%@`{[|>', $c) + || $c === '-' && (! isset($line[1]) || $line[1] === ' ') + || str_starts_with($line, '---') + || str_starts_with($line, '...') ) { - return [substr($line, 0, $pos), trim(substr($line, $pos + 1), ' ')]; + $this->fail(); + } + + if (($c === '"' || $c === "'") && ! preg_match('/^(?:'.Inline::REGEX_QUOTED_STRING.') *:(?: |$)/u', $line)) { + $this->fail(); } if (! preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\[\{!].*?) *\:(?: ++(?P.+))?$#u', rtrim($line, ' '), $matches)) { - throw new CannotFastParse; + $this->fail(); } - if (str_contains($matches['key'], ' #') && ! in_array($matches['key'][0], ['"', "'"], true)) { - throw new CannotFastParse; + if (str_contains($matches['key'], ' #') && $c !== '"' && $c !== "'") { + $this->fail(); } - $key = Inline::parseScalar($matches['key']); + try { + $key = Inline::parseScalar($matches['key']); + } catch (Throwable) { + $this->fail(); + } - // Symfony rejects other key types, and "<<" is a merge key. if (! is_string($key) && ! is_int($key) || $key === '<<') { - throw new CannotFastParse; + $this->fail(); } return [$key, $matches['value'] ?? '']; } /** - * Interpret a value that fits on a single line. + * A plain value with no colon or hash, which Symfony reads as a string unless it's reserved. */ - private function scalar(string $value, array $lines, int $i): mixed + private function plain(string $value): mixed { - $value = rtrim($value, ' '); - $c = $value[0]; - $quoted = $c === '"' || $c === "'"; - - if (str_contains('&*!|>#?', $c) - // Symfony checks for stray characters after a flow collection, which Inline doesn't. - || $c === '{' && ! str_ends_with($value, '}') - || $c === '[' && ! str_ends_with($value, ']') - // Symfony rejects some plain values containing a colon. - || ! $quoted && (str_contains($value, ': ') || str_ends_with($value, ':')) - // A plain value that continues onto more indented lines. - || ! $quoted && $c !== '{' && $c !== '[' && $this->nextLineIsMoreIndented($lines, $i) - ) { - throw new CannotFastParse; - } + return isset($value[5]) || ! array_key_exists($lower = strtolower($value), self::RESERVED) + ? $value + : self::RESERVED[$lower]; + } - // Shortcuts for the most common values, where Symfony's result is certain. + private function scalar(string $value, string $c): mixed + { $length = strlen($value); - if ($quoted && $length > 1 && $value[$length - 1] === $c && strpos($value, $c, 1) === $length - 1 && ($c === "'" || ! str_contains($value, '\\'))) { - return substr($value, 1, -1); - } + if ($c === "'") { + if ($length > 1 && $value[$length - 1] === "'") { + $inner = substr($value, 1, -1); - if (array_key_exists($lower = strtolower($value), self::RESERVED)) { - return self::RESERVED[$lower]; - } + if (! str_contains($inner, "'")) { + return $inner; + } + + if (! str_contains(str_replace("''", '', $inner), "'")) { + return str_replace("''", "'", $inner); + } + } + } elseif ($c >= '0' && $c <= '9') { + if ($length < 19 && ctype_digit($value) && ($c !== '0' || $length === 1)) { + return (int) $value; + } - if (ctype_alpha($c) && strspn($value, self::PLAIN_CHARS) === $length) { - return $value; + if ($length === 36 && $value[8] === '-' && strspn($value, self::HEX) === 36) { + return $value; // A UUID. + } + } elseif ($c === '"') { + if ($length > 1 && $value[$length - 1] === '"' && strpos($value, '"', 1) === $length - 1 && ! str_contains($value, '\\')) { + return substr($value, 1, -1); + } + } elseif (isset(self::PLAIN[$c]) || $c >= "\x80") { + if (strpbrk($value, ':#') === false) { + return $this->plain($value); + } + } elseif ($value === '[]' || $value === '{}') { + return []; } - if ($length < 19 && ($value === '0' || preg_match('/^-?[1-9][0-9]*$/', $value))) { - return (int) $value; + return $this->inline($value, $c); + } + + /** + * Anything the shortcuts don't cover is read by Symfony's inline parser. + */ + private function inline(string $value, string $c): mixed + { + if (str_contains('&*!|>#?', $c) + // Symfony reads flow collections with a stricter lexer than Inline, so only the simplest are taken. + || $c === '{' && ! preg_match('/^\{ *\}$/', $value) + || $c === '[' && ! preg_match('/^\[ *(?:[A-Za-z0-9_.\/-]+(?: *, *[A-Za-z0-9_.\/-]+)*)? *\]$/', $value) + // Symfony rejects some plain values containing a colon. + || $c !== '"' && $c !== "'" && (str_contains($value, ': ') || str_ends_with($value, ':')) + ) { + $this->fail(); } try { $parsed = Inline::parse($value); - } catch (ParseException) { - throw new CannotFastParse; // e.g. a quoted string spanning multiple lines. + } catch (Throwable) { + $this->fail(); // e.g. a quoted string spanning multiple lines. } - if ($parsed instanceof TaggedValue || is_object($parsed)) { - throw new CannotFastParse; + if (is_object($parsed)) { + $this->fail(); } return $parsed; } - private function nextLineIsMoreIndented(array $lines, int $i): bool + /** + * A literal block scalar. "|" and "|-" blocks are read directly; anything awkward + * is handed to Symfony as a small document of its own. + */ + private function literal(string $header, int $indent): string { - $indent = strlen($lines[$i]) - strlen(ltrim($lines[$i], ' ')); + if ($this->compact) { + // Inside "- key: value" items, Symfony keeps or drops a block's final newline + // depending on what follows the item. + $this->fail(); + } + + if ($header !== '|' && $header !== '|-') { + return $this->literalBySymfony($header, $indent); + } + + $lines = $this->lines; + $count = $this->count; + $block = []; + $blockIndent = null; + $j = $this->p; + + while ($j < $count) { + $line = $lines[$j]; + $i = strspn($line, ' '); + + if (! isset($line[$i])) { + if ($line !== '') { + return $this->literalBySymfony($header, $indent); // Whitespace-only lines. + } - for ($j = $i + 1, $count = count($lines); $j < $count; $j++) { - $trimmed = ltrim($lines[$j], ' '); + $block[] = ''; + $j++; - if ($trimmed !== '') { - return strlen($lines[$j]) - strlen($trimmed) > $indent; + continue; } + + if ($i <= $indent) { + break; + } + + if ($blockIndent === null) { + $blockIndent = $i; + } elseif ($i < $blockIndent) { + return $this->literalBySymfony($header, $indent); + } + + $block[] = substr($line, $blockIndent); + $j++; } - return false; + if ($blockIndent === null) { + $this->fail(); // An empty block holds different things depending on what follows. + } + + if ($block[0] === '') { + return $this->literalBySymfony($header, $indent); + } + + while (end($block) === '') { + array_pop($block); + } + + if ($header === '|-') { + $this->p = $j; + + return implode("\n", $block); + } + + if ($j >= $count) { + return $this->literalBySymfony($header, $indent); + } + + if ($count - $j <= $this->lead) { + // Symfony miscounts the end of the document by the lines it stripped from the + // top, and drops this block's final newline. Leave the document to it. + $this->fail(); + } + + $this->p = $j; + + return implode("\n", $block)."\n"; } - /** - * Parse a literal block scalar by handing it to Symfony as a small document of its - * own, so that chomping and indentation follow Symfony's rules exactly. - * - * @return array{mixed, int} The value, and the index of the line after the block. - */ - private function blockScalar(string $header, array $lines, int $i, int $indent): array + private function literalBySymfony(string $header, int $indent): string { - $header = rtrim($header, ' '); - if ($header !== '|' && $header !== '|-' && $header !== '|+') { - throw new CannotFastParse; // Indentation indicators, comments, etc. + $this->fail(); // Indentation indicators, comments, etc. } + $lines = $this->lines; + $count = $this->count; $block = ['k: '.$header]; - $count = count($lines); - for ($j = $i + 1; $j < $count; $j++) { + for ($j = $this->p; $j < $count; $j++) { $line = $lines[$j]; - $trimmed = ltrim($line, ' '); + $i = strspn($line, ' '); - if ($trimmed !== '' && strlen($line) - strlen($trimmed) <= $indent) { + if (isset($line[$i]) && $i <= $indent) { break; } @@ -418,13 +654,23 @@ private function blockScalar(string $header, array $lines, int $i, int $indent): $block[] = strlen($line) > $indent ? substr($line, $indent) : ''; } + if ($j < $count && $count - $j <= $this->lead && $header !== '|-') { + $this->fail(); // See literal(). + } + try { // The block's last line only ends with a newline if more lines followed it. - $value = $this->symfony->parse(implode("\n", $block).($j < $count ? "\n" : ''))['k']; + $value = self::symfony()->parse(implode("\n", $block).($j < $count ? "\n" : ''))['k']; } catch (Throwable) { - throw new CannotFastParse; + $this->fail(); } - return [$value, $j]; + if (! is_string($value) || trim($value, "\n") === '') { + $this->fail(); + } + + $this->p = $j; + + return $value; } } diff --git a/src/Yaml/FastParserFrame.php b/src/Yaml/FastParserFrame.php deleted file mode 100644 index 268ba726e83..00000000000 --- a/src/Yaml/FastParserFrame.php +++ /dev/null @@ -1,23 +0,0 @@ - "a: |\n one\n indented\n two\n", 'literal block, in compact item' => "- name: x\n b: |\n text\n- name: y\n", 'literal block, in compact item, blank line' => "- name: x\n with:\n b: |\n text\n\n- name: y\n", + 'literal block, empty' => "a: |\nb: 1\n", + 'literal block, empty at end' => "a: |\n", + 'literal block, only blank lines' => "a: |-\n\n\nb: 1\n", 'folded block' => "a: >\n one\n two\n", 'comment lines' => "# comment\na: 1\n # indented comment\nb: 2\n", @@ -81,6 +84,26 @@ public static function documentProvider() 'deep nesting' => self::nested(70), 'nesting beyond symfony\'s limit' => self::nested(130), + 'unindented list after a blank line' => "a:\n\n- x\n- y\nb: 1\n", + 'unindented list after a whitespace-only line' => "a:\n \n- x\nb: 1\n", + 'nested unindented list after a blank line' => "a:\n b:\n\n - x\n c: 1\n", + 'empty value followed only by blank lines' => "a:\n\n\n", + 'whitespace-only lines' => "a: 1\n \nb:\n c: 1\n \n d: 2\n", + 'malformed flow sequence' => "a: [1{2]\nb:\n - [1{2]\n", + 'malformed flow collections' => "a: [a]]\nb: { : }\nc: [a, b,]\nd: [,]\n", + 'simple flow collections' => "a: [a, b]\nb: [ a ,b ]\nc: { }\nd: {}\ne: []\n", + 'front matter, nested literal block, one more line' => "---\na:\n b: |\n text\nc: 1", + 'front matter, deeper literal block, one more line' => "---\na:\n b:\n c: |\n text\nd: 1", + 'front matter, nested literal block, two more lines' => "---\na:\n b: |\n text\nc: 1\nd: 2\n", + 'text after a closing quote' => "a: 'x' y\nb: \"x\" y\n", + 'value ending in an ellipsis' => "a: Read more...\nb: 'Quoted...'\n", + 'document end marker' => "a: 1\n...", + 'document end marker in a sequence' => "a:\n - x\n...\n", + 'tab inside a literal block' => "a: |\n one\ttwo\nb: 1\n", + 'tab inside a value' => "a: 'one\ttwo'\nb: one\ttwo\n", + 'escaped single quotes' => "a: 'it''s ''quoted'''\n", + 'uuids' => "a: 0a1b2c3d-4e5f-6789-abcd-ef0123456789\nb: 0A1B2C3D-4E5F-6789-ABCD-EF0123456789\n", + 'plain values with spaces and symbols' => "a: hello world\nb: éclair au chocolat\nc: (brackets) and = signs; ^ < /\nd: \$money\n", 'top level sequence' => "- a\n- b\n", 'top level scalar' => 'just a string', 'invalid: tab indentation' => "a:\n\tb: 1\n", From ccef295771a2a7f0e581a41f1321abef853e8313 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 10 Nov 2026 14:33:17 +0000 Subject: [PATCH 4/9] Use new self instead of new static in FastParser Co-Authored-By: Claude Opus 5.5 --- src/Yaml/FastParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Yaml/FastParser.php b/src/Yaml/FastParser.php index 9ce0f45a9b2..e55e2f7900a 100644 --- a/src/Yaml/FastParser.php +++ b/src/Yaml/FastParser.php @@ -82,7 +82,7 @@ public function parse(string $yaml, int $flags = 0): mixed // Flags change how values are interpreted, which we leave to Symfony. if ($flags === 0) { try { - return (new static)->parseBlocks($yaml); + return (new self)->parseBlocks($yaml); } catch (CannotFastParse) { // } From 23d302c14c6905a26fb966bb5bd77b4956b4e133 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 10 Nov 2026 14:51:47 +0000 Subject: [PATCH 5/9] missed some bits from Daniel's work --- src/Yaml/FastParser.php | 39 ++++++++++++++++++++++++++--------- tests/Yaml/FastParserTest.php | 21 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/Yaml/FastParser.php b/src/Yaml/FastParser.php index e55e2f7900a..65459185ba4 100644 --- a/src/Yaml/FastParser.php +++ b/src/Yaml/FastParser.php @@ -44,9 +44,9 @@ class FastParser private const RESERVED = ['null' => null, 'true' => true, 'false' => false]; /** - * Deeper indentation is left to Symfony, which applies its own nesting limit. + * Deeper nesting is left to Symfony, which applies its own limit (128 levels by default). */ - private const MAX_INDENT = 100; + private const MAX_DEPTH = 64; /** * Parsed keys are remembered for the life of the process. This caps how many, so @@ -57,8 +57,6 @@ class FastParser /** Raw key text => parsed key, for keys already proven safe. */ private static array $keys = []; - private static ?CannotFastParse $cannot = null; - private static ?Parser $symfony = null; private array $lines = []; @@ -74,6 +72,9 @@ class FastParser /** How many "- key: value" mappings are open. */ private int $compact = 0; + /** How many mappings and sequences are open. */ + private int $depth = 0; + /** Whether the document has tabs, which are only understood inside literal blocks. */ private bool $tabs = false; @@ -103,8 +104,15 @@ private function parseBlocks(string $yaml): array $this->fail(); } + // In a document starting with "---", which all front matter does, Symfony strips a + // trailing "..." as an end of document marker, even from a value like "Read more...". + if (str_ends_with(rtrim($yaml, " \t\n"), '...')) { + $this->fail(); + } + $this->lead = 0; $this->compact = 0; + $this->depth = 0; // Front matter arrives with its opening "---" line, which Symfony ignores. if (str_starts_with($yaml, "---\n")) { @@ -145,8 +153,17 @@ private function parseBlocks(string $yaml): array private function fail(): never { - // Reused, as building an exception's stack trace is the slowest part of giving up. - throw self::$cannot ??= new CannotFastParse; + // A new exception each time, so no document is kept alive by a stored stack trace. + throw new CannotFastParse; + } + + private function enter(): void + { + // Indentation doesn't measure depth: a key's value can be a sequence at the key's + // own indentation, and "- key: value" adds a mapping inside a sequence item. + if (++$this->depth > self::MAX_DEPTH) { + $this->fail(); + } } private function isSequenceItem(string $line, int $indent): bool @@ -159,6 +176,8 @@ private function isSequenceItem(string $line, int $indent): bool */ private function parseMapping(int $indent, ?string $line): array { + $this->enter(); + $data = []; $keys = 0; $lines = $this->lines; @@ -238,12 +257,15 @@ private function parseMapping(int $indent, ?string $line): array } $this->p = $p; + $this->depth--; return $data; } private function parseSequence(int $indent): array { + $this->enter(); + $data = []; $lines = $this->lines; $count = $this->count; @@ -324,6 +346,7 @@ private function parseSequence(int $indent): array } $this->p = $p; + $this->depth--; return $data; } @@ -364,10 +387,6 @@ private function child(int $indent, bool $allowUnindentedSequence): ?array } if ($i > $indent) { - if ($i > self::MAX_INDENT) { - $this->fail(); - } - return $this->isSequenceItem($line, $i) ? $this->parseSequence($i) : $this->parseMapping($i, null); diff --git a/tests/Yaml/FastParserTest.php b/tests/Yaml/FastParserTest.php index 10923758855..495552a929b 100644 --- a/tests/Yaml/FastParserTest.php +++ b/tests/Yaml/FastParserTest.php @@ -84,6 +84,8 @@ public static function documentProvider() 'deep nesting' => self::nested(70), 'nesting beyond symfony\'s limit' => self::nested(130), + 'unindented nesting' => self::unindentedNesting(30), + 'unindented nesting beyond symfony\'s limit' => self::unindentedNesting(70), 'unindented list after a blank line' => "a:\n\n- x\n- y\nb: 1\n", 'unindented list after a whitespace-only line' => "a:\n \n- x\nb: 1\n", 'nested unindented list after a blank line' => "a:\n b:\n\n - x\n c: 1\n", @@ -97,6 +99,10 @@ public static function documentProvider() 'front matter, nested literal block, two more lines' => "---\na:\n b: |\n text\nc: 1\nd: 2\n", 'text after a closing quote' => "a: 'x' y\nb: \"x\" y\n", 'value ending in an ellipsis' => "a: Read more...\nb: 'Quoted...'\n", + 'front matter ending in an ellipsis' => "---\ntitle: Hi\nexcerpt: Read more...", + 'front matter ending in an ellipsis and a newline' => "---\ntitle: Hi\nexcerpt: Read more...\n", + 'front matter ending in an ellipsis in a list' => "---\na:\n - Read more...", + 'front matter with an ellipsis before the end' => "---\nexcerpt: Read more...\ntitle: Hi\n", 'document end marker' => "a: 1\n...", 'document end marker in a sequence' => "a:\n - x\n...\n", 'tab inside a literal block' => "a: |\n one\ttwo\nb: 1\n", @@ -117,6 +123,21 @@ public static function documentProvider() ])->mapWithKeys(fn ($yaml, $name) => [$name => [$yaml]])->all(); } + /** + * A key whose value is a sequence at the key's own indentation, holding "- key:" items: + * two levels of nesting for each step, but only two columns of indentation. + */ + private static function unindentedNesting(int $steps): string + { + $yaml = "k:\n"; + + for ($i = 0; $i < $steps; $i++) { + $yaml .= str_repeat(' ', $i * 2)."- k:\n"; + } + + return $yaml.str_repeat(' ', $steps * 2)."- end\n"; + } + private static function nested(int $depth): string { $yaml = "root:\n"; From 4a8d1a6d6354135ee634532249ed68c898b8b5f1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 10:24:56 -0500 Subject: [PATCH 6/9] Keep literal blocks with indentation indicators on the fast path Symfony writes an indentation indicator (e.g. |2-) when a multi-line value's first line starts with spaces, which previously made the whole document fall back to Symfony. These blocks are now handed to Symfony on their own, like |+ blocks already are. Co-Authored-By: Claude Opus 5.5 --- src/Yaml/FastParser.php | 5 +++-- tests/Yaml/FastParserTest.php | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Yaml/FastParser.php b/src/Yaml/FastParser.php index 65459185ba4..1ea3e98ef35 100644 --- a/src/Yaml/FastParser.php +++ b/src/Yaml/FastParser.php @@ -653,8 +653,9 @@ private function literal(string $header, int $indent): string private function literalBySymfony(string $header, int $indent): string { - if ($header !== '|' && $header !== '|-' && $header !== '|+') { - $this->fail(); // Indentation indicators, comments, etc. + // Indentation indicators are written when a block's first line starts with spaces. + if (! preg_match('/^\|[1-9]?[-+]?$/', $header)) { + $this->fail(); // Comments, folded blocks, etc. } $lines = $this->lines; diff --git a/tests/Yaml/FastParserTest.php b/tests/Yaml/FastParserTest.php index 495552a929b..af9564b1899 100644 --- a/tests/Yaml/FastParserTest.php +++ b/tests/Yaml/FastParserTest.php @@ -65,6 +65,14 @@ public static function documentProvider() 'literal block, nested' => "a:\n b: |\n text\n c: 1\n", 'literal block, blank lines with spaces' => "a: |-\n one \n \n \n two\n \nb: 1\n", 'literal block, more indented lines' => "a: |\n one\n indented\n two\n", + 'literal block, indentation indicator' => "a: |2\n one\n two\nb: 1\n", + 'literal block, indentation indicator, strip' => "a: |2-\n one\n two\nb: 1\n", + 'literal block, indentation indicator, keep' => "a: |2+\n one\n two\n\nb: 1\n", + 'literal block, indentation indicator, nested' => "a:\n b: |2-\n one\n two\n c: 1\n", + 'literal block, indentation indicator, last in document' => "a: |2-\n one\n two", + 'literal block, other indentation indicators' => "a: |1\n one\n two\nb: |4-\n one\n two\nc: |9\n x\n", + 'literal block, indentation indicator before chomping' => "a: |-2\n one\n two\n", + 'literal block, indentation indicator too large' => "a: |4\n one\n two\nb: 1\n", 'literal block, in compact item' => "- name: x\n b: |\n text\n- name: y\n", 'literal block, in compact item, blank line' => "- name: x\n with:\n b: |\n text\n\n- name: y\n", 'literal block, empty' => "a: |\nb: 1\n", @@ -161,6 +169,7 @@ public function it_uses_the_fast_path_for_content_that_statamic_writes() 'empty' => [], 'tags' => ['one', 'two'], 'text' => "Line one\nLine two\n\n indented", + 'code' => " indented first line\nsecond line\n", 'content' => [ ['type' => 'paragraph', 'content' => [['type' => 'text', 'marks' => [['type' => 'bold']], 'text' => 'Hello']]], ['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => ['type' => 'image', 'image' => ['a.jpg']]]], From d622d1b8719542335f78048b76491b1944bfceb6 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 11:02:40 -0500 Subject: [PATCH 7/9] Fall back to Symfony when a dash is followed by other whitespace Symfony treats a form feed or Unicode whitespace (such as a non-breaking space) after a dash as a sequence separator, so the fast parser could return different data or skip Symfony's errors for hand-edited content containing them. Co-Authored-By: Claude Opus 5.5 --- src/Yaml/FastParser.php | 21 ++++++++++++++++++--- tests/Yaml/FastParserTest.php | 14 +++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/Yaml/FastParser.php b/src/Yaml/FastParser.php index 1ea3e98ef35..3eca6638bd1 100644 --- a/src/Yaml/FastParser.php +++ b/src/Yaml/FastParser.php @@ -171,6 +171,17 @@ private function isSequenceItem(string $line, int $indent): bool return $line[$indent] === '-' && (! isset($line[$indent + 1]) || $line[$indent + 1] === ' '); } + /** + * Whether a dash at the start of the string is followed by whitespace other than a space. + * Symfony treats a form feed or Unicode whitespace there as a separator too, so the + * dash would start a sequence item. + */ + private function dashWithOtherWhitespace(string $s): bool + { + return isset($s[1]) && $s[0] === '-' + && ($s[1] === "\f" || $s[1] >= "\x80" && preg_match('/^-\s/u', $s)); + } + /** * @param string|null $line The first line, when it isn't the current line as written ("- key: value"). */ @@ -323,10 +334,14 @@ private function parseSequence(int $indent): array $c = $rest[0]; - if ($c === '-' && (! isset($rest[1]) || $rest[1] === ' ' || str_starts_with($rest, '---')) || $c === '.' && str_starts_with($rest, '...')) { + if ($c === '-' && (! isset($rest[1]) || $rest[1] === ' ' || str_starts_with($rest, '---') || $this->dashWithOtherWhitespace($rest)) || $c === '.' && str_starts_with($rest, '...')) { $this->fail(); // "- - x", or document markers. } + if (($c === "\f" || $c >= "\x80") && preg_match('/^\s/u', $rest)) { + $this->fail(); // Symfony strips other whitespace after the dash too. + } + if ((str_contains($rest, ': ') || $rest[-1] === ':') && preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P.+?))?\s*$#u', $rest) ) { @@ -423,7 +438,7 @@ private function key(string $raw): string|int|null } } - if (str_contains(' \'"[{!#&*?%@`|>~.', $c) || str_contains($raw, ' ') || str_starts_with($raw, '---')) { + if (str_contains(' \'"[{!#&*?%@`|>~.', $c) || str_contains($raw, ' ') || str_starts_with($raw, '---') || $this->dashWithOtherWhitespace($raw)) { return null; } @@ -458,7 +473,7 @@ private function splitKey(string $line): array $c = $line[0]; if (str_contains('#&*!?%@`{[|>', $c) - || $c === '-' && (! isset($line[1]) || $line[1] === ' ') + || $c === '-' && (! isset($line[1]) || $line[1] === ' ' || $this->dashWithOtherWhitespace($line)) || str_starts_with($line, '---') || str_starts_with($line, '...') ) { diff --git a/tests/Yaml/FastParserTest.php b/tests/Yaml/FastParserTest.php index af9564b1899..548f30dbd64 100644 --- a/tests/Yaml/FastParserTest.php +++ b/tests/Yaml/FastParserTest.php @@ -128,7 +128,18 @@ public static function documentProvider() 'invalid: unexpected indentation' => "a: 1\n b: 2\n", 'invalid: mapping in sequence' => "a:\n - x\n b: 1\n", 'invalid: utf-8' => "a: \xff\n", - ])->mapWithKeys(fn ($yaml, $name) => [$name => [$yaml]])->all(); + 'invalid: mapping after a sequence item with a non-breaking space' => "-\u{A0}x: y\nb: z\n", + 'invalid: quote after a non-breaking space in a sequence item' => "a:\n - \u{A0}'\n", + + 'non-breaking space after a sequence dash' => "- \u{A0}x\na:\n - \u{A0}b\n", + 'non-breaking space as a sequence separator' => "-\u{A0}x: y\n", + 'nested non-breaking space as a sequence separator' => "a:\n -\u{A0}b: 1\n", + 'non-breaking space in a nested sequence item' => "- -\u{A0}x\n", + 'unicode whitespace after a sequence dash' => "- \u{2003}x\n- \u{3000}y\n- \u{2028}z\n", + 'form feed after a sequence dash' => "- \fx\n", + 'form feed as a sequence separator' => "-\fa: 1\n", + 'non-breaking space as a key' => "-\u{A0}null: x\n", + 'non-breaking spaces elsewhere' => "a: \u{A0}x\nb: x\u{A0}\n\u{A0}c: x\nd:\u{A0}x\ne:\n - a:\u{A0}b\n", ])->mapWithKeys(fn ($yaml, $name) => [$name => [$yaml]])->all(); } /** @@ -252,6 +263,7 @@ private function parseWithoutFallingBack(string $yaml): array "'single'", "'it''s'", "''", '""', '"double"', '"esc\\"aped"', '"uni\\u00e9"', "'a\"b'", "'unterminated", '"unterminated', '{ }', '[]', '[a, b]', '{ a: 1 }', '&anchor foo', '*alias', '!tag foo', '!!str 1', '? complex', '@at', '`tick', '%pct', 'café', '日本語', 'with space', '-', '- x', '---', '...', ':', 'a:', ',', '<<', '\\', 'v1.2.3', '-foo', '.foo', '_foo', + "\u{A0}", "-\u{A0}", "\u{2003}x", "\fx", "-\f", ]; private function randomScalar(): string From 83a179e6042f5178da5c82ba8f4bcee636cf37e3 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 11:02:52 -0500 Subject: [PATCH 8/9] Only take the UUID shortcut for UUID-shaped values A 36 character value of hex digits and dashes with a dash at offset 8 could still be numeric to Symfony, such as 1234567e-123456789012345678901234567, which it reads as a float. Co-Authored-By: Claude Opus 5.5 --- src/Yaml/FastParser.php | 2 +- tests/Yaml/FastParserTest.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Yaml/FastParser.php b/src/Yaml/FastParser.php index 3eca6638bd1..044178e5805 100644 --- a/src/Yaml/FastParser.php +++ b/src/Yaml/FastParser.php @@ -536,7 +536,7 @@ private function scalar(string $value, string $c): mixed return (int) $value; } - if ($length === 36 && $value[8] === '-' && strspn($value, self::HEX) === 36) { + if ($length === 36 && $value[8] === '-' && $value[13] === '-' && $value[18] === '-' && $value[23] === '-' && strspn($value, self::HEX) === 36) { return $value; // A UUID. } } elseif ($c === '"') { diff --git a/tests/Yaml/FastParserTest.php b/tests/Yaml/FastParserTest.php index 548f30dbd64..3a6a7e6ddd6 100644 --- a/tests/Yaml/FastParserTest.php +++ b/tests/Yaml/FastParserTest.php @@ -139,7 +139,8 @@ public static function documentProvider() 'form feed after a sequence dash' => "- \fx\n", 'form feed as a sequence separator' => "-\fa: 1\n", 'non-breaking space as a key' => "-\u{A0}null: x\n", - 'non-breaking spaces elsewhere' => "a: \u{A0}x\nb: x\u{A0}\n\u{A0}c: x\nd:\u{A0}x\ne:\n - a:\u{A0}b\n", ])->mapWithKeys(fn ($yaml, $name) => [$name => [$yaml]])->all(); + 'non-breaking spaces elsewhere' => "a: \u{A0}x\nb: x\u{A0}\n\u{A0}c: x\nd:\u{A0}x\ne:\n - a:\u{A0}b\n", + 'numeric value shaped like a uuid' => "a: 1234567e-123456789012345678901234567\n", ])->mapWithKeys(fn ($yaml, $name) => [$name => [$yaml]])->all(); } /** From 39cca3b70135b8dd414d28e138c5a4846d15363e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 11:03:03 -0500 Subject: [PATCH 9/9] Rename duplicate document end marker test case Both cases used the same provider key, so the first was overwritten and never ran. Co-Authored-By: Claude Opus 5.5 --- tests/Yaml/FastParserTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Yaml/FastParserTest.php b/tests/Yaml/FastParserTest.php index 3a6a7e6ddd6..036594331d5 100644 --- a/tests/Yaml/FastParserTest.php +++ b/tests/Yaml/FastParserTest.php @@ -111,7 +111,7 @@ public static function documentProvider() 'front matter ending in an ellipsis and a newline' => "---\ntitle: Hi\nexcerpt: Read more...\n", 'front matter ending in an ellipsis in a list' => "---\na:\n - Read more...", 'front matter with an ellipsis before the end' => "---\nexcerpt: Read more...\ntitle: Hi\n", - 'document end marker' => "a: 1\n...", + 'document end marker, no final newline' => "a: 1\n...", 'document end marker in a sequence' => "a:\n - x\n...\n", 'tab inside a literal block' => "a: |\n one\ttwo\nb: 1\n", 'tab inside a value' => "a: 'one\ttwo'\nb: one\ttwo\n",