Parser.php (27412B)
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 12 namespace Symfony\Component\Yaml; 13 14 use Symfony\Component\Yaml\Exception\ParseException; 15 16 /** 17 * Parser parses YAML strings to convert them to PHP arrays. 18 * 19 * @author Fabien Potencier <fabien@symfony.com> 20 */ 21 class Parser 22 { 23 const BLOCK_SCALAR_HEADER_PATTERN = '(?P<separator>\||>)(?P<modifiers>\+|\-|\d+|\+\d+|\-\d+|\d+\+|\d+\-)?(?P<comments> +#.*)?'; 24 // BC - wrongly named 25 const FOLDED_SCALAR_PATTERN = self::BLOCK_SCALAR_HEADER_PATTERN; 26 27 private $offset = 0; 28 private $lines = array(); 29 private $currentLineNb = -1; 30 private $currentLine = ''; 31 private $refs = array(); 32 33 /** 34 * Constructor. 35 * 36 * @param int $offset The offset of YAML document (used for line numbers in error messages) 37 */ 38 public function __construct($offset = 0) 39 { 40 $this->offset = $offset; 41 } 42 43 /** 44 * Parses a YAML string to a PHP value. 45 * 46 * @param string $value A YAML string 47 * @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise 48 * @param bool $objectSupport true if object support is enabled, false otherwise 49 * @param bool $objectForMap true if maps should return a stdClass instead of array() 50 * 51 * @return mixed A PHP value 52 * 53 * @throws ParseException If the YAML is not valid 54 */ 55 public function parse($value, $exceptionOnInvalidType = false, $objectSupport = false, $objectForMap = false) 56 { 57 if (!preg_match('//u', $value)) { 58 throw new ParseException('The YAML value does not appear to be valid UTF-8.'); 59 } 60 $this->currentLineNb = -1; 61 $this->currentLine = ''; 62 $value = $this->cleanup($value); 63 $this->lines = explode("\n", $value); 64 65 if (function_exists('mb_internal_encoding') && ((int) ini_get('mbstring.func_overload')) & 2) { 66 $mbEncoding = mb_internal_encoding(); 67 mb_internal_encoding('UTF-8'); 68 } 69 70 $data = array(); 71 $context = null; 72 $allowOverwrite = false; 73 while ($this->moveToNextLine()) { 74 if ($this->isCurrentLineEmpty()) { 75 continue; 76 } 77 78 // tab? 79 if ("\t" === $this->currentLine[0]) { 80 throw new ParseException('A YAML file cannot contain tabs as indentation.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 81 } 82 83 $isRef = $mergeNode = false; 84 if (preg_match('#^\-((?P<leadspaces>\s+)(?P<value>.+?))?\s*$#u', $this->currentLine, $values)) { 85 if ($context && 'mapping' == $context) { 86 throw new ParseException('You cannot define a sequence item when in a mapping'); 87 } 88 $context = 'sequence'; 89 90 if (isset($values['value']) && preg_match('#^&(?P<ref>[^ ]+) *(?P<value>.*)#u', $values['value'], $matches)) { 91 $isRef = $matches['ref']; 92 $values['value'] = $matches['value']; 93 } 94 95 // array 96 if (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) { 97 $c = $this->getRealCurrentLineNb() + 1; 98 $parser = new self($c); 99 $parser->refs = &$this->refs; 100 $data[] = $parser->parse($this->getNextEmbedBlock(null, true), $exceptionOnInvalidType, $objectSupport, $objectForMap); 101 } else { 102 if (isset($values['leadspaces']) 103 && preg_match('#^(?P<key>'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P<value>.+?))?\s*$#u', $values['value'], $matches) 104 ) { 105 // this is a compact notation element, add to next block and parse 106 $c = $this->getRealCurrentLineNb(); 107 $parser = new self($c); 108 $parser->refs = &$this->refs; 109 110 $block = $values['value']; 111 if ($this->isNextLineIndented()) { 112 $block .= "\n".$this->getNextEmbedBlock($this->getCurrentLineIndentation() + strlen($values['leadspaces']) + 1); 113 } 114 115 $data[] = $parser->parse($block, $exceptionOnInvalidType, $objectSupport, $objectForMap); 116 } else { 117 $data[] = $this->parseValue($values['value'], $exceptionOnInvalidType, $objectSupport, $objectForMap); 118 } 119 } 120 if ($isRef) { 121 $this->refs[$isRef] = end($data); 122 } 123 } elseif (preg_match('#^(?P<key>'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\[\{].*?) *\:(\s+(?P<value>.+?))?\s*$#u', $this->currentLine, $values) && (false === strpos($values['key'], ' #') || in_array($values['key'][0], array('"', "'")))) { 124 if ($context && 'sequence' == $context) { 125 throw new ParseException('You cannot define a mapping item when in a sequence'); 126 } 127 $context = 'mapping'; 128 129 // force correct settings 130 Inline::parse(null, $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs); 131 try { 132 $key = Inline::parseScalar($values['key']); 133 } catch (ParseException $e) { 134 $e->setParsedLine($this->getRealCurrentLineNb() + 1); 135 $e->setSnippet($this->currentLine); 136 137 throw $e; 138 } 139 140 // Convert float keys to strings, to avoid being converted to integers by PHP 141 if (is_float($key)) { 142 $key = (string) $key; 143 } 144 145 if ('<<' === $key) { 146 $mergeNode = true; 147 $allowOverwrite = true; 148 if (isset($values['value']) && 0 === strpos($values['value'], '*')) { 149 $refName = substr($values['value'], 1); 150 if (!array_key_exists($refName, $this->refs)) { 151 throw new ParseException(sprintf('Reference "%s" does not exist.', $refName), $this->getRealCurrentLineNb() + 1, $this->currentLine); 152 } 153 154 $refValue = $this->refs[$refName]; 155 156 if (!is_array($refValue)) { 157 throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 158 } 159 160 foreach ($refValue as $key => $value) { 161 if (!isset($data[$key])) { 162 $data[$key] = $value; 163 } 164 } 165 } else { 166 if (isset($values['value']) && $values['value'] !== '') { 167 $value = $values['value']; 168 } else { 169 $value = $this->getNextEmbedBlock(); 170 } 171 $c = $this->getRealCurrentLineNb() + 1; 172 $parser = new self($c); 173 $parser->refs = &$this->refs; 174 $parsed = $parser->parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap); 175 176 if (!is_array($parsed)) { 177 throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 178 } 179 180 if (isset($parsed[0])) { 181 // If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes 182 // and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier 183 // in the sequence override keys specified in later mapping nodes. 184 foreach ($parsed as $parsedItem) { 185 if (!is_array($parsedItem)) { 186 throw new ParseException('Merge items must be arrays.', $this->getRealCurrentLineNb() + 1, $parsedItem); 187 } 188 189 foreach ($parsedItem as $key => $value) { 190 if (!isset($data[$key])) { 191 $data[$key] = $value; 192 } 193 } 194 } 195 } else { 196 // If the value associated with the key is a single mapping node, each of its key/value pairs is inserted into the 197 // current mapping, unless the key already exists in it. 198 foreach ($parsed as $key => $value) { 199 if (!isset($data[$key])) { 200 $data[$key] = $value; 201 } 202 } 203 } 204 } 205 } elseif (isset($values['value']) && preg_match('#^&(?P<ref>[^ ]+) *(?P<value>.*)#u', $values['value'], $matches)) { 206 $isRef = $matches['ref']; 207 $values['value'] = $matches['value']; 208 } 209 210 if ($mergeNode) { 211 // Merge keys 212 } elseif (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) { 213 // hash 214 // if next line is less indented or equal, then it means that the current value is null 215 if (!$this->isNextLineIndented() && !$this->isNextLineUnIndentedCollection()) { 216 // Spec: Keys MUST be unique; first one wins. 217 // But overwriting is allowed when a merge node is used in current block. 218 if ($allowOverwrite || !isset($data[$key])) { 219 $data[$key] = null; 220 } 221 } else { 222 $c = $this->getRealCurrentLineNb() + 1; 223 $parser = new self($c); 224 $parser->refs = &$this->refs; 225 $value = $parser->parse($this->getNextEmbedBlock(), $exceptionOnInvalidType, $objectSupport, $objectForMap); 226 // Spec: Keys MUST be unique; first one wins. 227 // But overwriting is allowed when a merge node is used in current block. 228 if ($allowOverwrite || !isset($data[$key])) { 229 $data[$key] = $value; 230 } 231 } 232 } else { 233 $value = $this->parseValue($values['value'], $exceptionOnInvalidType, $objectSupport, $objectForMap); 234 // Spec: Keys MUST be unique; first one wins. 235 // But overwriting is allowed when a merge node is used in current block. 236 if ($allowOverwrite || !isset($data[$key])) { 237 $data[$key] = $value; 238 } 239 } 240 if ($isRef) { 241 $this->refs[$isRef] = $data[$key]; 242 } 243 } else { 244 // multiple documents are not supported 245 if ('---' === $this->currentLine) { 246 throw new ParseException('Multiple documents are not supported.'); 247 } 248 249 // 1-liner optionally followed by newline(s) 250 if (is_string($value) && $this->lines[0] === trim($value)) { 251 try { 252 $value = Inline::parse($this->lines[0], $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs); 253 } catch (ParseException $e) { 254 $e->setParsedLine($this->getRealCurrentLineNb() + 1); 255 $e->setSnippet($this->currentLine); 256 257 throw $e; 258 } 259 260 if (is_array($value)) { 261 $first = reset($value); 262 if (is_string($first) && 0 === strpos($first, '*')) { 263 $data = array(); 264 foreach ($value as $alias) { 265 $data[] = $this->refs[substr($alias, 1)]; 266 } 267 $value = $data; 268 } 269 } 270 271 if (isset($mbEncoding)) { 272 mb_internal_encoding($mbEncoding); 273 } 274 275 return $value; 276 } 277 278 switch (preg_last_error()) { 279 case PREG_INTERNAL_ERROR: 280 $error = 'Internal PCRE error.'; 281 break; 282 case PREG_BACKTRACK_LIMIT_ERROR: 283 $error = 'pcre.backtrack_limit reached.'; 284 break; 285 case PREG_RECURSION_LIMIT_ERROR: 286 $error = 'pcre.recursion_limit reached.'; 287 break; 288 case PREG_BAD_UTF8_ERROR: 289 $error = 'Malformed UTF-8 data.'; 290 break; 291 case PREG_BAD_UTF8_OFFSET_ERROR: 292 $error = 'Offset doesn\'t correspond to the begin of a valid UTF-8 code point.'; 293 break; 294 default: 295 $error = 'Unable to parse.'; 296 } 297 298 throw new ParseException($error, $this->getRealCurrentLineNb() + 1, $this->currentLine); 299 } 300 } 301 302 if (isset($mbEncoding)) { 303 mb_internal_encoding($mbEncoding); 304 } 305 306 return empty($data) ? null : $data; 307 } 308 309 /** 310 * Returns the current line number (takes the offset into account). 311 * 312 * @return int The current line number 313 */ 314 private function getRealCurrentLineNb() 315 { 316 return $this->currentLineNb + $this->offset; 317 } 318 319 /** 320 * Returns the current line indentation. 321 * 322 * @return int The current line indentation 323 */ 324 private function getCurrentLineIndentation() 325 { 326 return strlen($this->currentLine) - strlen(ltrim($this->currentLine, ' ')); 327 } 328 329 /** 330 * Returns the next embed block of YAML. 331 * 332 * @param int $indentation The indent level at which the block is to be read, or null for default 333 * @param bool $inSequence True if the enclosing data structure is a sequence 334 * 335 * @return string A YAML string 336 * 337 * @throws ParseException When indentation problem are detected 338 */ 339 private function getNextEmbedBlock($indentation = null, $inSequence = false) 340 { 341 $oldLineIndentation = $this->getCurrentLineIndentation(); 342 343 if (!$this->moveToNextLine()) { 344 return; 345 } 346 347 if (null === $indentation) { 348 $newIndent = $this->getCurrentLineIndentation(); 349 350 $unindentedEmbedBlock = $this->isStringUnIndentedCollectionItem($this->currentLine); 351 352 if (!$this->isCurrentLineEmpty() && 0 === $newIndent && !$unindentedEmbedBlock) { 353 throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 354 } 355 } else { 356 $newIndent = $indentation; 357 } 358 359 $data = array(); 360 if ($this->getCurrentLineIndentation() >= $newIndent) { 361 $data[] = substr($this->currentLine, $newIndent); 362 } else { 363 $this->moveToPreviousLine(); 364 365 return; 366 } 367 368 if ($inSequence && $oldLineIndentation === $newIndent && isset($data[0][0]) && '-' === $data[0][0]) { 369 // the previous line contained a dash but no item content, this line is a sequence item with the same indentation 370 // and therefore no nested list or mapping 371 $this->moveToPreviousLine(); 372 373 return; 374 } 375 376 $isItUnindentedCollection = $this->isStringUnIndentedCollectionItem($this->currentLine); 377 378 // Comments must not be removed inside a block scalar 379 $removeCommentsPattern = '~'.self::BLOCK_SCALAR_HEADER_PATTERN.'$~'; 380 $removeComments = !preg_match($removeCommentsPattern, $this->currentLine); 381 382 while ($this->moveToNextLine()) { 383 $indent = $this->getCurrentLineIndentation(); 384 385 if ($indent === $newIndent) { 386 $removeComments = !preg_match($removeCommentsPattern, $this->currentLine); 387 } 388 389 if ($isItUnindentedCollection && !$this->isStringUnIndentedCollectionItem($this->currentLine) && $newIndent === $indent) { 390 $this->moveToPreviousLine(); 391 break; 392 } 393 394 if ($this->isCurrentLineBlank()) { 395 $data[] = substr($this->currentLine, $newIndent); 396 continue; 397 } 398 399 if ($removeComments && $this->isCurrentLineComment()) { 400 continue; 401 } 402 403 if ($indent >= $newIndent) { 404 $data[] = substr($this->currentLine, $newIndent); 405 } elseif (0 == $indent) { 406 $this->moveToPreviousLine(); 407 408 break; 409 } else { 410 throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine); 411 } 412 } 413 414 return implode("\n", $data); 415 } 416 417 /** 418 * Moves the parser to the next line. 419 * 420 * @return bool 421 */ 422 private function moveToNextLine() 423 { 424 if ($this->currentLineNb >= count($this->lines) - 1) { 425 return false; 426 } 427 428 $this->currentLine = $this->lines[++$this->currentLineNb]; 429 430 return true; 431 } 432 433 /** 434 * Moves the parser to the previous line. 435 */ 436 private function moveToPreviousLine() 437 { 438 $this->currentLine = $this->lines[--$this->currentLineNb]; 439 } 440 441 /** 442 * Parses a YAML value. 443 * 444 * @param string $value A YAML value 445 * @param bool $exceptionOnInvalidType True if an exception must be thrown on invalid types false otherwise 446 * @param bool $objectSupport True if object support is enabled, false otherwise 447 * @param bool $objectForMap true if maps should return a stdClass instead of array() 448 * 449 * @return mixed A PHP value 450 * 451 * @throws ParseException When reference does not exist 452 */ 453 private function parseValue($value, $exceptionOnInvalidType, $objectSupport, $objectForMap) 454 { 455 if (0 === strpos($value, '*')) { 456 if (false !== $pos = strpos($value, '#')) { 457 $value = substr($value, 1, $pos - 2); 458 } else { 459 $value = substr($value, 1); 460 } 461 462 if (!array_key_exists($value, $this->refs)) { 463 throw new ParseException(sprintf('Reference "%s" does not exist.', $value), $this->currentLine); 464 } 465 466 return $this->refs[$value]; 467 } 468 469 if (preg_match('/^'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value, $matches)) { 470 $modifiers = isset($matches['modifiers']) ? $matches['modifiers'] : ''; 471 472 return $this->parseBlockScalar($matches['separator'], preg_replace('#\d+#', '', $modifiers), (int) abs($modifiers)); 473 } 474 475 try { 476 return Inline::parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs); 477 } catch (ParseException $e) { 478 $e->setParsedLine($this->getRealCurrentLineNb() + 1); 479 $e->setSnippet($this->currentLine); 480 481 throw $e; 482 } 483 } 484 485 /** 486 * Parses a block scalar. 487 * 488 * @param string $style The style indicator that was used to begin this block scalar (| or >) 489 * @param string $chomping The chomping indicator that was used to begin this block scalar (+ or -) 490 * @param int $indentation The indentation indicator that was used to begin this block scalar 491 * 492 * @return string The text value 493 */ 494 private function parseBlockScalar($style, $chomping = '', $indentation = 0) 495 { 496 $notEOF = $this->moveToNextLine(); 497 if (!$notEOF) { 498 return ''; 499 } 500 501 $isCurrentLineBlank = $this->isCurrentLineBlank(); 502 $text = ''; 503 504 // leading blank lines are consumed before determining indentation 505 while ($notEOF && $isCurrentLineBlank) { 506 // newline only if not EOF 507 if ($notEOF = $this->moveToNextLine()) { 508 $text .= "\n"; 509 $isCurrentLineBlank = $this->isCurrentLineBlank(); 510 } 511 } 512 513 // determine indentation if not specified 514 if (0 === $indentation) { 515 if (preg_match('/^ +/', $this->currentLine, $matches)) { 516 $indentation = strlen($matches[0]); 517 } 518 } 519 520 if ($indentation > 0) { 521 $pattern = sprintf('/^ {%d}(.*)$/', $indentation); 522 523 while ( 524 $notEOF && ( 525 $isCurrentLineBlank || 526 preg_match($pattern, $this->currentLine, $matches) 527 ) 528 ) { 529 if ($isCurrentLineBlank) { 530 $text .= substr($this->currentLine, $indentation); 531 } else { 532 $text .= $matches[1]; 533 } 534 535 // newline only if not EOF 536 if ($notEOF = $this->moveToNextLine()) { 537 $text .= "\n"; 538 $isCurrentLineBlank = $this->isCurrentLineBlank(); 539 } 540 } 541 } elseif ($notEOF) { 542 $text .= "\n"; 543 } 544 545 if ($notEOF) { 546 $this->moveToPreviousLine(); 547 } 548 549 // folded style 550 if ('>' === $style) { 551 // folded lines 552 // replace all non-leading/non-trailing single newlines with spaces 553 preg_match('/(\n*)$/', $text, $matches); 554 $text = preg_replace('/(?<!\n|^)\n(?!\n)/', ' ', rtrim($text, "\n")); 555 $text .= $matches[1]; 556 557 // empty separation lines 558 // remove one newline from each group of non-leading/non-trailing newlines 559 $text = preg_replace('/[^\n]\n+\K\n(?=[^\n])/', '', $text); 560 } 561 562 // deal with trailing newlines 563 if ('' === $chomping) { 564 $text = preg_replace('/\n+$/', "\n", $text); 565 } elseif ('-' === $chomping) { 566 $text = preg_replace('/\n+$/', '', $text); 567 } 568 569 return $text; 570 } 571 572 /** 573 * Returns true if the next line is indented. 574 * 575 * @return bool Returns true if the next line is indented, false otherwise 576 */ 577 private function isNextLineIndented() 578 { 579 $currentIndentation = $this->getCurrentLineIndentation(); 580 $EOF = !$this->moveToNextLine(); 581 582 while (!$EOF && $this->isCurrentLineEmpty()) { 583 $EOF = !$this->moveToNextLine(); 584 } 585 586 if ($EOF) { 587 return false; 588 } 589 590 $ret = false; 591 if ($this->getCurrentLineIndentation() > $currentIndentation) { 592 $ret = true; 593 } 594 595 $this->moveToPreviousLine(); 596 597 return $ret; 598 } 599 600 /** 601 * Returns true if the current line is blank or if it is a comment line. 602 * 603 * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise 604 */ 605 private function isCurrentLineEmpty() 606 { 607 return $this->isCurrentLineBlank() || $this->isCurrentLineComment(); 608 } 609 610 /** 611 * Returns true if the current line is blank. 612 * 613 * @return bool Returns true if the current line is blank, false otherwise 614 */ 615 private function isCurrentLineBlank() 616 { 617 return '' == trim($this->currentLine, ' '); 618 } 619 620 /** 621 * Returns true if the current line is a comment line. 622 * 623 * @return bool Returns true if the current line is a comment line, false otherwise 624 */ 625 private function isCurrentLineComment() 626 { 627 //checking explicitly the first char of the trim is faster than loops or strpos 628 $ltrimmedLine = ltrim($this->currentLine, ' '); 629 630 return $ltrimmedLine[0] === '#'; 631 } 632 633 /** 634 * Cleanups a YAML string to be parsed. 635 * 636 * @param string $value The input YAML string 637 * 638 * @return string A cleaned up YAML string 639 */ 640 private function cleanup($value) 641 { 642 $value = str_replace(array("\r\n", "\r"), "\n", $value); 643 644 // strip YAML header 645 $count = 0; 646 $value = preg_replace('#^\%YAML[: ][\d\.]+.*\n#u', '', $value, -1, $count); 647 $this->offset += $count; 648 649 // remove leading comments 650 $trimmedValue = preg_replace('#^(\#.*?\n)+#s', '', $value, -1, $count); 651 if ($count == 1) { 652 // items have been removed, update the offset 653 $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); 654 $value = $trimmedValue; 655 } 656 657 // remove start of the document marker (---) 658 $trimmedValue = preg_replace('#^\-\-\-.*?\n#s', '', $value, -1, $count); 659 if ($count == 1) { 660 // items have been removed, update the offset 661 $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); 662 $value = $trimmedValue; 663 664 // remove end of the document marker (...) 665 $value = preg_replace('#\.\.\.\s*$#', '', $value); 666 } 667 668 return $value; 669 } 670 671 /** 672 * Returns true if the next line starts unindented collection. 673 * 674 * @return bool Returns true if the next line starts unindented collection, false otherwise 675 */ 676 private function isNextLineUnIndentedCollection() 677 { 678 $currentIndentation = $this->getCurrentLineIndentation(); 679 $notEOF = $this->moveToNextLine(); 680 681 while ($notEOF && $this->isCurrentLineEmpty()) { 682 $notEOF = $this->moveToNextLine(); 683 } 684 685 if (false === $notEOF) { 686 return false; 687 } 688 689 $ret = false; 690 if ( 691 $this->getCurrentLineIndentation() == $currentIndentation 692 && 693 $this->isStringUnIndentedCollectionItem($this->currentLine) 694 ) { 695 $ret = true; 696 } 697 698 $this->moveToPreviousLine(); 699 700 return $ret; 701 } 702 703 /** 704 * Returns true if the string is un-indented collection item. 705 * 706 * @return bool Returns true if the string is un-indented collection item, false otherwise 707 */ 708 private function isStringUnIndentedCollectionItem() 709 { 710 return (0 === strpos($this->currentLine, '- ')); 711 } 712 }