├── README.md ├── Exception ├── DumpException.php ├── ExceptionInterface.php ├── RuntimeException.php └── ParseException.php ├── Tag └── TaggedValue.php ├── composer.json ├── LICENSE ├── Resources └── bin │ └── yaml-lint ├── Unescaper.php ├── Yaml.php ├── Escaper.php ├── Dumper.php ├── CHANGELOG.md ├── Command └── LintCommand.php ├── Inline.php └── Parser.php /README.md: -------------------------------------------------------------------------------- 1 | Yaml Component 2 | ============== 3 | 4 | The Yaml component loads and dumps YAML files. 5 | 6 | Resources 7 | --------- 8 | 9 | * [Documentation](https://symfony.com/doc/current/components/yaml.html) 10 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 11 | * [Report issues](https://github.com/symfony/symfony/issues) and 12 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 13 | in the [main Symfony repository](https://github.com/symfony/symfony) 14 | -------------------------------------------------------------------------------- /Exception/DumpException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml\Exception; 13 | 14 | /** 15 | * Exception class thrown when an error occurs during dumping. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | class DumpException extends RuntimeException 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml\Exception; 13 | 14 | /** 15 | * Exception interface for all exceptions thrown by the component. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml\Exception; 13 | 14 | /** 15 | * Exception class thrown when an error occurs during parsing. 16 | * 17 | * @author Romain Neutron 18 | */ 19 | class RuntimeException extends \RuntimeException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Tag/TaggedValue.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml\Tag; 13 | 14 | /** 15 | * @author Nicolas Grekas 16 | * @author Guilhem N. 17 | */ 18 | final class TaggedValue 19 | { 20 | public function __construct( 21 | private string $tag, 22 | private mixed $value, 23 | ) { 24 | } 25 | 26 | public function getTag(): string 27 | { 28 | return $this->tag; 29 | } 30 | 31 | public function getValue(): mixed 32 | { 33 | return $this->value; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/yaml", 3 | "type": "library", 4 | "description": "Loads and dumps YAML files", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "symfony/deprecation-contracts": "^2.5|^3.0", 21 | "symfony/polyfill-ctype": "^1.8" 22 | }, 23 | "require-dev": { 24 | "symfony/console": "^6.4|^7.0" 25 | }, 26 | "conflict": { 27 | "symfony/console": "<6.4" 28 | }, 29 | "autoload": { 30 | "psr-4": { "Symfony\\Component\\Yaml\\": "" }, 31 | "exclude-from-classmap": [ 32 | "/Tests/" 33 | ] 34 | }, 35 | "bin": [ 36 | "Resources/bin/yaml-lint" 37 | ], 38 | "minimum-stability": "dev" 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2004-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Resources/bin/yaml-lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | */ 12 | 13 | if ('cli' !== \PHP_SAPI) { 14 | throw new Exception('This script must be run from the command line.'); 15 | } 16 | 17 | /** 18 | * Runs the Yaml lint command. 19 | * 20 | * @author Jan Schädlich 21 | */ 22 | 23 | use Symfony\Component\Console\Application; 24 | use Symfony\Component\Yaml\Command\LintCommand; 25 | 26 | function includeIfExists(string $file): bool 27 | { 28 | return file_exists($file) && include $file; 29 | } 30 | 31 | if ( 32 | !includeIfExists(__DIR__ . '/../../../../autoload.php') && 33 | !includeIfExists(__DIR__ . '/../../vendor/autoload.php') && 34 | !includeIfExists(__DIR__ . '/../../../../../../vendor/autoload.php') 35 | ) { 36 | fwrite(STDERR, 'Install dependencies using Composer.'.PHP_EOL); 37 | exit(1); 38 | } 39 | 40 | if (!class_exists(Application::class)) { 41 | fwrite(STDERR, 'You need the "symfony/console" component in order to run the Yaml linter.'.PHP_EOL); 42 | exit(1); 43 | } 44 | 45 | (new Application())->add($command = new LintCommand()) 46 | ->getApplication() 47 | ->setDefaultCommand($command->getName(), true) 48 | ->run() 49 | ; 50 | -------------------------------------------------------------------------------- /Exception/ParseException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml\Exception; 13 | 14 | /** 15 | * Exception class thrown when an error occurs during parsing. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | class ParseException extends RuntimeException 20 | { 21 | /** 22 | * @param string $rawMessage The error message 23 | * @param int $parsedLine The line where the error occurred 24 | * @param string|null $snippet The snippet of code near the problem 25 | * @param string|null $parsedFile The file name where the error occurred 26 | */ 27 | public function __construct( 28 | private string $rawMessage, 29 | private int $parsedLine = -1, 30 | private ?string $snippet = null, 31 | private ?string $parsedFile = null, 32 | ?\Throwable $previous = null, 33 | ) { 34 | $this->updateRepr(); 35 | 36 | parent::__construct($this->message, 0, $previous); 37 | } 38 | 39 | /** 40 | * Gets the snippet of code near the error. 41 | */ 42 | public function getSnippet(): string 43 | { 44 | return $this->snippet; 45 | } 46 | 47 | /** 48 | * Sets the snippet of code near the error. 49 | */ 50 | public function setSnippet(string $snippet): void 51 | { 52 | $this->snippet = $snippet; 53 | 54 | $this->updateRepr(); 55 | } 56 | 57 | /** 58 | * Gets the filename where the error occurred. 59 | * 60 | * This method returns null if a string is parsed. 61 | */ 62 | public function getParsedFile(): string 63 | { 64 | return $this->parsedFile; 65 | } 66 | 67 | /** 68 | * Sets the filename where the error occurred. 69 | */ 70 | public function setParsedFile(string $parsedFile): void 71 | { 72 | $this->parsedFile = $parsedFile; 73 | 74 | $this->updateRepr(); 75 | } 76 | 77 | /** 78 | * Gets the line where the error occurred. 79 | */ 80 | public function getParsedLine(): int 81 | { 82 | return $this->parsedLine; 83 | } 84 | 85 | /** 86 | * Sets the line where the error occurred. 87 | */ 88 | public function setParsedLine(int $parsedLine): void 89 | { 90 | $this->parsedLine = $parsedLine; 91 | 92 | $this->updateRepr(); 93 | } 94 | 95 | private function updateRepr(): void 96 | { 97 | $this->message = $this->rawMessage; 98 | 99 | $dot = false; 100 | if (str_ends_with($this->message, '.')) { 101 | $this->message = substr($this->message, 0, -1); 102 | $dot = true; 103 | } 104 | 105 | if (null !== $this->parsedFile) { 106 | $this->message .= \sprintf(' in %s', json_encode($this->parsedFile, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); 107 | } 108 | 109 | if ($this->parsedLine >= 0) { 110 | $this->message .= \sprintf(' at line %d', $this->parsedLine); 111 | } 112 | 113 | if ($this->snippet) { 114 | $this->message .= \sprintf(' (near "%s")', $this->snippet); 115 | } 116 | 117 | if ($dot) { 118 | $this->message .= '.'; 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Unescaper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml; 13 | 14 | use Symfony\Component\Yaml\Exception\ParseException; 15 | 16 | /** 17 | * Unescaper encapsulates unescaping rules for single and double-quoted 18 | * YAML strings. 19 | * 20 | * @author Matthew Lewinski 21 | * 22 | * @internal 23 | */ 24 | class Unescaper 25 | { 26 | /** 27 | * Regex fragment that matches an escaped character in a double quoted string. 28 | */ 29 | public const REGEX_ESCAPED_CHARACTER = '\\\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)'; 30 | 31 | /** 32 | * Unescapes a single quoted string. 33 | * 34 | * @param string $value A single quoted string 35 | */ 36 | public function unescapeSingleQuotedString(string $value): string 37 | { 38 | return str_replace('\'\'', '\'', $value); 39 | } 40 | 41 | /** 42 | * Unescapes a double quoted string. 43 | * 44 | * @param string $value A double quoted string 45 | */ 46 | public function unescapeDoubleQuotedString(string $value): string 47 | { 48 | $callback = fn ($match) => $this->unescapeCharacter($match[0]); 49 | 50 | // evaluate the string 51 | return preg_replace_callback('/'.self::REGEX_ESCAPED_CHARACTER.'/u', $callback, $value); 52 | } 53 | 54 | /** 55 | * Unescapes a character that was found in a double-quoted string. 56 | * 57 | * @param string $value An escaped character 58 | */ 59 | private function unescapeCharacter(string $value): string 60 | { 61 | return match ($value[1]) { 62 | '0' => "\x0", 63 | 'a' => "\x7", 64 | 'b' => "\x8", 65 | 't' => "\t", 66 | "\t" => "\t", 67 | 'n' => "\n", 68 | 'v' => "\xB", 69 | 'f' => "\xC", 70 | 'r' => "\r", 71 | 'e' => "\x1B", 72 | ' ' => ' ', 73 | '"' => '"', 74 | '/' => '/', 75 | '\\' => '\\', 76 | // U+0085 NEXT LINE 77 | 'N' => "\xC2\x85", 78 | // U+00A0 NO-BREAK SPACE 79 | '_' => "\xC2\xA0", 80 | // U+2028 LINE SEPARATOR 81 | 'L' => "\xE2\x80\xA8", 82 | // U+2029 PARAGRAPH SEPARATOR 83 | 'P' => "\xE2\x80\xA9", 84 | 'x' => self::utf8chr(hexdec(substr($value, 2, 2))), 85 | 'u' => self::utf8chr(hexdec(substr($value, 2, 4))), 86 | 'U' => self::utf8chr(hexdec(substr($value, 2, 8))), 87 | default => throw new ParseException(\sprintf('Found unknown escape character "%s".', $value)), 88 | }; 89 | } 90 | 91 | /** 92 | * Get the UTF-8 character for the given code point. 93 | */ 94 | private static function utf8chr(int $c): string 95 | { 96 | if (0x80 > $c %= 0x200000) { 97 | return \chr($c); 98 | } 99 | if (0x800 > $c) { 100 | return \chr(0xC0 | $c >> 6).\chr(0x80 | $c & 0x3F); 101 | } 102 | if (0x10000 > $c) { 103 | return \chr(0xE0 | $c >> 12).\chr(0x80 | $c >> 6 & 0x3F).\chr(0x80 | $c & 0x3F); 104 | } 105 | 106 | return \chr(0xF0 | $c >> 18).\chr(0x80 | $c >> 12 & 0x3F).\chr(0x80 | $c >> 6 & 0x3F).\chr(0x80 | $c & 0x3F); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Yaml.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml; 13 | 14 | use Symfony\Component\Yaml\Exception\ParseException; 15 | 16 | /** 17 | * Yaml offers convenience methods to load and dump YAML. 18 | * 19 | * @author Fabien Potencier 20 | * 21 | * @final 22 | */ 23 | class Yaml 24 | { 25 | public const DUMP_OBJECT = 1; 26 | public const PARSE_EXCEPTION_ON_INVALID_TYPE = 2; 27 | public const PARSE_OBJECT = 4; 28 | public const PARSE_OBJECT_FOR_MAP = 8; 29 | public const DUMP_EXCEPTION_ON_INVALID_TYPE = 16; 30 | public const PARSE_DATETIME = 32; 31 | public const DUMP_OBJECT_AS_MAP = 64; 32 | public const DUMP_MULTI_LINE_LITERAL_BLOCK = 128; 33 | public const PARSE_CONSTANT = 256; 34 | public const PARSE_CUSTOM_TAGS = 512; 35 | public const DUMP_EMPTY_ARRAY_AS_SEQUENCE = 1024; 36 | public const DUMP_NULL_AS_TILDE = 2048; 37 | public const DUMP_NUMERIC_KEY_AS_STRING = 4096; 38 | public const DUMP_NULL_AS_EMPTY = 8192; 39 | public const DUMP_COMPACT_NESTED_MAPPING = 16384; 40 | public const DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES = 32768; 41 | 42 | /** 43 | * Parses a YAML file into a PHP value. 44 | * 45 | * Usage: 46 | * 47 | * $array = Yaml::parseFile('config.yml'); 48 | * print_r($array); 49 | * 50 | * @param string $filename The path to the YAML file to be parsed 51 | * @param int-mask-of $flags A bit field of PARSE_* constants to customize the YAML parser behavior 52 | * 53 | * @throws ParseException If the file could not be read or the YAML is not valid 54 | */ 55 | public static function parseFile(string $filename, int $flags = 0): mixed 56 | { 57 | $yaml = new Parser(); 58 | 59 | return $yaml->parseFile($filename, $flags); 60 | } 61 | 62 | /** 63 | * Parses YAML into a PHP value. 64 | * 65 | * Usage: 66 | * 67 | * $array = Yaml::parse(file_get_contents('config.yml')); 68 | * print_r($array); 69 | * 70 | * 71 | * @param string $input A string containing YAML 72 | * @param int-mask-of $flags A bit field of PARSE_* constants to customize the YAML parser behavior 73 | * 74 | * @throws ParseException If the YAML is not valid 75 | */ 76 | public static function parse(string $input, int $flags = 0): mixed 77 | { 78 | $yaml = new Parser(); 79 | 80 | return $yaml->parse($input, $flags); 81 | } 82 | 83 | /** 84 | * Dumps a PHP value to a YAML string. 85 | * 86 | * The dump method, when supplied with an array, will do its best 87 | * to convert the array into friendly YAML. 88 | * 89 | * @param mixed $input The PHP value 90 | * @param int $inline The level where you switch to inline YAML 91 | * @param int $indent The amount of spaces to use for indentation of nested nodes 92 | * @param int-mask-of $flags A bit field of DUMP_* constants to customize the dumped YAML string 93 | */ 94 | public static function dump(mixed $input, int $inline = 2, int $indent = 4, int $flags = 0): string 95 | { 96 | $yaml = new Dumper($indent); 97 | 98 | return $yaml->dump($input, $inline, 0, $flags); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Escaper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml; 13 | 14 | /** 15 | * Escaper encapsulates escaping rules for single and double-quoted 16 | * YAML strings. 17 | * 18 | * @author Matthew Lewinski 19 | * 20 | * @internal 21 | */ 22 | class Escaper 23 | { 24 | // Characters that would cause a dumped string to require double quoting. 25 | public const REGEX_CHARACTER_TO_ESCAPE = "[\\x00-\\x1f]|\x7f|\xc2\x85|\xc2\xa0|\xe2\x80\xa8|\xe2\x80\xa9"; 26 | 27 | // Mapping arrays for escaping a double quoted string. The backslash is 28 | // first to ensure proper escaping because str_replace operates iteratively 29 | // on the input arrays. This ordering of the characters avoids the use of strtr, 30 | // which performs more slowly. 31 | private const ESCAPEES = [ 32 | '\\', '\\\\', '\\"', '"', 33 | "\x00", "\x01", "\x02", "\x03", "\x04", "\x05", "\x06", "\x07", 34 | "\x08", "\x09", "\x0a", "\x0b", "\x0c", "\x0d", "\x0e", "\x0f", 35 | "\x10", "\x11", "\x12", "\x13", "\x14", "\x15", "\x16", "\x17", 36 | "\x18", "\x19", "\x1a", "\x1b", "\x1c", "\x1d", "\x1e", "\x1f", 37 | "\x7f", 38 | "\xc2\x85", "\xc2\xa0", "\xe2\x80\xa8", "\xe2\x80\xa9", 39 | ]; 40 | private const ESCAPED = [ 41 | '\\\\', '\\"', '\\\\', '\\"', 42 | '\\0', '\\x01', '\\x02', '\\x03', '\\x04', '\\x05', '\\x06', '\\a', 43 | '\\b', '\\t', '\\n', '\\v', '\\f', '\\r', '\\x0e', '\\x0f', 44 | '\\x10', '\\x11', '\\x12', '\\x13', '\\x14', '\\x15', '\\x16', '\\x17', 45 | '\\x18', '\\x19', '\\x1a', '\\e', '\\x1c', '\\x1d', '\\x1e', '\\x1f', 46 | '\\x7f', 47 | '\\N', '\\_', '\\L', '\\P', 48 | ]; 49 | 50 | /** 51 | * Determines if a PHP value would require double quoting in YAML. 52 | * 53 | * @param string $value A PHP value 54 | */ 55 | public static function requiresDoubleQuoting(string $value): bool 56 | { 57 | return 0 < preg_match('/'.self::REGEX_CHARACTER_TO_ESCAPE.'/u', $value); 58 | } 59 | 60 | /** 61 | * Escapes and surrounds a PHP value with double quotes. 62 | * 63 | * @param string $value A PHP value 64 | */ 65 | public static function escapeWithDoubleQuotes(string $value): string 66 | { 67 | return \sprintf('"%s"', str_replace(self::ESCAPEES, self::ESCAPED, $value)); 68 | } 69 | 70 | /** 71 | * Determines if a PHP value would require single quoting in YAML. 72 | * 73 | * @param string $value A PHP value 74 | */ 75 | public static function requiresSingleQuoting(string $value): bool 76 | { 77 | // Determines if a PHP value is entirely composed of a value that would 78 | // require single quoting in YAML. 79 | if (\in_array(strtolower($value), ['null', '~', 'true', 'false', 'y', 'n', 'yes', 'no', 'on', 'off'])) { 80 | return true; 81 | } 82 | 83 | // Determines if the PHP value contains any single characters that would 84 | // cause it to require single quoting in YAML. 85 | return 0 < preg_match('/[\s\'"\:\{\}\[\],&\*\#\?] | \A[\-?|<>=!%@`\p{Zs}]/xu', $value); 86 | } 87 | 88 | /** 89 | * Escapes and surrounds a PHP value with single quotes. 90 | * 91 | * @param string $value A PHP value 92 | */ 93 | public static function escapeWithSingleQuotes(string $value): string 94 | { 95 | return \sprintf("'%s'", str_replace('\'', '\'\'', $value)); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Dumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml; 13 | 14 | use Symfony\Component\Yaml\Tag\TaggedValue; 15 | 16 | /** 17 | * Dumper dumps PHP variables to YAML strings. 18 | * 19 | * @author Fabien Potencier 20 | * 21 | * @final 22 | */ 23 | class Dumper 24 | { 25 | /** 26 | * @param int $indentation The amount of spaces to use for indentation of nested nodes 27 | */ 28 | public function __construct(private int $indentation = 4) 29 | { 30 | if ($indentation < 1) { 31 | throw new \InvalidArgumentException('The indentation must be greater than zero.'); 32 | } 33 | } 34 | 35 | /** 36 | * Dumps a PHP value to YAML. 37 | * 38 | * @param mixed $input The PHP value 39 | * @param int $inline The level where you switch to inline YAML 40 | * @param int $indent The level of indentation (used internally) 41 | * @param int-mask-of $flags A bit field of Yaml::DUMP_* constants to customize the dumped YAML string 42 | */ 43 | public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags = 0): string 44 | { 45 | if ($flags & Yaml::DUMP_NULL_AS_EMPTY && $flags & Yaml::DUMP_NULL_AS_TILDE) { 46 | throw new \InvalidArgumentException('The Yaml::DUMP_NULL_AS_EMPTY and Yaml::DUMP_NULL_AS_TILDE flags cannot be used together.'); 47 | } 48 | 49 | return $this->doDump($input, $inline, $indent, $flags); 50 | } 51 | 52 | private function doDump(mixed $input, int $inline = 0, int $indent = 0, int $flags = 0, int $nestingLevel = 0): string 53 | { 54 | $output = ''; 55 | $prefix = $indent ? str_repeat(' ', $indent) : ''; 56 | $dumpObjectAsInlineMap = true; 57 | 58 | if (Yaml::DUMP_OBJECT_AS_MAP & $flags && ($input instanceof \ArrayObject || $input instanceof \stdClass)) { 59 | $dumpObjectAsInlineMap = !(array) $input; 60 | } 61 | 62 | if ($inline <= 0 || (!\is_array($input) && !$input instanceof TaggedValue && $dumpObjectAsInlineMap) || !$input) { 63 | $output .= $prefix.Inline::dump($input, $flags, 0 === $nestingLevel); 64 | } elseif ($input instanceof TaggedValue) { 65 | $output .= $this->dumpTaggedValue($input, $inline, $indent, $flags, $prefix, $nestingLevel); 66 | } else { 67 | $dumpAsMap = Inline::isHash($input); 68 | $compactNestedMapping = Yaml::DUMP_COMPACT_NESTED_MAPPING & $flags && !$dumpAsMap; 69 | 70 | foreach ($input as $key => $value) { 71 | if ('' !== $output && "\n" !== $output[-1]) { 72 | $output .= "\n"; 73 | } 74 | 75 | if (\is_int($key) && Yaml::DUMP_NUMERIC_KEY_AS_STRING & $flags) { 76 | $key = (string) $key; 77 | } 78 | 79 | if (Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK & $flags && \is_string($value) && str_contains($value, "\n") && !str_contains($value, "\r")) { 80 | $blockIndentationIndicator = $this->getBlockIndentationIndicator($value); 81 | 82 | if (isset($value[-2]) && "\n" === $value[-2] && "\n" === $value[-1]) { 83 | $blockChompingIndicator = '+'; 84 | } elseif ("\n" === $value[-1]) { 85 | $blockChompingIndicator = ''; 86 | } else { 87 | $blockChompingIndicator = '-'; 88 | } 89 | 90 | $output .= \sprintf('%s%s%s |%s%s', $prefix, $dumpAsMap ? Inline::dump($key, $flags).':' : '-', '', $blockIndentationIndicator, $blockChompingIndicator); 91 | 92 | foreach (explode("\n", $value) as $row) { 93 | if ('' === $row) { 94 | $output .= "\n"; 95 | } else { 96 | $output .= \sprintf("\n%s%s%s", $prefix, str_repeat(' ', $this->indentation), $row); 97 | } 98 | } 99 | 100 | continue; 101 | } 102 | 103 | if ($value instanceof TaggedValue) { 104 | $output .= \sprintf('%s%s !%s', $prefix, $dumpAsMap ? Inline::dump($key, $flags).':' : '-', $value->getTag()); 105 | 106 | if (Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK & $flags && \is_string($value->getValue()) && str_contains($value->getValue(), "\n") && !str_contains($value->getValue(), "\r\n")) { 107 | $blockIndentationIndicator = $this->getBlockIndentationIndicator($value->getValue()); 108 | $output .= \sprintf(' |%s', $blockIndentationIndicator); 109 | 110 | foreach (explode("\n", $value->getValue()) as $row) { 111 | $output .= \sprintf("\n%s%s%s", $prefix, str_repeat(' ', $this->indentation), $row); 112 | } 113 | 114 | continue; 115 | } 116 | 117 | if ($inline - 1 <= 0 || null === $value->getValue() || \is_scalar($value->getValue())) { 118 | $output .= ' '.$this->doDump($value->getValue(), $inline - 1, 0, $flags, $nestingLevel + 1)."\n"; 119 | } else { 120 | $output .= "\n"; 121 | $output .= $this->doDump($value->getValue(), $inline - 1, $dumpAsMap ? $indent + $this->indentation : $indent + 2, $flags, $nestingLevel + 1); 122 | } 123 | 124 | continue; 125 | } 126 | 127 | $dumpObjectAsInlineMap = true; 128 | 129 | if (Yaml::DUMP_OBJECT_AS_MAP & $flags && ($value instanceof \ArrayObject || $value instanceof \stdClass)) { 130 | $dumpObjectAsInlineMap = !(array) $value; 131 | } 132 | 133 | $willBeInlined = $inline - 1 <= 0 || !\is_array($value) && $dumpObjectAsInlineMap || !$value; 134 | 135 | $output .= \sprintf('%s%s%s%s', 136 | $prefix, 137 | $dumpAsMap ? Inline::dump($key, $flags).':' : '-', 138 | $willBeInlined || ($compactNestedMapping && \is_array($value) && Inline::isHash($value)) ? ' ' : "\n", 139 | $compactNestedMapping && \is_array($value) && Inline::isHash($value) ? substr($this->doDump($value, $inline - 1, $indent + 2, $flags, $nestingLevel + 1), $indent + 2) : $this->doDump($value, $inline - 1, $willBeInlined ? 0 : $indent + $this->indentation, $flags, $nestingLevel + 1) 140 | ).($willBeInlined ? "\n" : ''); 141 | } 142 | } 143 | 144 | return $output; 145 | } 146 | 147 | private function dumpTaggedValue(TaggedValue $value, int $inline, int $indent, int $flags, string $prefix, int $nestingLevel): string 148 | { 149 | $output = \sprintf('%s!%s', $prefix ? $prefix.' ' : '', $value->getTag()); 150 | 151 | if (Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK & $flags && \is_string($value->getValue()) && str_contains($value->getValue(), "\n") && !str_contains($value->getValue(), "\r\n")) { 152 | $blockIndentationIndicator = $this->getBlockIndentationIndicator($value->getValue()); 153 | $output .= \sprintf(' |%s', $blockIndentationIndicator); 154 | 155 | foreach (explode("\n", $value->getValue()) as $row) { 156 | $output .= \sprintf("\n%s%s%s", $prefix, str_repeat(' ', $this->indentation), $row); 157 | } 158 | 159 | return $output; 160 | } 161 | 162 | if ($inline - 1 <= 0 || null === $value->getValue() || \is_scalar($value->getValue())) { 163 | return $output.' '.$this->doDump($value->getValue(), $inline - 1, 0, $flags, $nestingLevel + 1)."\n"; 164 | } 165 | 166 | return $output."\n".$this->doDump($value->getValue(), $inline - 1, $indent, $flags, $nestingLevel + 1); 167 | } 168 | 169 | private function getBlockIndentationIndicator(string $value): string 170 | { 171 | $lines = explode("\n", $value); 172 | 173 | // If the first line (that is neither empty nor contains only spaces) 174 | // starts with a space character, the spec requires a block indentation indicator 175 | // http://www.yaml.org/spec/1.2/spec.html#id2793979 176 | foreach ($lines as $line) { 177 | if ('' !== trim($line, ' ')) { 178 | return str_starts_with($line, ' ') ? (string) $this->indentation : ''; 179 | } 180 | } 181 | 182 | return ''; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Add compact nested mapping support by using the `Yaml::DUMP_COMPACT_NESTED_MAPPING` flag 8 | * Add the `Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES` flag to enforce double quotes around string values 9 | 10 | 7.2 11 | --- 12 | 13 | * Deprecate parsing duplicate mapping keys whose value is `null` 14 | * Add support for dumping `null` as an empty value by using the `Yaml::DUMP_NULL_AS_EMPTY` flag 15 | 16 | 7.1 17 | --- 18 | 19 | * Add support for getting all the enum cases with `!php/enum Foo` 20 | 21 | 7.0 22 | --- 23 | 24 | * Remove the `!php/const:` tag, use `!php/const` instead (without the colon) 25 | 26 | 6.3 27 | --- 28 | 29 | * Add support to dump int keys as strings by using the `Yaml::DUMP_NUMERIC_KEY_AS_STRING` flag 30 | 31 | 6.2 32 | --- 33 | 34 | * Add support for `!php/enum` and `!php/enum *->value` 35 | * Deprecate the `!php/const:` tag in key which will be replaced by the `!php/const` tag (without the colon) since 3.4 36 | 37 | 6.1 38 | --- 39 | 40 | * In cases where it will likely improve readability, strings containing single quotes will be double-quoted 41 | 42 | 5.4 43 | --- 44 | 45 | * Add new `lint:yaml dirname --exclude=/dirname/foo.yaml --exclude=/dirname/bar.yaml` 46 | option to exclude one or more specific files from multiple file list 47 | * Allow negatable for the parse tags option with `--no-parse-tags` 48 | 49 | 5.3 50 | --- 51 | 52 | * Added `github` format support & autodetection to render errors as annotations 53 | when running the YAML linter command in a Github Action environment. 54 | 55 | 5.1.0 56 | ----- 57 | 58 | * Added support for parsing numbers prefixed with `0o` as octal numbers. 59 | * Deprecated support for parsing numbers starting with `0` as octal numbers. They will be parsed as strings as of Symfony 6.0. Prefix numbers with `0o` 60 | so that they are parsed as octal numbers. 61 | 62 | Before: 63 | 64 | ```yaml 65 | Yaml::parse('072'); 66 | ``` 67 | 68 | After: 69 | 70 | ```yaml 71 | Yaml::parse('0o72'); 72 | ``` 73 | 74 | * Added `yaml-lint` binary. 75 | * Deprecated using the `!php/object` and `!php/const` tags without a value. 76 | 77 | 5.0.0 78 | ----- 79 | 80 | * Removed support for mappings inside multi-line strings. 81 | * removed support for implicit STDIN usage in the `lint:yaml` command, use `lint:yaml -` (append a dash) instead to make it explicit. 82 | 83 | 4.4.0 84 | ----- 85 | 86 | * Added support for parsing the inline notation spanning multiple lines. 87 | * Added support to dump `null` as `~` by using the `Yaml::DUMP_NULL_AS_TILDE` flag. 88 | * deprecated accepting STDIN implicitly when using the `lint:yaml` command, use `lint:yaml -` (append a dash) instead to make it explicit. 89 | 90 | 4.3.0 91 | ----- 92 | 93 | * Using a mapping inside a multi-line string is deprecated and will throw a `ParseException` in 5.0. 94 | 95 | 4.2.0 96 | ----- 97 | 98 | * added support for multiple files or directories in `LintCommand` 99 | 100 | 4.0.0 101 | ----- 102 | 103 | * The behavior of the non-specific tag `!` is changed and now forces 104 | non-evaluating your values. 105 | * complex mappings will throw a `ParseException` 106 | * support for the comma as a group separator for floats has been dropped, use 107 | the underscore instead 108 | * support for the `!!php/object` tag has been dropped, use the `!php/object` 109 | tag instead 110 | * duplicate mapping keys throw a `ParseException` 111 | * non-string mapping keys throw a `ParseException`, use the `Yaml::PARSE_KEYS_AS_STRINGS` 112 | flag to cast them to strings 113 | * `%` at the beginning of an unquoted string throw a `ParseException` 114 | * mappings with a colon (`:`) that is not followed by a whitespace throw a 115 | `ParseException` 116 | * the `Dumper::setIndentation()` method has been removed 117 | * being able to pass boolean options to the `Yaml::parse()`, `Yaml::dump()`, 118 | `Parser::parse()`, and `Dumper::dump()` methods to configure the behavior of 119 | the parser and dumper is no longer supported, pass bitmask flags instead 120 | * the constructor arguments of the `Parser` class have been removed 121 | * the `Inline` class is internal and no longer part of the BC promise 122 | * removed support for the `!str` tag, use the `!!str` tag instead 123 | * added support for tagged scalars. 124 | 125 | ```yml 126 | Yaml::parse('!foo bar', Yaml::PARSE_CUSTOM_TAGS); 127 | // returns TaggedValue('foo', 'bar'); 128 | ``` 129 | 130 | 3.4.0 131 | ----- 132 | 133 | * added support for parsing YAML files using the `Yaml::parseFile()` or `Parser::parseFile()` method 134 | 135 | * the `Dumper`, `Parser`, and `Yaml` classes are marked as final 136 | 137 | * Deprecated the `!php/object:` tag which will be replaced by the 138 | `!php/object` tag (without the colon) in 4.0. 139 | 140 | * Deprecated the `!php/const:` tag which will be replaced by the 141 | `!php/const` tag (without the colon) in 4.0. 142 | 143 | * Support for the `!str` tag is deprecated, use the `!!str` tag instead. 144 | 145 | * Deprecated using the non-specific tag `!` as its behavior will change in 4.0. 146 | It will force non-evaluating your values in 4.0. Use plain integers or `!!float` instead. 147 | 148 | 3.3.0 149 | ----- 150 | 151 | * Starting an unquoted string with a question mark followed by a space is 152 | deprecated and will throw a `ParseException` in Symfony 4.0. 153 | 154 | * Deprecated support for implicitly parsing non-string mapping keys as strings. 155 | Mapping keys that are no strings will lead to a `ParseException` in Symfony 156 | 4.0. Use quotes to opt-in for keys to be parsed as strings. 157 | 158 | Before: 159 | 160 | ```php 161 | $yaml = << new A(), 'bar' => 1], 0, 0, Yaml::DUMP_EXCEPTION_ON_INVALID_TYPE | Yaml::DUMP_OBJECT); 243 | ``` 244 | 245 | 3.0.0 246 | ----- 247 | 248 | * Yaml::parse() now throws an exception when a blackslash is not escaped 249 | in double-quoted strings 250 | 251 | 2.8.0 252 | ----- 253 | 254 | * Deprecated usage of a colon in an unquoted mapping value 255 | * Deprecated usage of @, \`, | and > at the beginning of an unquoted string 256 | * When surrounding strings with double-quotes, you must now escape `\` characters. Not 257 | escaping those characters (when surrounded by double-quotes) is deprecated. 258 | 259 | Before: 260 | 261 | ```yml 262 | class: "Foo\Var" 263 | ``` 264 | 265 | After: 266 | 267 | ```yml 268 | class: "Foo\\Var" 269 | ``` 270 | 271 | 2.1.0 272 | ----- 273 | 274 | * Yaml::parse() does not evaluate loaded files as PHP files by default 275 | anymore (call Yaml::enablePhpParsing() to get back the old behavior) 276 | -------------------------------------------------------------------------------- /Command/LintCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml\Command; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\CI\GithubActionReporter; 16 | use Symfony\Component\Console\Command\Command; 17 | use Symfony\Component\Console\Completion\CompletionInput; 18 | use Symfony\Component\Console\Completion\CompletionSuggestions; 19 | use Symfony\Component\Console\Exception\InvalidArgumentException; 20 | use Symfony\Component\Console\Exception\RuntimeException; 21 | use Symfony\Component\Console\Input\InputArgument; 22 | use Symfony\Component\Console\Input\InputInterface; 23 | use Symfony\Component\Console\Input\InputOption; 24 | use Symfony\Component\Console\Output\OutputInterface; 25 | use Symfony\Component\Console\Style\SymfonyStyle; 26 | use Symfony\Component\Yaml\Exception\ParseException; 27 | use Symfony\Component\Yaml\Parser; 28 | use Symfony\Component\Yaml\Yaml; 29 | 30 | /** 31 | * Validates YAML files syntax and outputs encountered errors. 32 | * 33 | * @author Grégoire Pineau 34 | * @author Robin Chalas 35 | */ 36 | #[AsCommand(name: 'lint:yaml', description: 'Lint a YAML file and outputs encountered errors')] 37 | class LintCommand extends Command 38 | { 39 | private Parser $parser; 40 | private ?string $format = null; 41 | private bool $displayCorrectFiles; 42 | private ?\Closure $directoryIteratorProvider; 43 | private ?\Closure $isReadableProvider; 44 | 45 | public function __construct(?string $name = null, ?callable $directoryIteratorProvider = null, ?callable $isReadableProvider = null) 46 | { 47 | parent::__construct($name); 48 | 49 | $this->directoryIteratorProvider = null === $directoryIteratorProvider ? null : $directoryIteratorProvider(...); 50 | $this->isReadableProvider = null === $isReadableProvider ? null : $isReadableProvider(...); 51 | } 52 | 53 | protected function configure(): void 54 | { 55 | $this 56 | ->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') 57 | ->addOption('format', null, InputOption::VALUE_REQUIRED, \sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions()))) 58 | ->addOption('exclude', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path(s) to exclude') 59 | ->addOption('parse-tags', null, InputOption::VALUE_NEGATABLE, 'Parse custom tags', null) 60 | ->setHelp(<<%command.name% command lints a YAML file and outputs to STDOUT 62 | the first encountered syntax error. 63 | 64 | You can validates YAML contents passed from STDIN: 65 | 66 | cat filename | php %command.full_name% - 67 | 68 | You can also validate the syntax of a file: 69 | 70 | php %command.full_name% filename 71 | 72 | Or of a whole directory: 73 | 74 | php %command.full_name% dirname 75 | 76 | The --format option specifies the format of the command output: 77 | 78 | php %command.full_name% dirname --format=json 79 | 80 | You can also exclude one or more specific files: 81 | 82 | php %command.full_name% dirname --exclude="dirname/foo.yaml" --exclude="dirname/bar.yaml" 83 | 84 | EOF 85 | ) 86 | ; 87 | } 88 | 89 | protected function execute(InputInterface $input, OutputInterface $output): int 90 | { 91 | $io = new SymfonyStyle($input, $output); 92 | $filenames = (array) $input->getArgument('filename'); 93 | $excludes = $input->getOption('exclude'); 94 | $this->format = $input->getOption('format'); 95 | $flags = $input->getOption('parse-tags'); 96 | 97 | if (null === $this->format) { 98 | // Autodetect format according to CI environment 99 | $this->format = class_exists(GithubActionReporter::class) && GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'; 100 | } 101 | 102 | $flags = $flags ? Yaml::PARSE_CUSTOM_TAGS : 0; 103 | 104 | $this->displayCorrectFiles = $output->isVerbose(); 105 | 106 | if (['-'] === $filenames) { 107 | return $this->display($io, [$this->validate(file_get_contents('php://stdin'), $flags)]); 108 | } 109 | 110 | if (!$filenames) { 111 | throw new RuntimeException('Please provide a filename or pipe file content to STDIN.'); 112 | } 113 | 114 | $filesInfo = []; 115 | foreach ($filenames as $filename) { 116 | if (!$this->isReadable($filename)) { 117 | throw new RuntimeException(\sprintf('File or directory "%s" is not readable.', $filename)); 118 | } 119 | 120 | foreach ($this->getFiles($filename) as $file) { 121 | if (!\in_array($file->getPathname(), $excludes, true)) { 122 | $filesInfo[] = $this->validate(file_get_contents($file), $flags, $file); 123 | } 124 | } 125 | } 126 | 127 | return $this->display($io, $filesInfo); 128 | } 129 | 130 | private function validate(string $content, int $flags, ?string $file = null): array 131 | { 132 | $prevErrorHandler = set_error_handler(function ($level, $message, $file, $line) use (&$prevErrorHandler) { 133 | if (\E_USER_DEPRECATED === $level) { 134 | throw new ParseException($message, $this->getParser()->getRealCurrentLineNb() + 1); 135 | } 136 | 137 | return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; 138 | }); 139 | 140 | try { 141 | $this->getParser()->parse($content, Yaml::PARSE_CONSTANT | $flags); 142 | } catch (ParseException $e) { 143 | return ['file' => $file, 'line' => $e->getParsedLine(), 'valid' => false, 'message' => $e->getMessage()]; 144 | } finally { 145 | restore_error_handler(); 146 | } 147 | 148 | return ['file' => $file, 'valid' => true]; 149 | } 150 | 151 | private function display(SymfonyStyle $io, array $files): int 152 | { 153 | return match ($this->format) { 154 | 'txt' => $this->displayTxt($io, $files), 155 | 'json' => $this->displayJson($io, $files), 156 | 'github' => $this->displayTxt($io, $files, true), 157 | default => throw new InvalidArgumentException(\sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))), 158 | }; 159 | } 160 | 161 | private function displayTxt(SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false): int 162 | { 163 | $countFiles = \count($filesInfo); 164 | $erroredFiles = 0; 165 | $suggestTagOption = false; 166 | 167 | if ($errorAsGithubAnnotations) { 168 | $githubReporter = new GithubActionReporter($io); 169 | } 170 | 171 | foreach ($filesInfo as $info) { 172 | if ($info['valid'] && $this->displayCorrectFiles) { 173 | $io->comment('OK'.($info['file'] ? \sprintf(' in %s', $info['file']) : '')); 174 | } elseif (!$info['valid']) { 175 | ++$erroredFiles; 176 | $io->text(' ERROR '.($info['file'] ? \sprintf(' in %s', $info['file']) : '')); 177 | $io->text(\sprintf(' >> %s', $info['message'])); 178 | 179 | if (str_contains($info['message'], 'PARSE_CUSTOM_TAGS')) { 180 | $suggestTagOption = true; 181 | } 182 | 183 | if ($errorAsGithubAnnotations) { 184 | $githubReporter->error($info['message'], $info['file'] ?? 'php://stdin', $info['line']); 185 | } 186 | } 187 | } 188 | 189 | if (0 === $erroredFiles) { 190 | $io->success(\sprintf('All %d YAML files contain valid syntax.', $countFiles)); 191 | } else { 192 | $io->warning(\sprintf('%d YAML files have valid syntax and %d contain errors.%s', $countFiles - $erroredFiles, $erroredFiles, $suggestTagOption ? ' Use the --parse-tags option if you want parse custom tags.' : '')); 193 | } 194 | 195 | return min($erroredFiles, 1); 196 | } 197 | 198 | private function displayJson(SymfonyStyle $io, array $filesInfo): int 199 | { 200 | $errors = 0; 201 | 202 | array_walk($filesInfo, function (&$v) use (&$errors) { 203 | $v['file'] = (string) $v['file']; 204 | if (!$v['valid']) { 205 | ++$errors; 206 | } 207 | 208 | if (isset($v['message']) && str_contains($v['message'], 'PARSE_CUSTOM_TAGS')) { 209 | $v['message'] .= ' Use the --parse-tags option if you want parse custom tags.'; 210 | } 211 | }); 212 | 213 | $io->writeln(json_encode($filesInfo, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); 214 | 215 | return min($errors, 1); 216 | } 217 | 218 | private function getFiles(string $fileOrDirectory): iterable 219 | { 220 | if (is_file($fileOrDirectory)) { 221 | yield new \SplFileInfo($fileOrDirectory); 222 | 223 | return; 224 | } 225 | 226 | foreach ($this->getDirectoryIterator($fileOrDirectory) as $file) { 227 | if (!\in_array($file->getExtension(), ['yml', 'yaml'])) { 228 | continue; 229 | } 230 | 231 | yield $file; 232 | } 233 | } 234 | 235 | private function getParser(): Parser 236 | { 237 | return $this->parser ??= new Parser(); 238 | } 239 | 240 | private function getDirectoryIterator(string $directory): iterable 241 | { 242 | $default = fn ($directory) => new \RecursiveIteratorIterator( 243 | new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS), 244 | \RecursiveIteratorIterator::LEAVES_ONLY 245 | ); 246 | 247 | if (null !== $this->directoryIteratorProvider) { 248 | return ($this->directoryIteratorProvider)($directory, $default); 249 | } 250 | 251 | return $default($directory); 252 | } 253 | 254 | private function isReadable(string $fileOrDirectory): bool 255 | { 256 | $default = is_readable(...); 257 | 258 | if (null !== $this->isReadableProvider) { 259 | return ($this->isReadableProvider)($fileOrDirectory, $default); 260 | } 261 | 262 | return $default($fileOrDirectory); 263 | } 264 | 265 | public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void 266 | { 267 | if ($input->mustSuggestOptionValuesFor('format')) { 268 | $suggestions->suggestValues($this->getAvailableFormatOptions()); 269 | } 270 | } 271 | 272 | /** @return string[] */ 273 | private function getAvailableFormatOptions(): array 274 | { 275 | return ['txt', 'json', 'github']; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /Inline.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml; 13 | 14 | use Symfony\Component\Yaml\Exception\DumpException; 15 | use Symfony\Component\Yaml\Exception\ParseException; 16 | use Symfony\Component\Yaml\Tag\TaggedValue; 17 | 18 | /** 19 | * Inline implements a YAML parser/dumper for the YAML inline syntax. 20 | * 21 | * @author Fabien Potencier 22 | * 23 | * @internal 24 | */ 25 | class Inline 26 | { 27 | public const REGEX_QUOTED_STRING = '(?:"([^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+)"|\'([^\']*+(?:\'\'[^\']*+)*+)\')'; 28 | 29 | public static int $parsedLineNumber = -1; 30 | public static ?string $parsedFilename = null; 31 | 32 | private static bool $exceptionOnInvalidType = false; 33 | private static bool $objectSupport = false; 34 | private static bool $objectForMap = false; 35 | private static bool $constantSupport = false; 36 | 37 | public static function initialize(int $flags, ?int $parsedLineNumber = null, ?string $parsedFilename = null): void 38 | { 39 | self::$exceptionOnInvalidType = (bool) (Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE & $flags); 40 | self::$objectSupport = (bool) (Yaml::PARSE_OBJECT & $flags); 41 | self::$objectForMap = (bool) (Yaml::PARSE_OBJECT_FOR_MAP & $flags); 42 | self::$constantSupport = (bool) (Yaml::PARSE_CONSTANT & $flags); 43 | self::$parsedFilename = $parsedFilename; 44 | 45 | if (null !== $parsedLineNumber) { 46 | self::$parsedLineNumber = $parsedLineNumber; 47 | } 48 | } 49 | 50 | /** 51 | * Converts a YAML string to a PHP value. 52 | * 53 | * @param int $flags A bit field of Yaml::PARSE_* constants to customize the YAML parser behavior 54 | * @param array $references Mapping of variable names to values 55 | * 56 | * @throws ParseException 57 | */ 58 | public static function parse(string $value, int $flags = 0, array &$references = []): mixed 59 | { 60 | self::initialize($flags); 61 | 62 | $value = trim($value); 63 | 64 | if ('' === $value) { 65 | return ''; 66 | } 67 | 68 | $i = 0; 69 | $tag = self::parseTag($value, $i, $flags); 70 | switch ($value[$i]) { 71 | case '[': 72 | $result = self::parseSequence($value, $flags, $i, $references); 73 | ++$i; 74 | break; 75 | case '{': 76 | $result = self::parseMapping($value, $flags, $i, $references); 77 | ++$i; 78 | break; 79 | default: 80 | $result = self::parseScalar($value, $flags, null, $i, true, $references); 81 | } 82 | 83 | // some comments are allowed at the end 84 | if (preg_replace('/\s*#.*$/A', '', substr($value, $i))) { 85 | throw new ParseException(\sprintf('Unexpected characters near "%s".', substr($value, $i)), self::$parsedLineNumber + 1, $value, self::$parsedFilename); 86 | } 87 | 88 | if (null !== $tag && '' !== $tag) { 89 | return new TaggedValue($tag, $result); 90 | } 91 | 92 | return $result; 93 | } 94 | 95 | /** 96 | * Dumps a given PHP variable to a YAML string. 97 | * 98 | * @param mixed $value The PHP variable to convert 99 | * @param int $flags A bit field of Yaml::DUMP_* constants to customize the dumped YAML string 100 | * 101 | * @throws DumpException When trying to dump PHP resource 102 | */ 103 | public static function dump(mixed $value, int $flags = 0, bool $rootLevel = false): string 104 | { 105 | switch (true) { 106 | case \is_resource($value): 107 | if (Yaml::DUMP_EXCEPTION_ON_INVALID_TYPE & $flags) { 108 | throw new DumpException(\sprintf('Unable to dump PHP resources in a YAML file ("%s").', get_resource_type($value))); 109 | } 110 | 111 | return self::dumpNull($flags); 112 | case $value instanceof \DateTimeInterface: 113 | return $value->format(match (true) { 114 | !$length = \strlen(rtrim($value->format('u'), '0')) => 'c', 115 | $length < 4 => 'Y-m-d\TH:i:s.vP', 116 | default => 'Y-m-d\TH:i:s.uP', 117 | }); 118 | case $value instanceof \UnitEnum: 119 | return \sprintf('!php/enum %s::%s', $value::class, $value->name); 120 | case \is_object($value): 121 | if ($value instanceof TaggedValue) { 122 | return '!'.$value->getTag().' '.self::dump($value->getValue(), $flags); 123 | } 124 | 125 | if (Yaml::DUMP_OBJECT & $flags) { 126 | return '!php/object '.self::dump(serialize($value)); 127 | } 128 | 129 | if (Yaml::DUMP_OBJECT_AS_MAP & $flags && ($value instanceof \stdClass || $value instanceof \ArrayObject)) { 130 | return self::dumpHashArray($value, $flags); 131 | } 132 | 133 | if (Yaml::DUMP_EXCEPTION_ON_INVALID_TYPE & $flags) { 134 | throw new DumpException('Object support when dumping a YAML file has been disabled.'); 135 | } 136 | 137 | return self::dumpNull($flags); 138 | case \is_array($value): 139 | return self::dumpArray($value, $flags); 140 | case null === $value: 141 | return self::dumpNull($flags, $rootLevel); 142 | case true === $value: 143 | return 'true'; 144 | case false === $value: 145 | return 'false'; 146 | case \is_int($value): 147 | return $value; 148 | case is_numeric($value) && false === strpbrk($value, "\f\n\r\t\v"): 149 | $locale = setlocale(\LC_NUMERIC, 0); 150 | if (false !== $locale) { 151 | setlocale(\LC_NUMERIC, 'C'); 152 | } 153 | if (\is_float($value)) { 154 | $repr = (string) $value; 155 | if (is_infinite($value)) { 156 | $repr = str_ireplace('INF', '.Inf', $repr); 157 | } elseif (floor($value) == $value && $repr == $value) { 158 | // Preserve float data type since storing a whole number will result in integer value. 159 | if (!str_contains($repr, 'E')) { 160 | $repr .= '.0'; 161 | } 162 | } 163 | } else { 164 | $repr = \is_string($value) ? "'$value'" : (string) $value; 165 | } 166 | if (false !== $locale) { 167 | setlocale(\LC_NUMERIC, $locale); 168 | } 169 | 170 | return $repr; 171 | case '' == $value: 172 | return "''"; 173 | case self::isBinaryString($value): 174 | return '!!binary '.base64_encode($value); 175 | case Escaper::requiresDoubleQuoting($value): 176 | case Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES & $flags: 177 | return Escaper::escapeWithDoubleQuotes($value); 178 | case Escaper::requiresSingleQuoting($value): 179 | $singleQuoted = Escaper::escapeWithSingleQuotes($value); 180 | if (!str_contains($value, "'")) { 181 | return $singleQuoted; 182 | } 183 | // Attempt double-quoting the string instead to see if it's more efficient. 184 | $doubleQuoted = Escaper::escapeWithDoubleQuotes($value); 185 | 186 | return \strlen($doubleQuoted) < \strlen($singleQuoted) ? $doubleQuoted : $singleQuoted; 187 | case Parser::preg_match('{^[0-9]+[_0-9]*$}', $value): 188 | case Parser::preg_match(self::getHexRegex(), $value): 189 | case Parser::preg_match(self::getTimestampRegex(), $value): 190 | return Escaper::escapeWithSingleQuotes($value); 191 | default: 192 | return $value; 193 | } 194 | } 195 | 196 | /** 197 | * Check if given array is hash or just normal indexed array. 198 | */ 199 | public static function isHash(array|\ArrayObject|\stdClass $value): bool 200 | { 201 | if ($value instanceof \stdClass || $value instanceof \ArrayObject) { 202 | return true; 203 | } 204 | 205 | $expectedKey = 0; 206 | 207 | foreach ($value as $key => $val) { 208 | if ($key !== $expectedKey++) { 209 | return true; 210 | } 211 | } 212 | 213 | return false; 214 | } 215 | 216 | /** 217 | * Dumps a PHP array to a YAML string. 218 | * 219 | * @param array $value The PHP array to dump 220 | * @param int $flags A bit field of Yaml::DUMP_* constants to customize the dumped YAML string 221 | */ 222 | private static function dumpArray(array $value, int $flags): string 223 | { 224 | // array 225 | if (($value || Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE & $flags) && !self::isHash($value)) { 226 | $output = []; 227 | foreach ($value as $val) { 228 | $output[] = self::dump($val, $flags); 229 | } 230 | 231 | return \sprintf('[%s]', implode(', ', $output)); 232 | } 233 | 234 | return self::dumpHashArray($value, $flags); 235 | } 236 | 237 | /** 238 | * Dumps hash array to a YAML string. 239 | * 240 | * @param array|\ArrayObject|\stdClass $value The hash array to dump 241 | * @param int $flags A bit field of Yaml::DUMP_* constants to customize the dumped YAML string 242 | */ 243 | private static function dumpHashArray(array|\ArrayObject|\stdClass $value, int $flags): string 244 | { 245 | $output = []; 246 | $keyFlags = $flags & ~Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES; 247 | foreach ($value as $key => $val) { 248 | if (\is_int($key) && Yaml::DUMP_NUMERIC_KEY_AS_STRING & $flags) { 249 | $key = (string) $key; 250 | } 251 | 252 | $output[] = \sprintf('%s: %s', self::dump($key, $keyFlags), self::dump($val, $flags)); 253 | } 254 | 255 | return \sprintf('{ %s }', implode(', ', $output)); 256 | } 257 | 258 | private static function dumpNull(int $flags, bool $rootLevel = false): string 259 | { 260 | if (Yaml::DUMP_NULL_AS_TILDE & $flags) { 261 | return '~'; 262 | } 263 | 264 | if (Yaml::DUMP_NULL_AS_EMPTY & $flags && !$rootLevel) { 265 | return ''; 266 | } 267 | 268 | return 'null'; 269 | } 270 | 271 | /** 272 | * Parses a YAML scalar. 273 | * 274 | * @throws ParseException When malformed inline YAML string is parsed 275 | */ 276 | public static function parseScalar(string $scalar, int $flags = 0, ?array $delimiters = null, int &$i = 0, bool $evaluate = true, array &$references = [], ?bool &$isQuoted = null): mixed 277 | { 278 | if (\in_array($scalar[$i], ['"', "'"], true)) { 279 | // quoted scalar 280 | $isQuoted = true; 281 | $output = self::parseQuotedScalar($scalar, $i); 282 | 283 | if (null !== $delimiters) { 284 | $tmp = ltrim(substr($scalar, $i), " \n"); 285 | if ('' === $tmp) { 286 | throw new ParseException(\sprintf('Unexpected end of line, expected one of "%s".', implode('', $delimiters)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 287 | } 288 | if (!\in_array($tmp[0], $delimiters)) { 289 | throw new ParseException(\sprintf('Unexpected characters (%s).', substr($scalar, $i)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 290 | } 291 | } 292 | } else { 293 | // "normal" string 294 | $isQuoted = false; 295 | 296 | if (!$delimiters) { 297 | $output = substr($scalar, $i); 298 | $i += \strlen($output); 299 | 300 | // remove comments 301 | if (Parser::preg_match('/[ \t]+#/', $output, $match, \PREG_OFFSET_CAPTURE)) { 302 | $output = substr($output, 0, $match[0][1]); 303 | } 304 | } elseif (Parser::preg_match('/^(.*?)('.implode('|', $delimiters).')/', substr($scalar, $i), $match)) { 305 | $output = $match[1]; 306 | $i += \strlen($output); 307 | $output = trim($output); 308 | } else { 309 | throw new ParseException(\sprintf('Malformed inline YAML string: "%s".', $scalar), self::$parsedLineNumber + 1, null, self::$parsedFilename); 310 | } 311 | 312 | // a non-quoted string cannot start with @ or ` (reserved) nor with a scalar indicator (| or >) 313 | if ($output && ('@' === $output[0] || '`' === $output[0] || '|' === $output[0] || '>' === $output[0] || '%' === $output[0])) { 314 | throw new ParseException(\sprintf('The reserved indicator "%s" cannot start a plain scalar; you need to quote the scalar.', $output[0]), self::$parsedLineNumber + 1, $output, self::$parsedFilename); 315 | } 316 | 317 | if ($evaluate) { 318 | $output = self::evaluateScalar($output, $flags, $references, $isQuoted); 319 | } 320 | } 321 | 322 | return $output; 323 | } 324 | 325 | /** 326 | * Parses a YAML quoted scalar. 327 | * 328 | * @throws ParseException When malformed inline YAML string is parsed 329 | */ 330 | private static function parseQuotedScalar(string $scalar, int &$i = 0): string 331 | { 332 | if (!Parser::preg_match('/'.self::REGEX_QUOTED_STRING.'/Au', substr($scalar, $i), $match)) { 333 | throw new ParseException(\sprintf('Malformed inline YAML string: "%s".', substr($scalar, $i)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 334 | } 335 | 336 | $output = substr($match[0], 1, -1); 337 | 338 | $unescaper = new Unescaper(); 339 | if ('"' == $scalar[$i]) { 340 | $output = $unescaper->unescapeDoubleQuotedString($output); 341 | } else { 342 | $output = $unescaper->unescapeSingleQuotedString($output); 343 | } 344 | 345 | $i += \strlen($match[0]); 346 | 347 | return $output; 348 | } 349 | 350 | /** 351 | * Parses a YAML sequence. 352 | * 353 | * @throws ParseException When malformed inline YAML string is parsed 354 | */ 355 | private static function parseSequence(string $sequence, int $flags, int &$i = 0, array &$references = []): array 356 | { 357 | $output = []; 358 | $len = \strlen($sequence); 359 | ++$i; 360 | 361 | // [foo, bar, ...] 362 | $lastToken = null; 363 | while ($i < $len) { 364 | if (']' === $sequence[$i]) { 365 | return $output; 366 | } 367 | if (',' === $sequence[$i] || ' ' === $sequence[$i]) { 368 | if (',' === $sequence[$i] && (null === $lastToken || 'separator' === $lastToken)) { 369 | $output[] = null; 370 | } elseif (',' === $sequence[$i]) { 371 | $lastToken = 'separator'; 372 | } 373 | 374 | ++$i; 375 | 376 | continue; 377 | } 378 | 379 | $tag = self::parseTag($sequence, $i, $flags); 380 | switch ($sequence[$i]) { 381 | case '[': 382 | // nested sequence 383 | $value = self::parseSequence($sequence, $flags, $i, $references); 384 | break; 385 | case '{': 386 | // nested mapping 387 | $value = self::parseMapping($sequence, $flags, $i, $references); 388 | break; 389 | default: 390 | $value = self::parseScalar($sequence, $flags, [',', ']'], $i, null === $tag, $references, $isQuoted); 391 | 392 | // the value can be an array if a reference has been resolved to an array var 393 | if (\is_string($value) && !$isQuoted && str_contains($value, ': ')) { 394 | // embedded mapping? 395 | try { 396 | $pos = 0; 397 | $value = self::parseMapping('{'.$value.'}', $flags, $pos, $references); 398 | } catch (\InvalidArgumentException) { 399 | // no, it's not 400 | } 401 | } 402 | 403 | if (!$isQuoted && \is_string($value) && '' !== $value && '&' === $value[0] && Parser::preg_match(Parser::REFERENCE_PATTERN, $value, $matches)) { 404 | $references[$matches['ref']] = $matches['value']; 405 | $value = $matches['value']; 406 | } 407 | 408 | --$i; 409 | } 410 | 411 | if (null !== $tag && '' !== $tag) { 412 | $value = new TaggedValue($tag, $value); 413 | } 414 | 415 | $output[] = $value; 416 | 417 | $lastToken = 'value'; 418 | ++$i; 419 | } 420 | 421 | throw new ParseException(\sprintf('Malformed inline YAML string: "%s".', $sequence), self::$parsedLineNumber + 1, null, self::$parsedFilename); 422 | } 423 | 424 | /** 425 | * Parses a YAML mapping. 426 | * 427 | * @throws ParseException When malformed inline YAML string is parsed 428 | */ 429 | private static function parseMapping(string $mapping, int $flags, int &$i = 0, array &$references = []): array|\stdClass 430 | { 431 | $output = []; 432 | $len = \strlen($mapping); 433 | ++$i; 434 | $allowOverwrite = false; 435 | 436 | // {foo: bar, bar:foo, ...} 437 | while ($i < $len) { 438 | switch ($mapping[$i]) { 439 | case ' ': 440 | case ',': 441 | case "\n": 442 | ++$i; 443 | continue 2; 444 | case '}': 445 | if (self::$objectForMap) { 446 | return (object) $output; 447 | } 448 | 449 | return $output; 450 | } 451 | 452 | // key 453 | $offsetBeforeKeyParsing = $i; 454 | $isKeyQuoted = \in_array($mapping[$i], ['"', "'"], true); 455 | $key = self::parseScalar($mapping, $flags, [':', ' '], $i, false); 456 | 457 | if ($offsetBeforeKeyParsing === $i) { 458 | throw new ParseException('Missing mapping key.', self::$parsedLineNumber + 1, $mapping); 459 | } 460 | 461 | if ('!php/const' === $key || '!php/enum' === $key) { 462 | $key .= ' '.self::parseScalar($mapping, $flags, ['(?value')) { 671 | $enumName = substr($enumName, 0, -7); 672 | } 673 | 674 | if (!\defined($enumName)) { 675 | throw new ParseException(\sprintf('The string "%s" is not the name of a valid enum.', $enumName), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 676 | } 677 | 678 | $value = \constant($enumName); 679 | 680 | if (!$useValue) { 681 | return $value; 682 | } 683 | if (!$value instanceof \BackedEnum) { 684 | throw new ParseException(\sprintf('The enum "%s" defines no value next to its name.', $enumName), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 685 | } 686 | 687 | return $value->value; 688 | } 689 | if (self::$exceptionOnInvalidType) { 690 | throw new ParseException(\sprintf('The string "%s" could not be parsed as an enum. Did you forget to pass the "Yaml::PARSE_CONSTANT" flag to the parser?', $scalar), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 691 | } 692 | 693 | return null; 694 | case str_starts_with($scalar, '!!float '): 695 | return (float) substr($scalar, 8); 696 | case str_starts_with($scalar, '!!binary '): 697 | return self::evaluateBinaryScalar(substr($scalar, 9)); 698 | } 699 | 700 | throw new ParseException(\sprintf('The string "%s" could not be parsed as it uses an unsupported built-in tag.', $scalar), self::$parsedLineNumber, $scalar, self::$parsedFilename); 701 | case preg_match('/^(?:\+|-)?0o(?P[0-7_]++)$/', $scalar, $matches): 702 | $value = str_replace('_', '', $matches['value']); 703 | 704 | if ('-' === $scalar[0]) { 705 | return -octdec($value); 706 | } 707 | 708 | return octdec($value); 709 | case \in_array($scalar[0], ['+', '-', '.'], true) || is_numeric($scalar[0]): 710 | if (Parser::preg_match('{^[+-]?[0-9][0-9_]*$}', $scalar)) { 711 | $scalar = str_replace('_', '', $scalar); 712 | } 713 | 714 | switch (true) { 715 | case ctype_digit($scalar): 716 | case '-' === $scalar[0] && ctype_digit(substr($scalar, 1)): 717 | if ($scalar < \PHP_INT_MIN || \PHP_INT_MAX < $scalar) { 718 | return $scalar; 719 | } 720 | 721 | $cast = (int) $scalar; 722 | 723 | return ($scalar === (string) $cast) ? $cast : $scalar; 724 | case is_numeric($scalar): 725 | case Parser::preg_match(self::getHexRegex(), $scalar): 726 | $scalar = str_replace('_', '', $scalar); 727 | 728 | return '0x' === $scalar[0].$scalar[1] ? hexdec($scalar) : (float) $scalar; 729 | case '.inf' === $scalarLower: 730 | case '.nan' === $scalarLower: 731 | return -log(0); 732 | case '-.inf' === $scalarLower: 733 | return log(0); 734 | case Parser::preg_match('/^(-|\+)?[0-9][0-9_]*(\.[0-9_]+)?$/', $scalar): 735 | return (float) str_replace('_', '', $scalar); 736 | case Parser::preg_match(self::getTimestampRegex(), $scalar): 737 | try { 738 | // When no timezone is provided in the parsed date, YAML spec says we must assume UTC. 739 | $time = new \DateTimeImmutable($scalar, new \DateTimeZone('UTC')); 740 | } catch (\Exception $e) { 741 | // Some dates accepted by the regex are not valid dates. 742 | throw new ParseException(\sprintf('The date "%s" could not be parsed as it is an invalid date.', $scalar), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename, $e); 743 | } 744 | 745 | if (Yaml::PARSE_DATETIME & $flags) { 746 | return $time; 747 | } 748 | 749 | if ('' !== rtrim($time->format('u'), '0')) { 750 | return (float) $time->format('U.u'); 751 | } 752 | 753 | try { 754 | if (false !== $scalar = $time->getTimestamp()) { 755 | return $scalar; 756 | } 757 | } catch (\DateRangeError|\ValueError) { 758 | // no-op 759 | } 760 | 761 | return $time->format('U'); 762 | } 763 | } 764 | 765 | return (string) $scalar; 766 | } 767 | 768 | private static function parseTag(string $value, int &$i, int $flags): ?string 769 | { 770 | if ('!' !== $value[$i]) { 771 | return null; 772 | } 773 | 774 | $tagLength = strcspn($value, " \t\n[]{},", $i + 1); 775 | $tag = substr($value, $i + 1, $tagLength); 776 | 777 | $nextOffset = $i + $tagLength + 1; 778 | $nextOffset += strspn($value, ' ', $nextOffset); 779 | 780 | if ('' === $tag && (!isset($value[$nextOffset]) || \in_array($value[$nextOffset], [']', '}', ','], true))) { 781 | throw new ParseException('Using the unquoted scalar value "!" is not supported. You must quote it.', self::$parsedLineNumber + 1, $value, self::$parsedFilename); 782 | } 783 | 784 | // Is followed by a scalar and is a built-in tag 785 | if ('' !== $tag && (!isset($value[$nextOffset]) || !\in_array($value[$nextOffset], ['[', '{'], true)) && ('!' === $tag[0] || \in_array($tag, ['str', 'php/const', 'php/enum', 'php/object'], true))) { 786 | // Manage in {@link self::evaluateScalar()} 787 | return null; 788 | } 789 | 790 | $i = $nextOffset; 791 | 792 | // Built-in tags 793 | if ('' !== $tag && '!' === $tag[0]) { 794 | throw new ParseException(\sprintf('The built-in tag "!%s" is not implemented.', $tag), self::$parsedLineNumber + 1, $value, self::$parsedFilename); 795 | } 796 | 797 | if ('' !== $tag && !isset($value[$i])) { 798 | throw new ParseException(\sprintf('Missing value for tag "%s".', $tag), self::$parsedLineNumber + 1, $value, self::$parsedFilename); 799 | } 800 | 801 | if ('' === $tag || Yaml::PARSE_CUSTOM_TAGS & $flags) { 802 | return $tag; 803 | } 804 | 805 | throw new ParseException(\sprintf('Tags support is not enabled. Enable the "Yaml::PARSE_CUSTOM_TAGS" flag to use "!%s".', $tag), self::$parsedLineNumber + 1, $value, self::$parsedFilename); 806 | } 807 | 808 | public static function evaluateBinaryScalar(string $scalar): string 809 | { 810 | $parsedBinaryData = self::parseScalar(preg_replace('/\s/', '', $scalar)); 811 | 812 | if (0 !== (\strlen($parsedBinaryData) % 4)) { 813 | throw new ParseException(\sprintf('The normalized base64 encoded data (data without whitespace characters) length must be a multiple of four (%d bytes given).', \strlen($parsedBinaryData)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 814 | } 815 | 816 | if (!Parser::preg_match('#^[A-Z0-9+/]+={0,2}$#i', $parsedBinaryData)) { 817 | throw new ParseException(\sprintf('The base64 encoded data (%s) contains invalid characters.', $parsedBinaryData), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); 818 | } 819 | 820 | return base64_decode($parsedBinaryData, true); 821 | } 822 | 823 | private static function isBinaryString(string $value): bool 824 | { 825 | return !preg_match('//u', $value) || preg_match('/[^\x00\x07-\x0d\x1B\x20-\xff]/', $value); 826 | } 827 | 828 | /** 829 | * Gets a regex that matches a YAML date. 830 | * 831 | * @see http://www.yaml.org/spec/1.2/spec.html#id2761573 832 | */ 833 | private static function getTimestampRegex(): string 834 | { 835 | return <<[0-9][0-9][0-9][0-9]) 838 | -(?P[0-9][0-9]?) 839 | -(?P[0-9][0-9]?) 840 | (?:(?:[Tt]|[ \t]+) 841 | (?P[0-9][0-9]?) 842 | :(?P[0-9][0-9]) 843 | :(?P[0-9][0-9]) 844 | (?:\.(?P[0-9]*))? 845 | (?:[ \t]*(?PZ|(?P[-+])(?P[0-9][0-9]?) 846 | (?::(?P[0-9][0-9]))?))?)? 847 | $~x 848 | EOF; 849 | } 850 | 851 | /** 852 | * Gets a regex that matches a YAML number in hexadecimal notation. 853 | */ 854 | private static function getHexRegex(): string 855 | { 856 | return '~^0x[0-9a-f_]++$~i'; 857 | } 858 | } 859 | -------------------------------------------------------------------------------- /Parser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Yaml; 13 | 14 | use Symfony\Component\Yaml\Exception\ParseException; 15 | use Symfony\Component\Yaml\Tag\TaggedValue; 16 | 17 | /** 18 | * Parser parses YAML strings to convert them to PHP arrays. 19 | * 20 | * @author Fabien Potencier 21 | * 22 | * @final 23 | */ 24 | class Parser 25 | { 26 | public const TAG_PATTERN = '(?P![\w!.\/:-]+)'; 27 | public const BLOCK_SCALAR_HEADER_PATTERN = '(?P\||>)(?P\+|\-|\d+|\+\d+|\-\d+|\d+\+|\d+\-)?(?P +#.*)?'; 28 | public const REFERENCE_PATTERN = '#^&(?P[^ ]++) *+(?P.*)#u'; 29 | 30 | private ?string $filename = null; 31 | private int $offset = 0; 32 | private int $numberOfParsedLines = 0; 33 | private ?int $totalNumberOfLines = null; 34 | private array $lines = []; 35 | private int $currentLineNb = -1; 36 | private string $currentLine = ''; 37 | private array $refs = []; 38 | private array $skippedLineNumbers = []; 39 | private array $locallySkippedLineNumbers = []; 40 | private array $refsBeingParsed = []; 41 | 42 | /** 43 | * Parses a YAML file into a PHP value. 44 | * 45 | * @param string $filename The path to the YAML file to be parsed 46 | * @param int-mask-of $flags A bit field of Yaml::PARSE_* constants to customize the YAML parser behavior 47 | * 48 | * @throws ParseException If the file could not be read or the YAML is not valid 49 | */ 50 | public function parseFile(string $filename, int $flags = 0): mixed 51 | { 52 | if (!is_file($filename)) { 53 | throw new ParseException(\sprintf('File "%s" does not exist.', $filename)); 54 | } 55 | 56 | if (!is_readable($filename)) { 57 | throw new ParseException(\sprintf('File "%s" cannot be read.', $filename)); 58 | } 59 | 60 | $this->filename = $filename; 61 | 62 | try { 63 | return $this->parse(file_get_contents($filename), $flags); 64 | } finally { 65 | $this->filename = null; 66 | } 67 | } 68 | 69 | /** 70 | * Parses a YAML string to a PHP value. 71 | * 72 | * @param string $value A YAML string 73 | * @param int-mask-of $flags A bit field of Yaml::PARSE_* constants to customize the YAML parser behavior 74 | * 75 | * @throws ParseException If the YAML is not valid 76 | */ 77 | public function parse(string $value, int $flags = 0): mixed 78 | { 79 | if (false === preg_match('//u', $value)) { 80 | throw new ParseException('The YAML value does not appear to be valid UTF-8.', -1, null, $this->filename); 81 | } 82 | 83 | $this->refs = []; 84 | 85 | try { 86 | $data = $this->doParse($value, $flags); 87 | } finally { 88 | $this->refsBeingParsed = []; 89 | $this->offset = 0; 90 | $this->lines = []; 91 | $this->currentLine = ''; 92 | $this->numberOfParsedLines = 0; 93 | $this->refs = []; 94 | $this->skippedLineNumbers = []; 95 | $this->locallySkippedLineNumbers = []; 96 | $this->totalNumberOfLines = null; 97 | } 98 | 99 | return $data; 100 | } 101 | 102 | private function doParse(string $value, int $flags): mixed 103 | { 104 | $this->currentLineNb = -1; 105 | $this->currentLine = ''; 106 | $value = $this->cleanup($value); 107 | $this->lines = explode("\n", $value); 108 | $this->numberOfParsedLines = \count($this->lines); 109 | $this->locallySkippedLineNumbers = []; 110 | $this->totalNumberOfLines ??= $this->numberOfParsedLines; 111 | 112 | if (!$this->moveToNextLine()) { 113 | return null; 114 | } 115 | 116 | $data = []; 117 | $context = null; 118 | $allowOverwrite = false; 119 | 120 | while ($this->isCurrentLineEmpty()) { 121 | if (!$this->moveToNextLine()) { 122 | return null; 123 | } 124 | } 125 | 126 | // Resolves the tag and returns if end of the document 127 | if (null !== ($tag = $this->getLineTag($this->currentLine, $flags, false)) && !$this->moveToNextLine()) { 128 | return new TaggedValue($tag, ''); 129 | } 130 | 131 | do { 132 | if ($this->isCurrentLineEmpty()) { 133 | continue; 134 | } 135 | 136 | // tab? 137 | if ("\t" === $this->currentLine[0]) { 138 | throw new ParseException('A YAML file cannot contain tabs as indentation.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 139 | } 140 | 141 | Inline::initialize($flags, $this->getRealCurrentLineNb(), $this->filename); 142 | 143 | $isRef = $mergeNode = false; 144 | if ('-' === $this->currentLine[0] && self::preg_match('#^\-((?P\s+)(?P.+))?$#u', rtrim($this->currentLine), $values)) { 145 | if ($context && 'mapping' == $context) { 146 | throw new ParseException('You cannot define a sequence item when in a mapping.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 147 | } 148 | $context = 'sequence'; 149 | 150 | if (isset($values['value']) && '&' === $values['value'][0] && self::preg_match(self::REFERENCE_PATTERN, $values['value'], $matches)) { 151 | $isRef = $matches['ref']; 152 | $this->refsBeingParsed[] = $isRef; 153 | $values['value'] = $matches['value']; 154 | } 155 | 156 | if (isset($values['value'][1]) && '?' === $values['value'][0] && ' ' === $values['value'][1]) { 157 | throw new ParseException('Complex mappings are not supported.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 158 | } 159 | 160 | // array 161 | if (isset($values['value']) && str_starts_with(ltrim($values['value'], ' '), '-')) { 162 | // Inline first child 163 | $currentLineNumber = $this->getRealCurrentLineNb(); 164 | 165 | $sequenceIndentation = \strlen($values['leadspaces']) + 1; 166 | $sequenceYaml = substr($this->currentLine, $sequenceIndentation); 167 | $sequenceYaml .= "\n".$this->getNextEmbedBlock($sequenceIndentation, true); 168 | 169 | $data[] = $this->parseBlock($currentLineNumber, rtrim($sequenceYaml), $flags); 170 | } elseif (!isset($values['value']) || '' == trim($values['value'], ' ') || str_starts_with(ltrim($values['value'], ' '), '#')) { 171 | $data[] = $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(null, true) ?? '', $flags); 172 | } elseif (null !== $subTag = $this->getLineTag(ltrim($values['value'], ' '), $flags)) { 173 | $data[] = new TaggedValue( 174 | $subTag, 175 | $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(null, true), $flags) 176 | ); 177 | } else { 178 | if ( 179 | isset($values['leadspaces']) 180 | && ( 181 | '!' === $values['value'][0] 182 | || self::preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P.+?))?\s*$#u', $this->trimTag($values['value']), $matches) 183 | ) 184 | ) { 185 | $block = $values['value']; 186 | if ($this->isNextLineIndented() || isset($matches['value']) && '>-' === $matches['value']) { 187 | $block .= "\n".$this->getNextEmbedBlock($this->getCurrentLineIndentation() + \strlen($values['leadspaces']) + 1); 188 | } 189 | 190 | $data[] = $this->parseBlock($this->getRealCurrentLineNb(), $block, $flags); 191 | } else { 192 | $data[] = $this->parseValue($values['value'], $flags, $context); 193 | } 194 | } 195 | if ($isRef) { 196 | $this->refs[$isRef] = end($data); 197 | array_pop($this->refsBeingParsed); 198 | } 199 | } elseif ( 200 | self::preg_match('#^(?P(?:![^\s]++\s++)?(?:'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\[\{!].*?)) *\:(( |\t)++(?P.+))?$#u', rtrim($this->currentLine), $values) 201 | && (!str_contains($values['key'], ' #') || \in_array($values['key'][0], ['"', "'"])) 202 | ) { 203 | if ($context && 'sequence' == $context) { 204 | throw new ParseException('You cannot define a mapping item when in a sequence.', $this->currentLineNb + 1, $this->currentLine, $this->filename); 205 | } 206 | $context = 'mapping'; 207 | 208 | try { 209 | $key = Inline::parseScalar($values['key']); 210 | } catch (ParseException $e) { 211 | $e->setParsedLine($this->getRealCurrentLineNb() + 1); 212 | $e->setSnippet($this->currentLine); 213 | 214 | throw $e; 215 | } 216 | 217 | if (!\is_string($key) && !\is_int($key)) { 218 | throw new ParseException((is_numeric($key) ? 'Numeric' : 'Non-string').' keys are not supported. Quote your evaluable mapping keys instead.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 219 | } 220 | 221 | // Convert float keys to strings, to avoid being converted to integers by PHP 222 | if (\is_float($key)) { 223 | $key = (string) $key; 224 | } 225 | 226 | if ('<<' === $key && (!isset($values['value']) || '&' !== $values['value'][0] || !self::preg_match('#^&(?P[^ ]+)#u', $values['value'], $refMatches))) { 227 | $mergeNode = true; 228 | $allowOverwrite = true; 229 | if (isset($values['value'][0]) && '*' === $values['value'][0]) { 230 | $refName = substr(rtrim($values['value']), 1); 231 | if (!\array_key_exists($refName, $this->refs)) { 232 | if (false !== $pos = array_search($refName, $this->refsBeingParsed, true)) { 233 | throw new ParseException(\sprintf('Circular reference [%s] detected for reference "%s".', implode(', ', array_merge(\array_slice($this->refsBeingParsed, $pos), [$refName])), $refName), $this->currentLineNb + 1, $this->currentLine, $this->filename); 234 | } 235 | 236 | throw new ParseException(\sprintf('Reference "%s" does not exist.', $refName), $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 237 | } 238 | 239 | $refValue = $this->refs[$refName]; 240 | 241 | if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $refValue instanceof \stdClass) { 242 | $refValue = (array) $refValue; 243 | } 244 | 245 | if (!\is_array($refValue)) { 246 | throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 247 | } 248 | 249 | $data += $refValue; // array union 250 | } else { 251 | if (isset($values['value']) && '' !== $values['value']) { 252 | $value = $values['value']; 253 | } else { 254 | $value = $this->getNextEmbedBlock(); 255 | } 256 | $parsed = $this->parseBlock($this->getRealCurrentLineNb() + 1, $value, $flags); 257 | 258 | if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $parsed instanceof \stdClass) { 259 | $parsed = (array) $parsed; 260 | } 261 | 262 | if (!\is_array($parsed)) { 263 | throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 264 | } 265 | 266 | if (isset($parsed[0])) { 267 | // If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes 268 | // and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier 269 | // in the sequence override keys specified in later mapping nodes. 270 | foreach ($parsed as $parsedItem) { 271 | if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $parsedItem instanceof \stdClass) { 272 | $parsedItem = (array) $parsedItem; 273 | } 274 | 275 | if (!\is_array($parsedItem)) { 276 | throw new ParseException('Merge items must be arrays.', $this->getRealCurrentLineNb() + 1, $parsedItem, $this->filename); 277 | } 278 | 279 | $data += $parsedItem; // array union 280 | } 281 | } else { 282 | // If the value associated with the key is a single mapping node, each of its key/value pairs is inserted into the 283 | // current mapping, unless the key already exists in it. 284 | $data += $parsed; // array union 285 | } 286 | } 287 | } elseif ('<<' !== $key && isset($values['value']) && '&' === $values['value'][0] && self::preg_match(self::REFERENCE_PATTERN, $values['value'], $matches)) { 288 | $isRef = $matches['ref']; 289 | $this->refsBeingParsed[] = $isRef; 290 | $values['value'] = $matches['value']; 291 | } 292 | 293 | $subTag = null; 294 | if ($mergeNode) { 295 | // Merge keys 296 | } elseif (!isset($values['value']) || '' === $values['value'] || str_starts_with($values['value'], '#') || (null !== $subTag = $this->getLineTag($values['value'], $flags)) || '<<' === $key) { 297 | // hash 298 | // if next line is less indented or equal, then it means that the current value is null 299 | if (!$this->isNextLineIndented() && !$this->isNextLineUnIndentedCollection()) { 300 | // Spec: Keys MUST be unique; first one wins. 301 | // But overwriting is allowed when a merge node is used in current block. 302 | if ($allowOverwrite || !isset($data[$key])) { 303 | if (!$allowOverwrite && \array_key_exists($key, $data)) { 304 | trigger_deprecation('symfony/yaml', '7.2', 'Duplicate key "%s" detected on line %d whilst parsing YAML. Silent handling of duplicate mapping keys in YAML is deprecated and will throw a ParseException in 8.0.', $key, $this->getRealCurrentLineNb() + 1); 305 | } 306 | 307 | if (null !== $subTag) { 308 | $data[$key] = new TaggedValue($subTag, ''); 309 | } else { 310 | $data[$key] = null; 311 | } 312 | } else { 313 | throw new ParseException(\sprintf('Duplicate key "%s" detected.', $key), $this->getRealCurrentLineNb() + 1, $this->currentLine); 314 | } 315 | } else { 316 | // remember the parsed line number here in case we need it to provide some contexts in error messages below 317 | $realCurrentLineNbKey = $this->getRealCurrentLineNb(); 318 | $value = $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(), $flags); 319 | if ('<<' === $key) { 320 | $this->refs[$refMatches['ref']] = $value; 321 | 322 | if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $value instanceof \stdClass) { 323 | $value = (array) $value; 324 | } 325 | 326 | $data += $value; 327 | } elseif ($allowOverwrite || !isset($data[$key])) { 328 | if (!$allowOverwrite && \array_key_exists($key, $data)) { 329 | trigger_deprecation('symfony/yaml', '7.2', 'Duplicate key "%s" detected on line %d whilst parsing YAML. Silent handling of duplicate mapping keys in YAML is deprecated and will throw a ParseException in 8.0.', $key, $this->getRealCurrentLineNb() + 1); 330 | } 331 | 332 | // Spec: Keys MUST be unique; first one wins. 333 | // But overwriting is allowed when a merge node is used in current block. 334 | if (null !== $subTag) { 335 | $data[$key] = new TaggedValue($subTag, $value); 336 | } else { 337 | $data[$key] = $value; 338 | } 339 | } else { 340 | throw new ParseException(\sprintf('Duplicate key "%s" detected.', $key), $realCurrentLineNbKey + 1, $this->currentLine); 341 | } 342 | } 343 | } else { 344 | $value = $this->parseValue(rtrim($values['value']), $flags, $context); 345 | // Spec: Keys MUST be unique; first one wins. 346 | // But overwriting is allowed when a merge node is used in current block. 347 | if ($allowOverwrite || !isset($data[$key])) { 348 | if (!$allowOverwrite && \array_key_exists($key, $data)) { 349 | trigger_deprecation('symfony/yaml', '7.2', 'Duplicate key "%s" detected on line %d whilst parsing YAML. Silent handling of duplicate mapping keys in YAML is deprecated and will throw a ParseException in 8.0.', $key, $this->getRealCurrentLineNb() + 1); 350 | } 351 | 352 | $data[$key] = $value; 353 | } else { 354 | throw new ParseException(\sprintf('Duplicate key "%s" detected.', $key), $this->getRealCurrentLineNb() + 1, $this->currentLine); 355 | } 356 | } 357 | if ($isRef) { 358 | $this->refs[$isRef] = $data[$key]; 359 | array_pop($this->refsBeingParsed); 360 | } 361 | } elseif ('"' === $this->currentLine[0] || "'" === $this->currentLine[0]) { 362 | if (null !== $context) { 363 | throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 364 | } 365 | 366 | try { 367 | return Inline::parse($this->lexInlineQuotedString(), $flags, $this->refs); 368 | } catch (ParseException $e) { 369 | $e->setParsedLine($this->getRealCurrentLineNb() + 1); 370 | $e->setSnippet($this->currentLine); 371 | 372 | throw $e; 373 | } 374 | } elseif ('{' === $this->currentLine[0]) { 375 | if (null !== $context) { 376 | throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 377 | } 378 | 379 | try { 380 | $parsedMapping = Inline::parse($this->lexInlineMapping(), $flags, $this->refs); 381 | 382 | while ($this->moveToNextLine()) { 383 | if (!$this->isCurrentLineEmpty()) { 384 | throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 385 | } 386 | } 387 | 388 | return $parsedMapping; 389 | } catch (ParseException $e) { 390 | $e->setParsedLine($this->getRealCurrentLineNb() + 1); 391 | $e->setSnippet($this->currentLine); 392 | 393 | throw $e; 394 | } 395 | } elseif ('[' === $this->currentLine[0]) { 396 | if (null !== $context) { 397 | throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 398 | } 399 | 400 | try { 401 | $parsedSequence = Inline::parse($this->lexInlineSequence(), $flags, $this->refs); 402 | 403 | while ($this->moveToNextLine()) { 404 | if (!$this->isCurrentLineEmpty()) { 405 | throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 406 | } 407 | } 408 | 409 | return $parsedSequence; 410 | } catch (ParseException $e) { 411 | $e->setParsedLine($this->getRealCurrentLineNb() + 1); 412 | $e->setSnippet($this->currentLine); 413 | 414 | throw $e; 415 | } 416 | } else { 417 | // multiple documents are not supported 418 | if ('---' === $this->currentLine) { 419 | throw new ParseException('Multiple documents are not supported.', $this->currentLineNb + 1, $this->currentLine, $this->filename); 420 | } 421 | 422 | if (isset($this->currentLine[1]) && '?' === $this->currentLine[0] && ' ' === $this->currentLine[1]) { 423 | throw new ParseException('Complex mappings are not supported.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 424 | } 425 | 426 | // 1-liner optionally followed by newline(s) 427 | if (\is_string($value) && $this->lines[0] === trim($value)) { 428 | try { 429 | $value = Inline::parse($this->lines[0], $flags, $this->refs); 430 | } catch (ParseException $e) { 431 | $e->setParsedLine($this->getRealCurrentLineNb() + 1); 432 | $e->setSnippet($this->currentLine); 433 | 434 | throw $e; 435 | } 436 | 437 | return $value; 438 | } 439 | 440 | // try to parse the value as a multi-line string as a last resort 441 | if (0 === $this->currentLineNb) { 442 | $previousLineWasNewline = false; 443 | $previousLineWasTerminatedWithBackslash = false; 444 | $value = ''; 445 | 446 | foreach ($this->lines as $line) { 447 | $trimmedLine = trim($line); 448 | if ('#' === ($trimmedLine[0] ?? '')) { 449 | continue; 450 | } 451 | // If the indentation is not consistent at offset 0, it is to be considered as a ParseError 452 | if (0 === $this->offset && isset($line[0]) && ' ' === $line[0]) { 453 | throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 454 | } 455 | 456 | if (str_contains($line, ': ')) { 457 | throw new ParseException('Mapping values are not allowed in multi-line blocks.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 458 | } 459 | 460 | if ('' === $trimmedLine) { 461 | $value .= "\n"; 462 | } elseif (!$previousLineWasNewline && !$previousLineWasTerminatedWithBackslash) { 463 | $value .= ' '; 464 | } 465 | 466 | if ('' !== $trimmedLine && str_ends_with($line, '\\')) { 467 | $value .= ltrim(substr($line, 0, -1)); 468 | } elseif ('' !== $trimmedLine) { 469 | $value .= $trimmedLine; 470 | } 471 | 472 | if ('' === $trimmedLine) { 473 | $previousLineWasNewline = true; 474 | $previousLineWasTerminatedWithBackslash = false; 475 | } elseif (str_ends_with($line, '\\')) { 476 | $previousLineWasNewline = false; 477 | $previousLineWasTerminatedWithBackslash = true; 478 | } else { 479 | $previousLineWasNewline = false; 480 | $previousLineWasTerminatedWithBackslash = false; 481 | } 482 | } 483 | 484 | try { 485 | return Inline::parse(trim($value)); 486 | } catch (ParseException) { 487 | // fall-through to the ParseException thrown below 488 | } 489 | } 490 | 491 | throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 492 | } 493 | } while ($this->moveToNextLine()); 494 | 495 | if (null !== $tag) { 496 | $data = new TaggedValue($tag, $data); 497 | } 498 | 499 | if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && 'mapping' === $context && !\is_object($data)) { 500 | $object = new \stdClass(); 501 | 502 | foreach ($data as $key => $value) { 503 | $object->$key = $value; 504 | } 505 | 506 | $data = $object; 507 | } 508 | 509 | return $data ?: null; 510 | } 511 | 512 | private function parseBlock(int $offset, string $yaml, int $flags): mixed 513 | { 514 | $skippedLineNumbers = $this->skippedLineNumbers; 515 | 516 | foreach ($this->locallySkippedLineNumbers as $lineNumber) { 517 | if ($lineNumber < $offset) { 518 | continue; 519 | } 520 | 521 | $skippedLineNumbers[] = $lineNumber; 522 | } 523 | 524 | $parser = new self(); 525 | $parser->offset = $offset; 526 | $parser->totalNumberOfLines = $this->totalNumberOfLines; 527 | $parser->skippedLineNumbers = $skippedLineNumbers; 528 | $parser->refs = &$this->refs; 529 | $parser->refsBeingParsed = $this->refsBeingParsed; 530 | 531 | return $parser->doParse($yaml, $flags); 532 | } 533 | 534 | /** 535 | * Returns the current line number (takes the offset into account). 536 | * 537 | * @internal 538 | */ 539 | public function getRealCurrentLineNb(): int 540 | { 541 | $realCurrentLineNumber = $this->currentLineNb + $this->offset; 542 | 543 | foreach ($this->skippedLineNumbers as $skippedLineNumber) { 544 | if ($skippedLineNumber > $realCurrentLineNumber) { 545 | break; 546 | } 547 | 548 | ++$realCurrentLineNumber; 549 | } 550 | 551 | return $realCurrentLineNumber; 552 | } 553 | 554 | private function getCurrentLineIndentation(): int 555 | { 556 | if (' ' !== ($this->currentLine[0] ?? '')) { 557 | return 0; 558 | } 559 | 560 | return \strlen($this->currentLine) - \strlen(ltrim($this->currentLine, ' ')); 561 | } 562 | 563 | /** 564 | * Returns the next embed block of YAML. 565 | * 566 | * @param int|null $indentation The indent level at which the block is to be read, or null for default 567 | * @param bool $inSequence True if the enclosing data structure is a sequence 568 | * 569 | * @throws ParseException When indentation problem are detected 570 | */ 571 | private function getNextEmbedBlock(?int $indentation = null, bool $inSequence = false): string 572 | { 573 | $oldLineIndentation = $this->getCurrentLineIndentation(); 574 | 575 | if (!$this->moveToNextLine()) { 576 | return ''; 577 | } 578 | 579 | if (null === $indentation) { 580 | $newIndent = null; 581 | $movements = 0; 582 | 583 | do { 584 | $EOF = false; 585 | 586 | // empty and comment-like lines do not influence the indentation depth 587 | if ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()) { 588 | $EOF = !$this->moveToNextLine(); 589 | 590 | if (!$EOF) { 591 | ++$movements; 592 | } 593 | } else { 594 | $newIndent = $this->getCurrentLineIndentation(); 595 | } 596 | } while (!$EOF && null === $newIndent); 597 | 598 | for ($i = 0; $i < $movements; ++$i) { 599 | $this->moveToPreviousLine(); 600 | } 601 | 602 | $unindentedEmbedBlock = $this->isStringUnIndentedCollectionItem(); 603 | 604 | if (!$this->isCurrentLineEmpty() && 0 === $newIndent && !$unindentedEmbedBlock) { 605 | throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 606 | } 607 | } else { 608 | $newIndent = $indentation; 609 | } 610 | 611 | $data = []; 612 | 613 | if ($this->getCurrentLineIndentation() >= $newIndent) { 614 | $data[] = substr($this->currentLine, $newIndent ?? 0); 615 | } elseif ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()) { 616 | $data[] = $this->currentLine; 617 | } else { 618 | $this->moveToPreviousLine(); 619 | 620 | return ''; 621 | } 622 | 623 | if ($inSequence && $oldLineIndentation === $newIndent && isset($data[0][0]) && '-' === $data[0][0]) { 624 | // the previous line contained a dash but no item content, this line is a sequence item with the same indentation 625 | // and therefore no nested list or mapping 626 | $this->moveToPreviousLine(); 627 | 628 | return ''; 629 | } 630 | 631 | $isItUnindentedCollection = $this->isStringUnIndentedCollectionItem(); 632 | $isItComment = $this->isCurrentLineComment(); 633 | 634 | while ($this->moveToNextLine()) { 635 | if ($isItComment && !$isItUnindentedCollection) { 636 | $isItUnindentedCollection = $this->isStringUnIndentedCollectionItem(); 637 | $isItComment = $this->isCurrentLineComment(); 638 | } 639 | 640 | $indent = $this->getCurrentLineIndentation(); 641 | 642 | if ($isItUnindentedCollection && !$this->isCurrentLineEmpty() && !$this->isStringUnIndentedCollectionItem() && $newIndent === $indent) { 643 | $this->moveToPreviousLine(); 644 | break; 645 | } 646 | 647 | if ($this->isCurrentLineBlank()) { 648 | $data[] = substr($this->currentLine, $newIndent ?? 0); 649 | continue; 650 | } 651 | 652 | if ($indent >= $newIndent) { 653 | $data[] = substr($this->currentLine, $newIndent ?? 0); 654 | } elseif ($this->isCurrentLineComment()) { 655 | $data[] = $this->currentLine; 656 | } elseif (0 == $indent) { 657 | $this->moveToPreviousLine(); 658 | 659 | break; 660 | } else { 661 | throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 662 | } 663 | } 664 | 665 | return implode("\n", $data); 666 | } 667 | 668 | private function hasMoreLines(): bool 669 | { 670 | return (\count($this->lines) - 1) > $this->currentLineNb; 671 | } 672 | 673 | /** 674 | * Moves the parser to the next line. 675 | */ 676 | private function moveToNextLine(): bool 677 | { 678 | if ($this->currentLineNb >= $this->numberOfParsedLines - 1) { 679 | return false; 680 | } 681 | 682 | $this->currentLine = $this->lines[++$this->currentLineNb]; 683 | 684 | return true; 685 | } 686 | 687 | /** 688 | * Moves the parser to the previous line. 689 | */ 690 | private function moveToPreviousLine(): bool 691 | { 692 | if ($this->currentLineNb < 1) { 693 | return false; 694 | } 695 | 696 | $this->currentLine = $this->lines[--$this->currentLineNb]; 697 | 698 | return true; 699 | } 700 | 701 | /** 702 | * Parses a YAML value. 703 | * 704 | * @param string $value A YAML value 705 | * @param int $flags A bit field of Yaml::PARSE_* constants to customize the YAML parser behavior 706 | * @param string $context The parser context (either sequence or mapping) 707 | * 708 | * @throws ParseException When reference does not exist 709 | */ 710 | private function parseValue(string $value, int $flags, string $context): mixed 711 | { 712 | if (str_starts_with($value, '*')) { 713 | if (false !== $pos = strpos($value, '#')) { 714 | $value = substr($value, 1, $pos - 2); 715 | } else { 716 | $value = substr($value, 1); 717 | } 718 | 719 | if (!\array_key_exists($value, $this->refs)) { 720 | if (false !== $pos = array_search($value, $this->refsBeingParsed, true)) { 721 | throw new ParseException(\sprintf('Circular reference [%s] detected for reference "%s".', implode(', ', array_merge(\array_slice($this->refsBeingParsed, $pos), [$value])), $value), $this->currentLineNb + 1, $this->currentLine, $this->filename); 722 | } 723 | 724 | throw new ParseException(\sprintf('Reference "%s" does not exist.', $value), $this->currentLineNb + 1, $this->currentLine, $this->filename); 725 | } 726 | 727 | return $this->refs[$value]; 728 | } 729 | 730 | if (\in_array($value[0], ['!', '|', '>'], true) && self::preg_match('/^(?:'.self::TAG_PATTERN.' +)?'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value, $matches)) { 731 | $modifiers = $matches['modifiers'] ?? ''; 732 | 733 | $data = $this->parseBlockScalar($matches['separator'], preg_replace('#\d+#', '', $modifiers), abs((int) $modifiers)); 734 | 735 | if ('' !== $matches['tag'] && '!' !== $matches['tag']) { 736 | if ('!!binary' === $matches['tag']) { 737 | return Inline::evaluateBinaryScalar($data); 738 | } 739 | 740 | return new TaggedValue(substr($matches['tag'], 1), $data); 741 | } 742 | 743 | return $data; 744 | } 745 | 746 | try { 747 | if ('' !== $value && '{' === $value[0]) { 748 | $cursor = \strlen(rtrim($this->currentLine)) - \strlen(rtrim($value)); 749 | 750 | return Inline::parse($this->lexInlineMapping($cursor), $flags, $this->refs); 751 | } elseif ('' !== $value && '[' === $value[0]) { 752 | $cursor = \strlen(rtrim($this->currentLine)) - \strlen(rtrim($value)); 753 | 754 | return Inline::parse($this->lexInlineSequence($cursor), $flags, $this->refs); 755 | } 756 | 757 | switch ($value[0] ?? '') { 758 | case '"': 759 | case "'": 760 | $cursor = \strlen(rtrim($this->currentLine)) - \strlen(rtrim($value)); 761 | $parsedValue = Inline::parse($this->lexInlineQuotedString($cursor), $flags, $this->refs); 762 | 763 | if (isset($this->currentLine[$cursor]) && preg_replace('/\s*(#.*)?$/A', '', substr($this->currentLine, $cursor))) { 764 | throw new ParseException(\sprintf('Unexpected characters near "%s".', substr($this->currentLine, $cursor))); 765 | } 766 | 767 | return $parsedValue; 768 | default: 769 | $lines = []; 770 | 771 | while ($this->moveToNextLine()) { 772 | // unquoted strings end before the first unindented line 773 | if (0 === $this->getCurrentLineIndentation()) { 774 | $this->moveToPreviousLine(); 775 | 776 | break; 777 | } 778 | 779 | if ($this->isCurrentLineComment()) { 780 | break; 781 | } 782 | 783 | if ('mapping' === $context && str_contains($this->currentLine, ': ') && !$this->isCurrentLineComment()) { 784 | throw new ParseException('A colon cannot be used in an unquoted mapping value.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 785 | } 786 | 787 | $lines[] = trim($this->currentLine); 788 | } 789 | 790 | for ($i = 0, $linesCount = \count($lines), $previousLineBlank = false; $i < $linesCount; ++$i) { 791 | if ('' === $lines[$i]) { 792 | $value .= "\n"; 793 | $previousLineBlank = true; 794 | } elseif ($previousLineBlank) { 795 | $value .= $lines[$i]; 796 | $previousLineBlank = false; 797 | } else { 798 | $value .= ' '.$lines[$i]; 799 | $previousLineBlank = false; 800 | } 801 | } 802 | 803 | Inline::$parsedLineNumber = $this->getRealCurrentLineNb(); 804 | 805 | $parsedValue = Inline::parse($value, $flags, $this->refs); 806 | 807 | if ('mapping' === $context && \is_string($parsedValue) && '"' !== $value[0] && "'" !== $value[0] && '[' !== $value[0] && '{' !== $value[0] && '!' !== $value[0] && str_contains($parsedValue, ': ')) { 808 | throw new ParseException('A colon cannot be used in an unquoted mapping value.', $this->getRealCurrentLineNb() + 1, $value, $this->filename); 809 | } 810 | 811 | return $parsedValue; 812 | } 813 | } catch (ParseException $e) { 814 | $e->setParsedLine($this->getRealCurrentLineNb() + 1); 815 | $e->setSnippet($this->currentLine); 816 | 817 | throw $e; 818 | } 819 | } 820 | 821 | /** 822 | * Parses a block scalar. 823 | * 824 | * @param string $style The style indicator that was used to begin this block scalar (| or >) 825 | * @param string $chomping The chomping indicator that was used to begin this block scalar (+ or -) 826 | * @param int $indentation The indentation indicator that was used to begin this block scalar 827 | */ 828 | private function parseBlockScalar(string $style, string $chomping = '', int $indentation = 0): string 829 | { 830 | $notEOF = $this->moveToNextLine(); 831 | if (!$notEOF) { 832 | return ''; 833 | } 834 | 835 | $isCurrentLineBlank = $this->isCurrentLineBlank(); 836 | $blockLines = []; 837 | 838 | // leading blank lines are consumed before determining indentation 839 | while ($notEOF && $isCurrentLineBlank) { 840 | // newline only if not EOF 841 | if ($notEOF = $this->moveToNextLine()) { 842 | $blockLines[] = ''; 843 | $isCurrentLineBlank = $this->isCurrentLineBlank(); 844 | } 845 | } 846 | 847 | // determine indentation if not specified 848 | if (0 === $indentation) { 849 | $currentLineLength = \strlen($this->currentLine); 850 | 851 | for ($i = 0; $i < $currentLineLength && ' ' === $this->currentLine[$i]; ++$i) { 852 | ++$indentation; 853 | } 854 | } 855 | 856 | if ($indentation > 0) { 857 | $pattern = \sprintf('/^ {%d}(.*)$/', $indentation); 858 | 859 | while ( 860 | $notEOF && ( 861 | $isCurrentLineBlank 862 | || self::preg_match($pattern, $this->currentLine, $matches) 863 | ) 864 | ) { 865 | if ($isCurrentLineBlank && \strlen($this->currentLine) > $indentation) { 866 | $blockLines[] = substr($this->currentLine, $indentation); 867 | } elseif ($isCurrentLineBlank) { 868 | $blockLines[] = ''; 869 | } else { 870 | $blockLines[] = $matches[1]; 871 | } 872 | 873 | // newline only if not EOF 874 | if ($notEOF = $this->moveToNextLine()) { 875 | $isCurrentLineBlank = $this->isCurrentLineBlank(); 876 | } 877 | } 878 | } elseif ($notEOF) { 879 | $blockLines[] = ''; 880 | } 881 | 882 | if ($notEOF) { 883 | $blockLines[] = ''; 884 | $this->moveToPreviousLine(); 885 | } elseif (!$this->isCurrentLineLastLineInDocument()) { 886 | $blockLines[] = ''; 887 | } 888 | 889 | // folded style 890 | if ('>' === $style) { 891 | $text = ''; 892 | $previousLineIndented = false; 893 | $previousLineBlank = false; 894 | 895 | for ($i = 0, $blockLinesCount = \count($blockLines); $i < $blockLinesCount; ++$i) { 896 | if ('' === $blockLines[$i]) { 897 | $text .= "\n"; 898 | $previousLineIndented = false; 899 | $previousLineBlank = true; 900 | } elseif (' ' === $blockLines[$i][0]) { 901 | $text .= "\n".$blockLines[$i]; 902 | $previousLineIndented = true; 903 | $previousLineBlank = false; 904 | } elseif ($previousLineIndented) { 905 | $text .= "\n".$blockLines[$i]; 906 | $previousLineIndented = false; 907 | $previousLineBlank = false; 908 | } elseif ($previousLineBlank || 0 === $i) { 909 | $text .= $blockLines[$i]; 910 | $previousLineIndented = false; 911 | $previousLineBlank = false; 912 | } else { 913 | $text .= ' '.$blockLines[$i]; 914 | $previousLineIndented = false; 915 | $previousLineBlank = false; 916 | } 917 | } 918 | } else { 919 | $text = implode("\n", $blockLines); 920 | } 921 | 922 | // deal with trailing newlines 923 | if ('' === $chomping) { 924 | $text = preg_replace('/\n+$/', "\n", $text); 925 | } elseif ('-' === $chomping) { 926 | $text = preg_replace('/\n+$/', '', $text); 927 | } 928 | 929 | return $text; 930 | } 931 | 932 | /** 933 | * Returns true if the next line is indented. 934 | */ 935 | private function isNextLineIndented(): bool 936 | { 937 | $currentIndentation = $this->getCurrentLineIndentation(); 938 | $movements = 0; 939 | 940 | do { 941 | $EOF = !$this->moveToNextLine(); 942 | 943 | if (!$EOF) { 944 | ++$movements; 945 | } 946 | } while (!$EOF && ($this->isCurrentLineEmpty() || $this->isCurrentLineComment())); 947 | 948 | if ($EOF) { 949 | for ($i = 0; $i < $movements; ++$i) { 950 | $this->moveToPreviousLine(); 951 | } 952 | 953 | return false; 954 | } 955 | 956 | $ret = $this->getCurrentLineIndentation() > $currentIndentation; 957 | 958 | for ($i = 0; $i < $movements; ++$i) { 959 | $this->moveToPreviousLine(); 960 | } 961 | 962 | return $ret; 963 | } 964 | 965 | private function isCurrentLineEmpty(): bool 966 | { 967 | return $this->isCurrentLineBlank() || $this->isCurrentLineComment(); 968 | } 969 | 970 | private function isCurrentLineBlank(): bool 971 | { 972 | return '' === $this->currentLine || '' === trim($this->currentLine, ' '); 973 | } 974 | 975 | private function isCurrentLineComment(): bool 976 | { 977 | // checking explicitly the first char of the trim is faster than loops or strpos 978 | $ltrimmedLine = '' !== $this->currentLine && ' ' === $this->currentLine[0] ? ltrim($this->currentLine, ' ') : $this->currentLine; 979 | 980 | return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0]; 981 | } 982 | 983 | private function isCurrentLineLastLineInDocument(): bool 984 | { 985 | return ($this->offset + $this->currentLineNb) >= ($this->totalNumberOfLines - 1); 986 | } 987 | 988 | private function cleanup(string $value): string 989 | { 990 | $value = str_replace(["\r\n", "\r"], "\n", $value); 991 | 992 | // strip YAML header 993 | $count = 0; 994 | $value = preg_replace('#^\%YAML[: ][\d\.]+.*\n#u', '', $value, -1, $count); 995 | $this->offset += $count; 996 | 997 | // remove leading comments 998 | $trimmedValue = preg_replace('#^(\#.*?\n)+#s', '', $value, -1, $count); 999 | if (1 === $count) { 1000 | // items have been removed, update the offset 1001 | $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); 1002 | $value = $trimmedValue; 1003 | } 1004 | 1005 | // remove start of the document marker (---) 1006 | $trimmedValue = preg_replace('#^\-\-\-.*?\n#s', '', $value, -1, $count); 1007 | if (1 === $count) { 1008 | // items have been removed, update the offset 1009 | $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); 1010 | $value = $trimmedValue; 1011 | 1012 | // remove end of the document marker (...) 1013 | $value = preg_replace('#\.\.\.\s*$#', '', $value); 1014 | } 1015 | 1016 | return $value; 1017 | } 1018 | 1019 | private function isNextLineUnIndentedCollection(): bool 1020 | { 1021 | $currentIndentation = $this->getCurrentLineIndentation(); 1022 | $movements = 0; 1023 | 1024 | do { 1025 | $EOF = !$this->moveToNextLine(); 1026 | 1027 | if (!$EOF) { 1028 | ++$movements; 1029 | } 1030 | } while (!$EOF && ($this->isCurrentLineEmpty() || $this->isCurrentLineComment())); 1031 | 1032 | if ($EOF) { 1033 | return false; 1034 | } 1035 | 1036 | $ret = $this->getCurrentLineIndentation() === $currentIndentation && $this->isStringUnIndentedCollectionItem(); 1037 | 1038 | for ($i = 0; $i < $movements; ++$i) { 1039 | $this->moveToPreviousLine(); 1040 | } 1041 | 1042 | return $ret; 1043 | } 1044 | 1045 | private function isStringUnIndentedCollectionItem(): bool 1046 | { 1047 | return '-' === rtrim($this->currentLine) || str_starts_with($this->currentLine, '- '); 1048 | } 1049 | 1050 | /** 1051 | * A local wrapper for "preg_match" which will throw a ParseException if there 1052 | * is an internal error in the PCRE engine. 1053 | * 1054 | * This avoids us needing to check for "false" every time PCRE is used 1055 | * in the YAML engine 1056 | * 1057 | * @throws ParseException on a PCRE internal error 1058 | * 1059 | * @internal 1060 | */ 1061 | public static function preg_match(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int 1062 | { 1063 | if (false === $ret = preg_match($pattern, $subject, $matches, $flags, $offset)) { 1064 | throw new ParseException(preg_last_error_msg()); 1065 | } 1066 | 1067 | return $ret; 1068 | } 1069 | 1070 | /** 1071 | * Trim the tag on top of the value. 1072 | * 1073 | * Prevent values such as "!foo {quz: bar}" to be considered as 1074 | * a mapping block. 1075 | */ 1076 | private function trimTag(string $value): string 1077 | { 1078 | if ('!' === $value[0]) { 1079 | return ltrim(substr($value, 1, strcspn($value, " \r\n", 1)), ' '); 1080 | } 1081 | 1082 | return $value; 1083 | } 1084 | 1085 | private function getLineTag(string $value, int $flags, bool $nextLineCheck = true): ?string 1086 | { 1087 | if ('' === $value || '!' !== $value[0] || 1 !== self::preg_match('/^'.self::TAG_PATTERN.' *( +#.*)?$/', $value, $matches)) { 1088 | return null; 1089 | } 1090 | 1091 | if ($nextLineCheck && !$this->isNextLineIndented()) { 1092 | return null; 1093 | } 1094 | 1095 | $tag = substr($matches['tag'], 1); 1096 | 1097 | // Built-in tags 1098 | if ($tag && '!' === $tag[0]) { 1099 | throw new ParseException(\sprintf('The built-in tag "!%s" is not implemented.', $tag), $this->getRealCurrentLineNb() + 1, $value, $this->filename); 1100 | } 1101 | 1102 | if (Yaml::PARSE_CUSTOM_TAGS & $flags) { 1103 | return $tag; 1104 | } 1105 | 1106 | throw new ParseException(\sprintf('Tags support is not enabled. You must use the flag "Yaml::PARSE_CUSTOM_TAGS" to use "%s".', $matches['tag']), $this->getRealCurrentLineNb() + 1, $value, $this->filename); 1107 | } 1108 | 1109 | private function lexInlineQuotedString(int &$cursor = 0): string 1110 | { 1111 | $quotation = $this->currentLine[$cursor]; 1112 | $value = $quotation; 1113 | ++$cursor; 1114 | 1115 | $previousLineWasNewline = true; 1116 | $previousLineWasTerminatedWithBackslash = false; 1117 | $lineNumber = 0; 1118 | 1119 | do { 1120 | if (++$lineNumber > 1) { 1121 | $cursor += strspn($this->currentLine, ' ', $cursor); 1122 | } 1123 | 1124 | if ($this->isCurrentLineBlank()) { 1125 | $value .= "\n"; 1126 | } elseif (!$previousLineWasNewline && !$previousLineWasTerminatedWithBackslash) { 1127 | $value .= ' '; 1128 | } 1129 | 1130 | for (; \strlen($this->currentLine) > $cursor; ++$cursor) { 1131 | switch ($this->currentLine[$cursor]) { 1132 | case '\\': 1133 | if ("'" === $quotation) { 1134 | $value .= '\\'; 1135 | } elseif (isset($this->currentLine[++$cursor])) { 1136 | $value .= '\\'.$this->currentLine[$cursor]; 1137 | } 1138 | 1139 | break; 1140 | case $quotation: 1141 | ++$cursor; 1142 | 1143 | if ("'" === $quotation && isset($this->currentLine[$cursor]) && "'" === $this->currentLine[$cursor]) { 1144 | $value .= "''"; 1145 | break; 1146 | } 1147 | 1148 | return $value.$quotation; 1149 | default: 1150 | $value .= $this->currentLine[$cursor]; 1151 | } 1152 | } 1153 | 1154 | if ($this->isCurrentLineBlank()) { 1155 | $previousLineWasNewline = true; 1156 | $previousLineWasTerminatedWithBackslash = false; 1157 | } elseif ('\\' === $this->currentLine[-1]) { 1158 | $previousLineWasNewline = false; 1159 | $previousLineWasTerminatedWithBackslash = true; 1160 | } else { 1161 | $previousLineWasNewline = false; 1162 | $previousLineWasTerminatedWithBackslash = false; 1163 | } 1164 | 1165 | if ($this->hasMoreLines()) { 1166 | $cursor = 0; 1167 | } 1168 | } while ($this->moveToNextLine()); 1169 | 1170 | throw new ParseException('Malformed inline YAML string.'); 1171 | } 1172 | 1173 | private function lexUnquotedString(int &$cursor): string 1174 | { 1175 | $offset = $cursor; 1176 | 1177 | while ($cursor < \strlen($this->currentLine)) { 1178 | if (\in_array($this->currentLine[$cursor], ['[', ']', '{', '}', ',', ':'], true)) { 1179 | break; 1180 | } 1181 | 1182 | if (\in_array($this->currentLine[$cursor], [' ', "\t"], true) && '#' === ($this->currentLine[$cursor + 1] ?? '')) { 1183 | break; 1184 | } 1185 | 1186 | ++$cursor; 1187 | } 1188 | 1189 | if ($cursor === $offset) { 1190 | throw new ParseException('Malformed unquoted YAML string.'); 1191 | } 1192 | 1193 | return substr($this->currentLine, $offset, $cursor - $offset); 1194 | } 1195 | 1196 | private function lexInlineMapping(int &$cursor = 0, bool $consumeUntilEol = true): string 1197 | { 1198 | return $this->lexInlineStructure($cursor, '}', $consumeUntilEol); 1199 | } 1200 | 1201 | private function lexInlineSequence(int &$cursor = 0, bool $consumeUntilEol = true): string 1202 | { 1203 | return $this->lexInlineStructure($cursor, ']', $consumeUntilEol); 1204 | } 1205 | 1206 | private function lexInlineStructure(int &$cursor, string $closingTag, bool $consumeUntilEol = true): string 1207 | { 1208 | $value = $this->currentLine[$cursor]; 1209 | ++$cursor; 1210 | 1211 | do { 1212 | $this->consumeWhitespaces($cursor); 1213 | 1214 | while (isset($this->currentLine[$cursor])) { 1215 | switch ($this->currentLine[$cursor]) { 1216 | case '"': 1217 | case "'": 1218 | $value .= $this->lexInlineQuotedString($cursor); 1219 | break; 1220 | case ':': 1221 | case ',': 1222 | $value .= $this->currentLine[$cursor]; 1223 | ++$cursor; 1224 | break; 1225 | case '{': 1226 | $value .= $this->lexInlineMapping($cursor, false); 1227 | break; 1228 | case '[': 1229 | $value .= $this->lexInlineSequence($cursor, false); 1230 | break; 1231 | case $closingTag: 1232 | $value .= $this->currentLine[$cursor]; 1233 | ++$cursor; 1234 | 1235 | if ($consumeUntilEol && isset($this->currentLine[$cursor]) && ($whitespaces = strspn($this->currentLine, ' ', $cursor) + $cursor) < \strlen($this->currentLine) && '#' !== $this->currentLine[$whitespaces]) { 1236 | throw new ParseException(\sprintf('Unexpected token "%s".', trim(substr($this->currentLine, $cursor)))); 1237 | } 1238 | 1239 | return $value; 1240 | case '#': 1241 | break 2; 1242 | default: 1243 | $value .= $this->lexUnquotedString($cursor); 1244 | } 1245 | 1246 | if ($this->consumeWhitespaces($cursor)) { 1247 | $value .= ' '; 1248 | } 1249 | } 1250 | 1251 | if ($this->hasMoreLines()) { 1252 | $cursor = 0; 1253 | } 1254 | } while ($this->moveToNextLine()); 1255 | 1256 | throw new ParseException('Malformed inline YAML string.'); 1257 | } 1258 | 1259 | private function consumeWhitespaces(int &$cursor): bool 1260 | { 1261 | $whitespacesConsumed = 0; 1262 | 1263 | do { 1264 | $whitespaceOnlyTokenLength = strspn($this->currentLine, " \t", $cursor); 1265 | $whitespacesConsumed += $whitespaceOnlyTokenLength; 1266 | $cursor += $whitespaceOnlyTokenLength; 1267 | 1268 | if (isset($this->currentLine[$cursor])) { 1269 | return 0 < $whitespacesConsumed; 1270 | } 1271 | 1272 | if ($this->hasMoreLines()) { 1273 | $cursor = 0; 1274 | } 1275 | } while ($this->moveToNextLine()); 1276 | 1277 | return 0 < $whitespacesConsumed; 1278 | } 1279 | } 1280 | --------------------------------------------------------------------------------