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