From 85a5c31e0526d79ed3980640d720ab5fd351a520 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Tue, 5 Dec 2017 08:15:46 +0100 Subject: [PATCH] fix parsing inline YAML spanning multiple lines --- src/Symfony/Component/Yaml/CHANGELOG.md | 1 + src/Symfony/Component/Yaml/Inline.php | 9 +- src/Symfony/Component/Yaml/Parser.php | 179 +++++++++++++++ .../Component/Yaml/Tests/InlineTest.php | 2 +- .../Component/Yaml/Tests/ParserTest.php | 203 +++++++++++++++++- 5 files changed, 388 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Yaml/CHANGELOG.md b/src/Symfony/Component/Yaml/CHANGELOG.md index d80aba4b32..ff78af2feb 100644 --- a/src/Symfony/Component/Yaml/CHANGELOG.md +++ b/src/Symfony/Component/Yaml/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 4.4.0 ----- + * Added support for parsing the inline notation spanning multiple lines. * Added support to dump `null` as `~` by using the `Yaml::DUMP_NULL_AS_TILDE` flag. * deprecated accepting STDIN implicitly when using the `lint:yaml` command, use `lint:yaml -` (append a dash) instead to make it explicit. diff --git a/src/Symfony/Component/Yaml/Inline.php b/src/Symfony/Component/Yaml/Inline.php index d2481c4869..2d0c0b253d 100644 --- a/src/Symfony/Component/Yaml/Inline.php +++ b/src/Symfony/Component/Yaml/Inline.php @@ -274,7 +274,7 @@ class Inline $output = self::parseQuotedScalar($scalar, $i); if (null !== $delimiters) { - $tmp = ltrim(substr($scalar, $i), ' '); + $tmp = ltrim(substr($scalar, $i), " \n"); if ('' === $tmp) { throw new ParseException(sprintf('Unexpected end of line, expected one of "%s".', implode('', $delimiters)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); } @@ -419,6 +419,7 @@ class Inline switch ($mapping[$i]) { case ' ': case ',': + case "\n": ++$i; continue 2; case '}': @@ -450,7 +451,7 @@ class Inline } } - if (!$isKeyQuoted && (!isset($mapping[$i + 1]) || !\in_array($mapping[$i + 1], [' ', ',', '[', ']', '{', '}'], true))) { + if (!$isKeyQuoted && (!isset($mapping[$i + 1]) || !\in_array($mapping[$i + 1], [' ', ',', '[', ']', '{', '}', "\n"], true))) { throw new ParseException('Colons must be followed by a space or an indication character (i.e. " ", ",", "[", "]", "{", "}").', self::$parsedLineNumber + 1, $mapping); } @@ -459,7 +460,7 @@ class Inline } while ($i < $len) { - if (':' === $mapping[$i] || ' ' === $mapping[$i]) { + if (':' === $mapping[$i] || ' ' === $mapping[$i] || "\n" === $mapping[$i]) { ++$i; continue; @@ -508,7 +509,7 @@ class Inline } break; default: - $value = self::parseScalar($mapping, $flags, [',', '}'], $i, null === $tag, $references); + $value = self::parseScalar($mapping, $flags, [',', '}', "\n"], $i, null === $tag, $references); // Spec: Keys MUST be unique; first one wins. // Parser cannot abort this mapping earlier, since lines // are processed sequentially. diff --git a/src/Symfony/Component/Yaml/Parser.php b/src/Symfony/Component/Yaml/Parser.php index ef53f2a5e6..c34e264b66 100644 --- a/src/Symfony/Component/Yaml/Parser.php +++ b/src/Symfony/Component/Yaml/Parser.php @@ -353,6 +353,61 @@ class Parser $this->refs[$isRef] = $data[$key]; array_pop($this->refsBeingParsed); } + } elseif ('"' === $this->currentLine[0] || "'" === $this->currentLine[0]) { + if (null !== $context) { + throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); + } + + try { + return Inline::parse($this->parseQuotedString($this->currentLine), $flags, $this->refs); + } catch (ParseException $e) { + $e->setParsedLine($this->getRealCurrentLineNb() + 1); + $e->setSnippet($this->currentLine); + + throw $e; + } + } elseif ('{' === $this->currentLine[0]) { + if (null !== $context) { + throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); + } + + try { + $parsedMapping = Inline::parse($this->lexInlineMapping($this->currentLine), $flags, $this->refs); + + while ($this->moveToNextLine()) { + if (!$this->isCurrentLineEmpty()) { + throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); + } + } + + return $parsedMapping; + } catch (ParseException $e) { + $e->setParsedLine($this->getRealCurrentLineNb() + 1); + $e->setSnippet($this->currentLine); + + throw $e; + } + } elseif ('[' === $this->currentLine[0]) { + if (null !== $context) { + throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); + } + + try { + $parsedSequence = Inline::parse($this->lexInlineSequence($this->currentLine), $flags, $this->refs); + + while ($this->moveToNextLine()) { + if (!$this->isCurrentLineEmpty()) { + throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); + } + } + + return $parsedSequence; + } catch (ParseException $e) { + $e->setParsedLine($this->getRealCurrentLineNb() + 1); + $e->setSnippet($this->currentLine); + + throw $e; + } } else { // multiple documents are not supported if ('---' === $this->currentLine) { @@ -678,6 +733,12 @@ class Parser } try { + if ('' !== $value && '{' === $value[0]) { + return Inline::parse($this->lexInlineMapping($value), $flags, $this->refs); + } elseif ('' !== $value && '[' === $value[0]) { + return Inline::parse($this->lexInlineSequence($value), $flags, $this->refs); + } + $quotation = '' !== $value && ('"' === $value[0] || "'" === $value[0]) ? $value[0] : null; // do not take following lines into account when the current line is a quoted single line value @@ -1072,4 +1133,122 @@ class Parser 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); } + + private function parseQuotedString($yaml) + { + if ('' === $yaml || ('"' !== $yaml[0] && "'" !== $yaml[0])) { + throw new \InvalidArgumentException(sprintf('"%s" is not a quoted string.', $yaml)); + } + + $lines = [$yaml]; + + while ($this->moveToNextLine()) { + $lines[] = $this->currentLine; + + if (!$this->isCurrentLineEmpty() && $yaml[0] === $this->currentLine[-1]) { + break; + } + } + + $value = ''; + + for ($i = 0, $linesCount = \count($lines), $previousLineWasNewline = false, $previousLineWasTerminatedWithBackslash = false; $i < $linesCount; ++$i) { + if ('' === trim($lines[$i])) { + $value .= "\n"; + } elseif (!$previousLineWasNewline && !$previousLineWasTerminatedWithBackslash) { + $value .= ' '; + } + + if ('' !== trim($lines[$i]) && '\\' === substr($lines[$i], -1)) { + $value .= ltrim(substr($lines[$i], 0, -1)); + } elseif ('' !== trim($lines[$i])) { + $value .= trim($lines[$i]); + } + + if ('' === trim($lines[$i])) { + $previousLineWasNewline = true; + $previousLineWasTerminatedWithBackslash = false; + } elseif ('\\' === substr($lines[$i], -1)) { + $previousLineWasNewline = false; + $previousLineWasTerminatedWithBackslash = true; + } else { + $previousLineWasNewline = false; + $previousLineWasTerminatedWithBackslash = false; + } + } + + return $value; + + for ($i = 1; isset($yaml[$i]) && $quotation !== $yaml[$i]; ++$i) { + } + + // quoted single line string + if (isset($yaml[$i]) && $quotation === $yaml[$i]) { + return $yaml; + } + + $lines = [$yaml]; + + while ($this->moveToNextLine()) { + for ($i = 1; isset($this->currentLine[$i]) && $quotation !== $this->currentLine[$i]; ++$i) { + } + + $lines[] = trim($this->currentLine); + + if (isset($this->currentLine[$i]) && $quotation === $this->currentLine[$i]) { + break; + } + } + } + + private function lexInlineMapping(string $yaml): string + { + if ('' === $yaml || '{' !== $yaml[0]) { + throw new \InvalidArgumentException(sprintf('"%s" is not a sequence.', $yaml)); + } + + for ($i = 1; isset($yaml[$i]) && '}' !== $yaml[$i]; ++$i) { + } + + if (isset($yaml[$i]) && '}' === $yaml[$i]) { + return $yaml; + } + + $lines = [$yaml]; + + while ($this->moveToNextLine()) { + $lines[] = $this->currentLine; + } + + return implode("\n", $lines); + } + + private function lexInlineSequence($yaml) + { + if ('' === $yaml || '[' !== $yaml[0]) { + throw new \InvalidArgumentException(sprintf('"%s" is not a sequence.', $yaml)); + } + + for ($i = 1; isset($yaml[$i]) && ']' !== $yaml[$i]; ++$i) { + } + + if (isset($yaml[$i]) && ']' === $yaml[$i]) { + return $yaml; + } + + $value = $yaml; + + while ($this->moveToNextLine()) { + for ($i = 1; isset($this->currentLine[$i]) && ']' !== $this->currentLine[$i]; ++$i) { + } + + $value .= trim($this->currentLine); + + if (isset($this->currentLine[$i]) && ']' === $this->currentLine[$i]) { + break; + } + } + + return $value; + } } diff --git a/src/Symfony/Component/Yaml/Tests/InlineTest.php b/src/Symfony/Component/Yaml/Tests/InlineTest.php index 74dc8ff19a..5942f6918d 100644 --- a/src/Symfony/Component/Yaml/Tests/InlineTest.php +++ b/src/Symfony/Component/Yaml/Tests/InlineTest.php @@ -711,7 +711,7 @@ class InlineTest extends TestCase public function testUnfinishedInlineMap() { $this->expectException('Symfony\Component\Yaml\Exception\ParseException'); - $this->expectExceptionMessage('Unexpected end of line, expected one of ",}" at line 1 (near "{abc: \'def\'").'); + $this->expectExceptionMessage("Unexpected end of line, expected one of \",}\n\" at line 1 (near \"{abc: 'def'\")."); Inline::parse("{abc: 'def'"); } } diff --git a/src/Symfony/Component/Yaml/Tests/ParserTest.php b/src/Symfony/Component/Yaml/Tests/ParserTest.php index 366902a864..9842dc982e 100644 --- a/src/Symfony/Component/Yaml/Tests/ParserTest.php +++ b/src/Symfony/Component/Yaml/Tests/ParserTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Yaml\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Parser; use Symfony\Component\Yaml\Tag\TaggedValue; use Symfony\Component\Yaml\Yaml; @@ -1614,6 +1615,206 @@ EOF; return $tests; } + /** + * @dataProvider inlineNotationSpanningMultipleLinesProvider + */ + public function testInlineNotationSpanningMultipleLines($expected, string $yaml) + { + $this->assertEquals($expected, $this->parser->parse($yaml)); + } + + public function inlineNotationSpanningMultipleLinesProvider(): array + { + return [ + 'mapping' => [ + ['foo' => 'bar', 'bar' => 'baz'], + << [ + ['foo', 'bar'], + << [ + ['foo' => ['bar', 'foobar'], 'bar' => ['baz']], + << [ + [ + 'foobar' => [ + 'foo', + 'bar', + 'baz', + ], + ], + << [ + [ + 'foo' => [ + 'foobar', + [ + 'bar', + 'baz', + ], + ], + ], + << [ + [ + 'foo' => [ + 'foobar', + [ + 'bar', + 'baz', + ], + ], + ], + << [ + ['foo', ['bar' => 'baz']], + << [ + [ + [ + 'foo' => 'bar', + 'bar' => 'baz', + ], + ], + << [ + [ + [ + 'foo' => [ + 'bar' => 'foobar', + ], + 'bar' => 'baz', + ], + ], + << [ + [ + [ + 'foo' => [ + 'bar' => 'foobar', + ], + 'bar' => 'baz', + ], + ], + << [ + "foo\nbar", + << [ + "foo\nbar", + << [ + ['foo' => "bar\nbaz"], + <<expectException(ParseException::class); + $this->expectExceptionMessage('Unable to parse at line 2 (near "foobar").'); + + $yaml = <<parser->parse($yaml); + } + public function testTaggedInlineMapping() { $this->assertEquals(new TaggedValue('foo', ['foo' => 'bar']), $this->parser->parse('!foo {foo: bar}', Yaml::PARSE_CUSTOM_TAGS)); @@ -1749,7 +1950,7 @@ YAML; public function testParsingIniThrowsException() { $this->expectException('Symfony\Component\Yaml\Exception\ParseException'); - $this->expectExceptionMessage('Unable to parse at line 1 (near "[parameters]").'); + $this->expectExceptionMessage('Unable to parse at line 2 (near " foo = bar").'); $ini = <<