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