feature #33658 [Yaml] fix parsing inline YAML spanning multiple lines (xabbuh)

This PR was merged into the 4.4 branch.

Discussion
----------

[Yaml] fix parsing inline YAML spanning multiple lines

| Q             | A
| ------------- | ---
| Branch?       | 4.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #25239 #25379 #31333
| License       | MIT
| Doc PR        |

Commits
-------

85a5c31e05 fix parsing inline YAML spanning multiple lines
This commit is contained in:
Fabien Potencier 2019-09-25 20:53:23 +02:00
commit 4cf7ec1ecf
5 changed files with 387 additions and 6 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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;
}
}

View File

@ -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'");
}
}

View File

@ -1615,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'],
<<<YAML
{
'foo': 'bar',
'bar': 'baz'
}
YAML
,
],
'sequence' => [
['foo', 'bar'],
<<<YAML
[
'foo',
'bar'
]
YAML
,
],
'sequence nested in mapping' => [
['foo' => ['bar', 'foobar'], 'bar' => ['baz']],
<<<YAML
{
'foo': ['bar', 'foobar'],
'bar': ['baz']
}
YAML
,
],
'sequence spanning multiple lines nested in mapping' => [
[
'foobar' => [
'foo',
'bar',
'baz',
],
],
<<<YAML
foobar: [foo,
bar,
baz
]
YAML
,
],
'nested sequence nested in mapping starting on the same line' => [
[
'foo' => [
'foobar',
[
'bar',
'baz',
],
],
],
<<<YAML
foo: [foobar, [
bar,
baz
]]
YAML
,
],
'nested sequence nested in mapping starting on the following line' => [
[
'foo' => [
'foobar',
[
'bar',
'baz',
],
],
],
<<<YAML
foo: [foobar,
[
bar,
baz
]]
YAML
,
],
'mapping nested in sequence' => [
['foo', ['bar' => 'baz']],
<<<YAML
[
'foo',
{
'bar': 'baz'
}
]
YAML
,
],
'mapping spanning multiple lines nested in sequence' => [
[
[
'foo' => 'bar',
'bar' => 'baz',
],
],
<<<YAML
- {
foo: bar,
bar: baz
}
YAML
,
],
'nested mapping nested in sequence starting on the same line' => [
[
[
'foo' => [
'bar' => 'foobar',
],
'bar' => 'baz',
],
],
<<<YAML
- { foo: {
bar: foobar
},
bar: baz
}
YAML
,
],
'nested mapping nested in sequence starting on the following line' => [
[
[
'foo' => [
'bar' => 'foobar',
],
'bar' => 'baz',
],
],
<<<YAML
- { foo:
{
bar: foobar
},
bar: baz
}
YAML
,
],
'single quoted multi-line string' => [
"foo\nbar",
<<<YAML
'foo
bar'
YAML
,
],
'double quoted multi-line string' => [
"foo\nbar",
<<<YAML
'foo
bar'
YAML
,
],
'single-quoted multi-line mapping value' => [
['foo' => "bar\nbaz"],
<<<YAML
foo: 'bar
baz'
YAML
],
];
}
public function testRootLevelInlineMappingFollowedByMoreContentIsInvalid()
{
$this->expectException(ParseException::class);
$this->expectExceptionMessage('Unable to parse at line 2 (near "foobar").');
$yaml = <<<YAML
{ foo: bar }
foobar
YAML;
$this->parser->parse($yaml);
}
public function testTaggedInlineMapping()
{
$this->assertEquals(new TaggedValue('foo', ['foo' => 'bar']), $this->parser->parse('!foo {foo: bar}', Yaml::PARSE_CUSTOM_TAGS));
@ -1750,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 = <<<INI
[parameters]
foo = bar