1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Yaml; 13 14use Symfony\Component\Yaml\Exception\ParseException; 15use Symfony\Component\Yaml\Tag\TaggedValue; 16 17/** 18 * Parser parses YAML strings to convert them to PHP arrays. 19 * 20 * @author Fabien Potencier <fabien@symfony.com> 21 * 22 * @final 23 */ 24class Parser 25{ 26 const TAG_PATTERN = '(?P<tag>![\w!.\/:-]+)'; 27 const BLOCK_SCALAR_HEADER_PATTERN = '(?P<separator>\||>)(?P<modifiers>\+|\-|\d+|\+\d+|\-\d+|\d+\+|\d+\-)?(?P<comments> +#.*)?'; 28 29 private $filename; 30 private $offset = 0; 31 private $totalNumberOfLines; 32 private $lines = []; 33 private $currentLineNb = -1; 34 private $currentLine = ''; 35 private $refs = []; 36 private $skippedLineNumbers = []; 37 private $locallySkippedLineNumbers = []; 38 private $refsBeingParsed = []; 39 40 /** 41 * Parses a YAML file into a PHP value. 42 * 43 * @param string $filename The path to the YAML file to be parsed 44 * @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior 45 * 46 * @return mixed The YAML converted to a PHP value 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) 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 $flags A bit field of PARSE_* constants to customize the YAML parser behavior 74 * 75 * @return mixed A PHP value 76 * 77 * @throws ParseException If the YAML is not valid 78 */ 79 public function parse(string $value, int $flags = 0) 80 { 81 if (false === preg_match('//u', $value)) { 82 throw new ParseException('The YAML value does not appear to be valid UTF-8.', -1, null, $this->filename); 83 } 84 85 $this->refs = []; 86 87 $mbEncoding = null; 88 $data = null; 89 90 if (2 /* MB_OVERLOAD_STRING */ & (int) ini_get('mbstring.func_overload')) { 91 $mbEncoding = mb_internal_encoding(); 92 mb_internal_encoding('UTF-8'); 93 } 94 95 try { 96 $data = $this->doParse($value, $flags); 97 } finally { 98 if (null !== $mbEncoding) { 99 mb_internal_encoding($mbEncoding); 100 } 101 $this->lines = []; 102 $this->currentLine = ''; 103 $this->refs = []; 104 $this->skippedLineNumbers = []; 105 $this->locallySkippedLineNumbers = []; 106 } 107 108 return $data; 109 } 110 111 /** 112 * @internal 113 * 114 * @return int 115 */ 116 public function getLastLineNumberBeforeDeprecation(): int 117 { 118 return $this->getRealCurrentLineNb(); 119 } 120 121 private function doParse(string $value, int $flags) 122 { 123 $this->currentLineNb = -1; 124 $this->currentLine = ''; 125 $value = $this->cleanup($value); 126 $this->lines = explode("\n", $value); 127 $this->locallySkippedLineNumbers = []; 128 129 if (null === $this->totalNumberOfLines) { 130 $this->totalNumberOfLines = \count($this->lines); 131 } 132 133 if (!$this->moveToNextLine()) { 134 return null; 135 } 136 137 $data = []; 138 $context = null; 139 $allowOverwrite = false; 140 141 while ($this->isCurrentLineEmpty()) { 142 if (!$this->moveToNextLine()) { 143 return null; 144 } 145 } 146 147 // Resolves the tag and returns if end of the document 148 if (null !== ($tag = $this->getLineTag($this->currentLine, $flags, false)) && !$this->moveToNextLine()) { 149 return new TaggedValue($tag, ''); 150 } 151 152 do { 153 if ($this->isCurrentLineEmpty()) { 154 continue; 155 } 156 157 // tab? 158 if ("\t" === $this->currentLine[0]) { 159 throw new ParseException('A YAML file cannot contain tabs as indentation.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 160 } 161 162 Inline::initialize($flags, $this->getRealCurrentLineNb(), $this->filename); 163 164 $isRef = $mergeNode = false; 165 if ('-' === $this->currentLine[0] && self::preg_match('#^\-((?P<leadspaces>\s+)(?P<value>.+))?$#u', rtrim($this->currentLine), $values)) { 166 if ($context && 'mapping' == $context) { 167 throw new ParseException('You cannot define a sequence item when in a mapping', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 168 } 169 $context = 'sequence'; 170 171 if (isset($values['value']) && '&' === $values['value'][0] && self::preg_match('#^&(?P<ref>[^ ]+) *(?P<value>.*)#u', $values['value'], $matches)) { 172 $isRef = $matches['ref']; 173 $this->refsBeingParsed[] = $isRef; 174 $values['value'] = $matches['value']; 175 } 176 177 if (isset($values['value'][1]) && '?' === $values['value'][0] && ' ' === $values['value'][1]) { 178 throw new ParseException('Complex mappings are not supported.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 179 } 180 181 // array 182 if (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) { 183 $data[] = $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(null, true) ?? '', $flags); 184 } elseif (null !== $subTag = $this->getLineTag(ltrim($values['value'], ' '), $flags)) { 185 $data[] = new TaggedValue( 186 $subTag, 187 $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(null, true), $flags) 188 ); 189 } else { 190 if (isset($values['leadspaces']) 191 && self::preg_match('#^(?P<key>'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P<value>.+?))?\s*$#u', $this->trimTag($values['value']), $matches) 192 ) { 193 // this is a compact notation element, add to next block and parse 194 $block = $values['value']; 195 if ($this->isNextLineIndented()) { 196 $block .= "\n".$this->getNextEmbedBlock($this->getCurrentLineIndentation() + \strlen($values['leadspaces']) + 1); 197 } 198 199 $data[] = $this->parseBlock($this->getRealCurrentLineNb(), $block, $flags); 200 } else { 201 $data[] = $this->parseValue($values['value'], $flags, $context); 202 } 203 } 204 if ($isRef) { 205 $this->refs[$isRef] = end($data); 206 array_pop($this->refsBeingParsed); 207 } 208 } elseif ( 209 self::preg_match('#^(?P<key>(?:![^\s]++\s++)?(?:'.Inline::REGEX_QUOTED_STRING.'|(?:!?!php/const:)?[^ \'"\[\{!].*?)) *\:(\s++(?P<value>.+))?$#u', rtrim($this->currentLine), $values) 210 && (false === strpos($values['key'], ' #') || \in_array($values['key'][0], ['"', "'"])) 211 ) { 212 if ($context && 'sequence' == $context) { 213 throw new ParseException('You cannot define a mapping item when in a sequence', $this->currentLineNb + 1, $this->currentLine, $this->filename); 214 } 215 $context = 'mapping'; 216 217 try { 218 $key = Inline::parseScalar($values['key']); 219 } catch (ParseException $e) { 220 $e->setParsedLine($this->getRealCurrentLineNb() + 1); 221 $e->setSnippet($this->currentLine); 222 223 throw $e; 224 } 225 226 if (!\is_string($key) && !\is_int($key)) { 227 throw new ParseException(sprintf('%s keys are not supported. Quote your evaluable mapping keys instead.', is_numeric($key) ? 'Numeric' : 'Non-string'), $this->getRealCurrentLineNb() + 1, $this->currentLine); 228 } 229 230 // Convert float keys to strings, to avoid being converted to integers by PHP 231 if (\is_float($key)) { 232 $key = (string) $key; 233 } 234 235 if ('<<' === $key && (!isset($values['value']) || '&' !== $values['value'][0] || !self::preg_match('#^&(?P<ref>[^ ]+)#u', $values['value'], $refMatches))) { 236 $mergeNode = true; 237 $allowOverwrite = true; 238 if (isset($values['value'][0]) && '*' === $values['value'][0]) { 239 $refName = substr(rtrim($values['value']), 1); 240 if (!\array_key_exists($refName, $this->refs)) { 241 if (false !== $pos = array_search($refName, $this->refsBeingParsed, true)) { 242 throw new ParseException(sprintf('Circular reference [%s, %s] detected for reference "%s".', implode(', ', \array_slice($this->refsBeingParsed, $pos)), $refName, $refName), $this->currentLineNb + 1, $this->currentLine, $this->filename); 243 } 244 245 throw new ParseException(sprintf('Reference "%s" does not exist.', $refName), $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 246 } 247 248 $refValue = $this->refs[$refName]; 249 250 if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $refValue instanceof \stdClass) { 251 $refValue = (array) $refValue; 252 } 253 254 if (!\is_array($refValue)) { 255 throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 256 } 257 258 $data += $refValue; // array union 259 } else { 260 if (isset($values['value']) && '' !== $values['value']) { 261 $value = $values['value']; 262 } else { 263 $value = $this->getNextEmbedBlock(); 264 } 265 $parsed = $this->parseBlock($this->getRealCurrentLineNb() + 1, $value, $flags); 266 267 if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $parsed instanceof \stdClass) { 268 $parsed = (array) $parsed; 269 } 270 271 if (!\is_array($parsed)) { 272 throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 273 } 274 275 if (isset($parsed[0])) { 276 // If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes 277 // and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier 278 // in the sequence override keys specified in later mapping nodes. 279 foreach ($parsed as $parsedItem) { 280 if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $parsedItem instanceof \stdClass) { 281 $parsedItem = (array) $parsedItem; 282 } 283 284 if (!\is_array($parsedItem)) { 285 throw new ParseException('Merge items must be arrays.', $this->getRealCurrentLineNb() + 1, $parsedItem, $this->filename); 286 } 287 288 $data += $parsedItem; // array union 289 } 290 } else { 291 // If the value associated with the key is a single mapping node, each of its key/value pairs is inserted into the 292 // current mapping, unless the key already exists in it. 293 $data += $parsed; // array union 294 } 295 } 296 } elseif ('<<' !== $key && isset($values['value']) && '&' === $values['value'][0] && self::preg_match('#^&(?P<ref>[^ ]++) *+(?P<value>.*)#u', $values['value'], $matches)) { 297 $isRef = $matches['ref']; 298 $this->refsBeingParsed[] = $isRef; 299 $values['value'] = $matches['value']; 300 } 301 302 $subTag = null; 303 if ($mergeNode) { 304 // Merge keys 305 } elseif (!isset($values['value']) || '' === $values['value'] || 0 === strpos($values['value'], '#') || (null !== $subTag = $this->getLineTag($values['value'], $flags)) || '<<' === $key) { 306 // hash 307 // if next line is less indented or equal, then it means that the current value is null 308 if (!$this->isNextLineIndented() && !$this->isNextLineUnIndentedCollection()) { 309 // Spec: Keys MUST be unique; first one wins. 310 // But overwriting is allowed when a merge node is used in current block. 311 if ($allowOverwrite || !isset($data[$key])) { 312 if (null !== $subTag) { 313 $data[$key] = new TaggedValue($subTag, ''); 314 } else { 315 $data[$key] = null; 316 } 317 } else { 318 throw new ParseException(sprintf('Duplicate key "%s" detected.', $key), $this->getRealCurrentLineNb() + 1, $this->currentLine); 319 } 320 } else { 321 // remember the parsed line number here in case we need it to provide some contexts in error messages below 322 $realCurrentLineNbKey = $this->getRealCurrentLineNb(); 323 $value = $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(), $flags); 324 if ('<<' === $key) { 325 $this->refs[$refMatches['ref']] = $value; 326 327 if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $value instanceof \stdClass) { 328 $value = (array) $value; 329 } 330 331 $data += $value; 332 } elseif ($allowOverwrite || !isset($data[$key])) { 333 // Spec: Keys MUST be unique; first one wins. 334 // But overwriting is allowed when a merge node is used in current block. 335 if (null !== $subTag) { 336 $data[$key] = new TaggedValue($subTag, $value); 337 } else { 338 $data[$key] = $value; 339 } 340 } else { 341 throw new ParseException(sprintf('Duplicate key "%s" detected.', $key), $realCurrentLineNbKey + 1, $this->currentLine); 342 } 343 } 344 } else { 345 $value = $this->parseValue(rtrim($values['value']), $flags, $context); 346 // Spec: Keys MUST be unique; first one wins. 347 // But overwriting is allowed when a merge node is used in current block. 348 if ($allowOverwrite || !isset($data[$key])) { 349 $data[$key] = $value; 350 } else { 351 throw new ParseException(sprintf('Duplicate key "%s" detected.', $key), $this->getRealCurrentLineNb() + 1, $this->currentLine); 352 } 353 } 354 if ($isRef) { 355 $this->refs[$isRef] = $data[$key]; 356 array_pop($this->refsBeingParsed); 357 } 358 } else { 359 // multiple documents are not supported 360 if ('---' === $this->currentLine) { 361 throw new ParseException('Multiple documents are not supported.', $this->currentLineNb + 1, $this->currentLine, $this->filename); 362 } 363 364 if ($deprecatedUsage = (isset($this->currentLine[1]) && '?' === $this->currentLine[0] && ' ' === $this->currentLine[1])) { 365 throw new ParseException('Complex mappings are not supported.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 366 } 367 368 // 1-liner optionally followed by newline(s) 369 if (\is_string($value) && $this->lines[0] === trim($value)) { 370 try { 371 $value = Inline::parse($this->lines[0], $flags, $this->refs); 372 } catch (ParseException $e) { 373 $e->setParsedLine($this->getRealCurrentLineNb() + 1); 374 $e->setSnippet($this->currentLine); 375 376 throw $e; 377 } 378 379 return $value; 380 } 381 382 // try to parse the value as a multi-line string as a last resort 383 if (0 === $this->currentLineNb) { 384 $previousLineWasNewline = false; 385 $previousLineWasTerminatedWithBackslash = false; 386 $value = ''; 387 388 foreach ($this->lines as $line) { 389 // If the indentation is not consistent at offset 0, it is to be considered as a ParseError 390 if (0 === $this->offset && !$deprecatedUsage && isset($line[0]) && ' ' === $line[0]) { 391 throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 392 } 393 if ('' === trim($line)) { 394 $value .= "\n"; 395 } elseif (!$previousLineWasNewline && !$previousLineWasTerminatedWithBackslash) { 396 $value .= ' '; 397 } 398 399 if ('' !== trim($line) && '\\' === substr($line, -1)) { 400 $value .= ltrim(substr($line, 0, -1)); 401 } elseif ('' !== trim($line)) { 402 $value .= trim($line); 403 } 404 405 if ('' === trim($line)) { 406 $previousLineWasNewline = true; 407 $previousLineWasTerminatedWithBackslash = false; 408 } elseif ('\\' === substr($line, -1)) { 409 $previousLineWasNewline = false; 410 $previousLineWasTerminatedWithBackslash = true; 411 } else { 412 $previousLineWasNewline = false; 413 $previousLineWasTerminatedWithBackslash = false; 414 } 415 } 416 417 try { 418 return Inline::parse(trim($value)); 419 } catch (ParseException $e) { 420 // fall-through to the ParseException thrown below 421 } 422 } 423 424 throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 425 } 426 } while ($this->moveToNextLine()); 427 428 if (null !== $tag) { 429 $data = new TaggedValue($tag, $data); 430 } 431 432 if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && !\is_object($data) && 'mapping' === $context) { 433 $object = new \stdClass(); 434 435 foreach ($data as $key => $value) { 436 $object->$key = $value; 437 } 438 439 $data = $object; 440 } 441 442 return empty($data) ? null : $data; 443 } 444 445 private function parseBlock(int $offset, string $yaml, int $flags) 446 { 447 $skippedLineNumbers = $this->skippedLineNumbers; 448 449 foreach ($this->locallySkippedLineNumbers as $lineNumber) { 450 if ($lineNumber < $offset) { 451 continue; 452 } 453 454 $skippedLineNumbers[] = $lineNumber; 455 } 456 457 $parser = new self(); 458 $parser->offset = $offset; 459 $parser->totalNumberOfLines = $this->totalNumberOfLines; 460 $parser->skippedLineNumbers = $skippedLineNumbers; 461 $parser->refs = &$this->refs; 462 $parser->refsBeingParsed = $this->refsBeingParsed; 463 464 return $parser->doParse($yaml, $flags); 465 } 466 467 /** 468 * Returns the current line number (takes the offset into account). 469 * 470 * @internal 471 * 472 * @return int The current line number 473 */ 474 public function getRealCurrentLineNb(): int 475 { 476 $realCurrentLineNumber = $this->currentLineNb + $this->offset; 477 478 foreach ($this->skippedLineNumbers as $skippedLineNumber) { 479 if ($skippedLineNumber > $realCurrentLineNumber) { 480 break; 481 } 482 483 ++$realCurrentLineNumber; 484 } 485 486 return $realCurrentLineNumber; 487 } 488 489 /** 490 * Returns the current line indentation. 491 * 492 * @return int The current line indentation 493 */ 494 private function getCurrentLineIndentation(): int 495 { 496 return \strlen($this->currentLine) - \strlen(ltrim($this->currentLine, ' ')); 497 } 498 499 /** 500 * Returns the next embed block of YAML. 501 * 502 * @param int|null $indentation The indent level at which the block is to be read, or null for default 503 * @param bool $inSequence True if the enclosing data structure is a sequence 504 * 505 * @return string A YAML string 506 * 507 * @throws ParseException When indentation problem are detected 508 */ 509 private function getNextEmbedBlock(int $indentation = null, bool $inSequence = false): ?string 510 { 511 $oldLineIndentation = $this->getCurrentLineIndentation(); 512 513 if (!$this->moveToNextLine()) { 514 return null; 515 } 516 517 if (null === $indentation) { 518 $newIndent = null; 519 $movements = 0; 520 521 do { 522 $EOF = false; 523 524 // empty and comment-like lines do not influence the indentation depth 525 if ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()) { 526 $EOF = !$this->moveToNextLine(); 527 528 if (!$EOF) { 529 ++$movements; 530 } 531 } else { 532 $newIndent = $this->getCurrentLineIndentation(); 533 } 534 } while (!$EOF && null === $newIndent); 535 536 for ($i = 0; $i < $movements; ++$i) { 537 $this->moveToPreviousLine(); 538 } 539 540 $unindentedEmbedBlock = $this->isStringUnIndentedCollectionItem(); 541 542 if (!$this->isCurrentLineEmpty() && 0 === $newIndent && !$unindentedEmbedBlock) { 543 throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 544 } 545 } else { 546 $newIndent = $indentation; 547 } 548 549 $data = []; 550 if ($this->getCurrentLineIndentation() >= $newIndent) { 551 $data[] = substr($this->currentLine, $newIndent); 552 } elseif ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()) { 553 $data[] = $this->currentLine; 554 } else { 555 $this->moveToPreviousLine(); 556 557 return null; 558 } 559 560 if ($inSequence && $oldLineIndentation === $newIndent && isset($data[0][0]) && '-' === $data[0][0]) { 561 // the previous line contained a dash but no item content, this line is a sequence item with the same indentation 562 // and therefore no nested list or mapping 563 $this->moveToPreviousLine(); 564 565 return null; 566 } 567 568 $isItUnindentedCollection = $this->isStringUnIndentedCollectionItem(); 569 570 while ($this->moveToNextLine()) { 571 $indent = $this->getCurrentLineIndentation(); 572 573 if ($isItUnindentedCollection && !$this->isCurrentLineEmpty() && !$this->isStringUnIndentedCollectionItem() && $newIndent === $indent) { 574 $this->moveToPreviousLine(); 575 break; 576 } 577 578 if ($this->isCurrentLineBlank()) { 579 $data[] = substr($this->currentLine, $newIndent); 580 continue; 581 } 582 583 if ($indent >= $newIndent) { 584 $data[] = substr($this->currentLine, $newIndent); 585 } elseif ($this->isCurrentLineComment()) { 586 $data[] = $this->currentLine; 587 } elseif (0 == $indent) { 588 $this->moveToPreviousLine(); 589 590 break; 591 } else { 592 throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); 593 } 594 } 595 596 return implode("\n", $data); 597 } 598 599 /** 600 * Moves the parser to the next line. 601 * 602 * @return bool 603 */ 604 private function moveToNextLine(): bool 605 { 606 if ($this->currentLineNb >= \count($this->lines) - 1) { 607 return false; 608 } 609 610 $this->currentLine = $this->lines[++$this->currentLineNb]; 611 612 return true; 613 } 614 615 /** 616 * Moves the parser to the previous line. 617 * 618 * @return bool 619 */ 620 private function moveToPreviousLine(): bool 621 { 622 if ($this->currentLineNb < 1) { 623 return false; 624 } 625 626 $this->currentLine = $this->lines[--$this->currentLineNb]; 627 628 return true; 629 } 630 631 /** 632 * Parses a YAML value. 633 * 634 * @param string $value A YAML value 635 * @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior 636 * @param string $context The parser context (either sequence or mapping) 637 * 638 * @return mixed A PHP value 639 * 640 * @throws ParseException When reference does not exist 641 */ 642 private function parseValue(string $value, int $flags, string $context) 643 { 644 if (0 === strpos($value, '*')) { 645 if (false !== $pos = strpos($value, '#')) { 646 $value = substr($value, 1, $pos - 2); 647 } else { 648 $value = substr($value, 1); 649 } 650 651 if (!\array_key_exists($value, $this->refs)) { 652 if (false !== $pos = array_search($value, $this->refsBeingParsed, true)) { 653 throw new ParseException(sprintf('Circular reference [%s, %s] detected for reference "%s".', implode(', ', \array_slice($this->refsBeingParsed, $pos)), $value, $value), $this->currentLineNb + 1, $this->currentLine, $this->filename); 654 } 655 656 throw new ParseException(sprintf('Reference "%s" does not exist.', $value), $this->currentLineNb + 1, $this->currentLine, $this->filename); 657 } 658 659 return $this->refs[$value]; 660 } 661 662 if (\in_array($value[0], ['!', '|', '>'], true) && self::preg_match('/^(?:'.self::TAG_PATTERN.' +)?'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value, $matches)) { 663 $modifiers = isset($matches['modifiers']) ? $matches['modifiers'] : ''; 664 665 $data = $this->parseBlockScalar($matches['separator'], preg_replace('#\d+#', '', $modifiers), (int) abs($modifiers)); 666 667 if ('' !== $matches['tag'] && '!' !== $matches['tag']) { 668 if ('!!binary' === $matches['tag']) { 669 return Inline::evaluateBinaryScalar($data); 670 } 671 672 return new TaggedValue(substr($matches['tag'], 1), $data); 673 } 674 675 return $data; 676 } 677 678 try { 679 $quotation = '' !== $value && ('"' === $value[0] || "'" === $value[0]) ? $value[0] : null; 680 681 // do not take following lines into account when the current line is a quoted single line value 682 if (null !== $quotation && self::preg_match('/^'.$quotation.'.*'.$quotation.'(\s*#.*)?$/', $value)) { 683 return Inline::parse($value, $flags, $this->refs); 684 } 685 686 $lines = []; 687 688 while ($this->moveToNextLine()) { 689 // unquoted strings end before the first unindented line 690 if (null === $quotation && 0 === $this->getCurrentLineIndentation()) { 691 $this->moveToPreviousLine(); 692 693 break; 694 } 695 696 $lines[] = trim($this->currentLine); 697 698 // quoted string values end with a line that is terminated with the quotation character 699 if ('' !== $this->currentLine && substr($this->currentLine, -1) === $quotation) { 700 break; 701 } 702 } 703 704 for ($i = 0, $linesCount = \count($lines), $previousLineBlank = false; $i < $linesCount; ++$i) { 705 if ('' === $lines[$i]) { 706 $value .= "\n"; 707 $previousLineBlank = true; 708 } elseif ($previousLineBlank) { 709 $value .= $lines[$i]; 710 $previousLineBlank = false; 711 } else { 712 $value .= ' '.$lines[$i]; 713 $previousLineBlank = false; 714 } 715 } 716 717 Inline::$parsedLineNumber = $this->getRealCurrentLineNb(); 718 719 $parsedValue = Inline::parse($value, $flags, $this->refs); 720 721 if ('mapping' === $context && \is_string($parsedValue) && '"' !== $value[0] && "'" !== $value[0] && '[' !== $value[0] && '{' !== $value[0] && '!' !== $value[0] && false !== strpos($parsedValue, ': ')) { 722 throw new ParseException('A colon cannot be used in an unquoted mapping value.', $this->getRealCurrentLineNb() + 1, $value, $this->filename); 723 } 724 725 return $parsedValue; 726 } catch (ParseException $e) { 727 $e->setParsedLine($this->getRealCurrentLineNb() + 1); 728 $e->setSnippet($this->currentLine); 729 730 throw $e; 731 } 732 } 733 734 /** 735 * Parses a block scalar. 736 * 737 * @param string $style The style indicator that was used to begin this block scalar (| or >) 738 * @param string $chomping The chomping indicator that was used to begin this block scalar (+ or -) 739 * @param int $indentation The indentation indicator that was used to begin this block scalar 740 * 741 * @return string The text value 742 */ 743 private function parseBlockScalar(string $style, string $chomping = '', int $indentation = 0): string 744 { 745 $notEOF = $this->moveToNextLine(); 746 if (!$notEOF) { 747 return ''; 748 } 749 750 $isCurrentLineBlank = $this->isCurrentLineBlank(); 751 $blockLines = []; 752 753 // leading blank lines are consumed before determining indentation 754 while ($notEOF && $isCurrentLineBlank) { 755 // newline only if not EOF 756 if ($notEOF = $this->moveToNextLine()) { 757 $blockLines[] = ''; 758 $isCurrentLineBlank = $this->isCurrentLineBlank(); 759 } 760 } 761 762 // determine indentation if not specified 763 if (0 === $indentation) { 764 $currentLineLength = \strlen($this->currentLine); 765 766 for ($i = 0; $i < $currentLineLength && ' ' === $this->currentLine[$i]; ++$i) { 767 ++$indentation; 768 } 769 } 770 771 if ($indentation > 0) { 772 $pattern = sprintf('/^ {%d}(.*)$/', $indentation); 773 774 while ( 775 $notEOF && ( 776 $isCurrentLineBlank || 777 self::preg_match($pattern, $this->currentLine, $matches) 778 ) 779 ) { 780 if ($isCurrentLineBlank && \strlen($this->currentLine) > $indentation) { 781 $blockLines[] = substr($this->currentLine, $indentation); 782 } elseif ($isCurrentLineBlank) { 783 $blockLines[] = ''; 784 } else { 785 $blockLines[] = $matches[1]; 786 } 787 788 // newline only if not EOF 789 if ($notEOF = $this->moveToNextLine()) { 790 $isCurrentLineBlank = $this->isCurrentLineBlank(); 791 } 792 } 793 } elseif ($notEOF) { 794 $blockLines[] = ''; 795 } 796 797 if ($notEOF) { 798 $blockLines[] = ''; 799 $this->moveToPreviousLine(); 800 } elseif (!$notEOF && !$this->isCurrentLineLastLineInDocument()) { 801 $blockLines[] = ''; 802 } 803 804 // folded style 805 if ('>' === $style) { 806 $text = ''; 807 $previousLineIndented = false; 808 $previousLineBlank = false; 809 810 for ($i = 0, $blockLinesCount = \count($blockLines); $i < $blockLinesCount; ++$i) { 811 if ('' === $blockLines[$i]) { 812 $text .= "\n"; 813 $previousLineIndented = false; 814 $previousLineBlank = true; 815 } elseif (' ' === $blockLines[$i][0]) { 816 $text .= "\n".$blockLines[$i]; 817 $previousLineIndented = true; 818 $previousLineBlank = false; 819 } elseif ($previousLineIndented) { 820 $text .= "\n".$blockLines[$i]; 821 $previousLineIndented = false; 822 $previousLineBlank = false; 823 } elseif ($previousLineBlank || 0 === $i) { 824 $text .= $blockLines[$i]; 825 $previousLineIndented = false; 826 $previousLineBlank = false; 827 } else { 828 $text .= ' '.$blockLines[$i]; 829 $previousLineIndented = false; 830 $previousLineBlank = false; 831 } 832 } 833 } else { 834 $text = implode("\n", $blockLines); 835 } 836 837 // deal with trailing newlines 838 if ('' === $chomping) { 839 $text = preg_replace('/\n+$/', "\n", $text); 840 } elseif ('-' === $chomping) { 841 $text = preg_replace('/\n+$/', '', $text); 842 } 843 844 return $text; 845 } 846 847 /** 848 * Returns true if the next line is indented. 849 * 850 * @return bool Returns true if the next line is indented, false otherwise 851 */ 852 private function isNextLineIndented(): bool 853 { 854 $currentIndentation = $this->getCurrentLineIndentation(); 855 $movements = 0; 856 857 do { 858 $EOF = !$this->moveToNextLine(); 859 860 if (!$EOF) { 861 ++$movements; 862 } 863 } while (!$EOF && ($this->isCurrentLineEmpty() || $this->isCurrentLineComment())); 864 865 if ($EOF) { 866 return false; 867 } 868 869 $ret = $this->getCurrentLineIndentation() > $currentIndentation; 870 871 for ($i = 0; $i < $movements; ++$i) { 872 $this->moveToPreviousLine(); 873 } 874 875 return $ret; 876 } 877 878 /** 879 * Returns true if the current line is blank or if it is a comment line. 880 * 881 * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise 882 */ 883 private function isCurrentLineEmpty(): bool 884 { 885 return $this->isCurrentLineBlank() || $this->isCurrentLineComment(); 886 } 887 888 /** 889 * Returns true if the current line is blank. 890 * 891 * @return bool Returns true if the current line is blank, false otherwise 892 */ 893 private function isCurrentLineBlank(): bool 894 { 895 return '' == trim($this->currentLine, ' '); 896 } 897 898 /** 899 * Returns true if the current line is a comment line. 900 * 901 * @return bool Returns true if the current line is a comment line, false otherwise 902 */ 903 private function isCurrentLineComment(): bool 904 { 905 //checking explicitly the first char of the trim is faster than loops or strpos 906 $ltrimmedLine = ltrim($this->currentLine, ' '); 907 908 return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0]; 909 } 910 911 private function isCurrentLineLastLineInDocument(): bool 912 { 913 return ($this->offset + $this->currentLineNb) >= ($this->totalNumberOfLines - 1); 914 } 915 916 /** 917 * Cleanups a YAML string to be parsed. 918 * 919 * @param string $value The input YAML string 920 * 921 * @return string A cleaned up YAML string 922 */ 923 private function cleanup(string $value): string 924 { 925 $value = str_replace(["\r\n", "\r"], "\n", $value); 926 927 // strip YAML header 928 $count = 0; 929 $value = preg_replace('#^\%YAML[: ][\d\.]+.*\n#u', '', $value, -1, $count); 930 $this->offset += $count; 931 932 // remove leading comments 933 $trimmedValue = preg_replace('#^(\#.*?\n)+#s', '', $value, -1, $count); 934 if (1 === $count) { 935 // items have been removed, update the offset 936 $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); 937 $value = $trimmedValue; 938 } 939 940 // remove start of the document marker (---) 941 $trimmedValue = preg_replace('#^\-\-\-.*?\n#s', '', $value, -1, $count); 942 if (1 === $count) { 943 // items have been removed, update the offset 944 $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); 945 $value = $trimmedValue; 946 947 // remove end of the document marker (...) 948 $value = preg_replace('#\.\.\.\s*$#', '', $value); 949 } 950 951 return $value; 952 } 953 954 /** 955 * Returns true if the next line starts unindented collection. 956 * 957 * @return bool Returns true if the next line starts unindented collection, false otherwise 958 */ 959 private function isNextLineUnIndentedCollection(): bool 960 { 961 $currentIndentation = $this->getCurrentLineIndentation(); 962 $movements = 0; 963 964 do { 965 $EOF = !$this->moveToNextLine(); 966 967 if (!$EOF) { 968 ++$movements; 969 } 970 } while (!$EOF && ($this->isCurrentLineEmpty() || $this->isCurrentLineComment())); 971 972 if ($EOF) { 973 return false; 974 } 975 976 $ret = $this->getCurrentLineIndentation() === $currentIndentation && $this->isStringUnIndentedCollectionItem(); 977 978 for ($i = 0; $i < $movements; ++$i) { 979 $this->moveToPreviousLine(); 980 } 981 982 return $ret; 983 } 984 985 /** 986 * Returns true if the string is un-indented collection item. 987 * 988 * @return bool Returns true if the string is un-indented collection item, false otherwise 989 */ 990 private function isStringUnIndentedCollectionItem(): bool 991 { 992 return '-' === rtrim($this->currentLine) || 0 === strpos($this->currentLine, '- '); 993 } 994 995 /** 996 * A local wrapper for "preg_match" which will throw a ParseException if there 997 * is an internal error in the PCRE engine. 998 * 999 * This avoids us needing to check for "false" every time PCRE is used 1000 * in the YAML engine 1001 * 1002 * @throws ParseException on a PCRE internal error 1003 * 1004 * @see preg_last_error() 1005 * 1006 * @internal 1007 */ 1008 public static function preg_match(string $pattern, string $subject, array &$matches = null, int $flags = 0, int $offset = 0): int 1009 { 1010 if (false === $ret = preg_match($pattern, $subject, $matches, $flags, $offset)) { 1011 switch (preg_last_error()) { 1012 case PREG_INTERNAL_ERROR: 1013 $error = 'Internal PCRE error.'; 1014 break; 1015 case PREG_BACKTRACK_LIMIT_ERROR: 1016 $error = 'pcre.backtrack_limit reached.'; 1017 break; 1018 case PREG_RECURSION_LIMIT_ERROR: 1019 $error = 'pcre.recursion_limit reached.'; 1020 break; 1021 case PREG_BAD_UTF8_ERROR: 1022 $error = 'Malformed UTF-8 data.'; 1023 break; 1024 case PREG_BAD_UTF8_OFFSET_ERROR: 1025 $error = 'Offset doesn\'t correspond to the begin of a valid UTF-8 code point.'; 1026 break; 1027 default: 1028 $error = 'Error.'; 1029 } 1030 1031 throw new ParseException($error); 1032 } 1033 1034 return $ret; 1035 } 1036 1037 /** 1038 * Trim the tag on top of the value. 1039 * 1040 * Prevent values such as "!foo {quz: bar}" to be considered as 1041 * a mapping block. 1042 */ 1043 private function trimTag(string $value): string 1044 { 1045 if ('!' === $value[0]) { 1046 return ltrim(substr($value, 1, strcspn($value, " \r\n", 1)), ' '); 1047 } 1048 1049 return $value; 1050 } 1051 1052 private function getLineTag(string $value, int $flags, bool $nextLineCheck = true): ?string 1053 { 1054 if ('' === $value || '!' !== $value[0] || 1 !== self::preg_match('/^'.self::TAG_PATTERN.' *( +#.*)?$/', $value, $matches)) { 1055 return null; 1056 } 1057 1058 if ($nextLineCheck && !$this->isNextLineIndented()) { 1059 return null; 1060 } 1061 1062 $tag = substr($matches['tag'], 1); 1063 1064 // Built-in tags 1065 if ($tag && '!' === $tag[0]) { 1066 throw new ParseException(sprintf('The built-in tag "!%s" is not implemented.', $tag), $this->getRealCurrentLineNb() + 1, $value, $this->filename); 1067 } 1068 1069 if (Yaml::PARSE_CUSTOM_TAGS & $flags) { 1070 return $tag; 1071 } 1072 1073 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); 1074 } 1075} 1076