diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b23cd7..364bf66d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,17 @@ CHANGELOG ========= +7.3 +--- + + * Add compact nested mapping support by using the `Yaml::DUMP_COMPACT_NESTED_MAPPING` flag + * Add the `Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES` flag to enforce double quotes around string values + 7.2 --- * Deprecate parsing duplicate mapping keys whose value is `null` + * Add support for dumping `null` as an empty value by using the `Yaml::DUMP_NULL_AS_EMPTY` flag 7.1 --- diff --git a/Dumper.php b/Dumper.php index f8ea205a..cd5a1f6c 100644 --- a/Dumper.php +++ b/Dumper.php @@ -41,6 +41,15 @@ public function __construct(private int $indentation = 4) * @param int-mask-of $flags A bit field of Yaml::DUMP_* constants to customize the dumped YAML string */ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags = 0): string + { + if ($flags & Yaml::DUMP_NULL_AS_EMPTY && $flags & Yaml::DUMP_NULL_AS_TILDE) { + throw new \InvalidArgumentException('The Yaml::DUMP_NULL_AS_EMPTY and Yaml::DUMP_NULL_AS_TILDE flags cannot be used together.'); + } + + return $this->doDump($input, $inline, $indent, $flags); + } + + private function doDump(mixed $input, int $inline = 0, int $indent = 0, int $flags = 0, int $nestingLevel = 0): string { $output = ''; $prefix = $indent ? str_repeat(' ', $indent) : ''; @@ -51,11 +60,12 @@ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags } if ($inline <= 0 || (!\is_array($input) && !$input instanceof TaggedValue && $dumpObjectAsInlineMap) || !$input) { - $output .= $prefix.Inline::dump($input, $flags); + $output .= $prefix.Inline::dump($input, $flags, 0 === $nestingLevel); } elseif ($input instanceof TaggedValue) { - $output .= $this->dumpTaggedValue($input, $inline, $indent, $flags, $prefix); + $output .= $this->dumpTaggedValue($input, $inline, $indent, $flags, $prefix, $nestingLevel); } else { $dumpAsMap = Inline::isHash($input); + $compactNestedMapping = Yaml::DUMP_COMPACT_NESTED_MAPPING & $flags && !$dumpAsMap; foreach ($input as $key => $value) { if ('' !== $output && "\n" !== $output[-1]) { @@ -105,10 +115,10 @@ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags } if ($inline - 1 <= 0 || null === $value->getValue() || \is_scalar($value->getValue())) { - $output .= ' '.$this->dump($value->getValue(), $inline - 1, 0, $flags)."\n"; + $output .= ' '.$this->doDump($value->getValue(), $inline - 1, 0, $flags, $nestingLevel + 1)."\n"; } else { $output .= "\n"; - $output .= $this->dump($value->getValue(), $inline - 1, $dumpAsMap ? $indent + $this->indentation : $indent + 2, $flags); + $output .= $this->doDump($value->getValue(), $inline - 1, $dumpAsMap ? $indent + $this->indentation : $indent + 2, $flags, $nestingLevel + 1); } continue; @@ -125,8 +135,8 @@ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags $output .= \sprintf('%s%s%s%s', $prefix, $dumpAsMap ? Inline::dump($key, $flags).':' : '-', - $willBeInlined ? ' ' : "\n", - $this->dump($value, $inline - 1, $willBeInlined ? 0 : $indent + $this->indentation, $flags) + $willBeInlined || ($compactNestedMapping && \is_array($value) && Inline::isHash($value)) ? ' ' : "\n", + $compactNestedMapping && \is_array($value) && Inline::isHash($value) ? substr($this->doDump($value, $inline - 1, $indent + 2, $flags, $nestingLevel + 1), $indent + 2) : $this->doDump($value, $inline - 1, $willBeInlined ? 0 : $indent + $this->indentation, $flags, $nestingLevel + 1) ).($willBeInlined ? "\n" : ''); } } @@ -134,7 +144,7 @@ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags return $output; } - private function dumpTaggedValue(TaggedValue $value, int $inline, int $indent, int $flags, string $prefix): string + private function dumpTaggedValue(TaggedValue $value, int $inline, int $indent, int $flags, string $prefix, int $nestingLevel): string { $output = \sprintf('%s!%s', $prefix ? $prefix.' ' : '', $value->getTag()); @@ -150,10 +160,10 @@ private function dumpTaggedValue(TaggedValue $value, int $inline, int $indent, i } if ($inline - 1 <= 0 || null === $value->getValue() || \is_scalar($value->getValue())) { - return $output.' '.$this->dump($value->getValue(), $inline - 1, 0, $flags)."\n"; + return $output.' '.$this->doDump($value->getValue(), $inline - 1, 0, $flags, $nestingLevel + 1)."\n"; } - return $output."\n".$this->dump($value->getValue(), $inline - 1, $indent, $flags); + return $output."\n".$this->doDump($value->getValue(), $inline - 1, $indent, $flags, $nestingLevel + 1); } private function getBlockIndentationIndicator(string $value): string diff --git a/Escaper.php b/Escaper.php index 0857ef2f..8cc492c5 100644 --- a/Escaper.php +++ b/Escaper.php @@ -28,7 +28,8 @@ class Escaper // first to ensure proper escaping because str_replace operates iteratively // on the input arrays. This ordering of the characters avoids the use of strtr, // which performs more slowly. - private const ESCAPEES = ['\\', '\\\\', '\\"', '"', + private const ESCAPEES = [ + '\\', '\\\\', '\\"', '"', "\x00", "\x01", "\x02", "\x03", "\x04", "\x05", "\x06", "\x07", "\x08", "\x09", "\x0a", "\x0b", "\x0c", "\x0d", "\x0e", "\x0f", "\x10", "\x11", "\x12", "\x13", "\x14", "\x15", "\x16", "\x17", @@ -36,7 +37,8 @@ class Escaper "\x7f", "\xc2\x85", "\xc2\xa0", "\xe2\x80\xa8", "\xe2\x80\xa9", ]; - private const ESCAPED = ['\\\\', '\\"', '\\\\', '\\"', + private const ESCAPED = [ + '\\\\', '\\"', '\\\\', '\\"', '\\0', '\\x01', '\\x02', '\\x03', '\\x04', '\\x05', '\\x06', '\\a', '\\b', '\\t', '\\n', '\\v', '\\f', '\\r', '\\x0e', '\\x0f', '\\x10', '\\x11', '\\x12', '\\x13', '\\x14', '\\x15', '\\x16', '\\x17', diff --git a/Inline.php b/Inline.php index ff663f91..1c9fa609 100644 --- a/Inline.php +++ b/Inline.php @@ -100,7 +100,7 @@ public static function parse(string $value, int $flags = 0, array &$references = * * @throws DumpException When trying to dump PHP resource */ - public static function dump(mixed $value, int $flags = 0): string + public static function dump(mixed $value, int $flags = 0, bool $rootLevel = false): string { switch (true) { case \is_resource($value): @@ -138,7 +138,7 @@ public static function dump(mixed $value, int $flags = 0): string case \is_array($value): return self::dumpArray($value, $flags); case null === $value: - return self::dumpNull($flags); + return self::dumpNull($flags, $rootLevel); case true === $value: return 'true'; case false === $value: @@ -173,6 +173,7 @@ public static function dump(mixed $value, int $flags = 0): string case self::isBinaryString($value): return '!!binary '.base64_encode($value); case Escaper::requiresDoubleQuoting($value): + case Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES & $flags: return Escaper::escapeWithDoubleQuotes($value); case Escaper::requiresSingleQuoting($value): $singleQuoted = Escaper::escapeWithSingleQuotes($value); @@ -242,23 +243,28 @@ private static function dumpArray(array $value, int $flags): string private static function dumpHashArray(array|\ArrayObject|\stdClass $value, int $flags): string { $output = []; + $keyFlags = $flags & ~Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES; foreach ($value as $key => $val) { if (\is_int($key) && Yaml::DUMP_NUMERIC_KEY_AS_STRING & $flags) { $key = (string) $key; } - $output[] = \sprintf('%s: %s', self::dump($key, $flags), self::dump($val, $flags)); + $output[] = \sprintf('%s: %s', self::dump($key, $keyFlags), self::dump($val, $flags)); } return \sprintf('{ %s }', implode(', ', $output)); } - private static function dumpNull(int $flags): string + private static function dumpNull(int $flags, bool $rootLevel = false): string { if (Yaml::DUMP_NULL_AS_TILDE & $flags) { return '~'; } + if (Yaml::DUMP_NULL_AS_EMPTY & $flags && !$rootLevel) { + return ''; + } + return 'null'; } diff --git a/Tests/DumperTest.php b/Tests/DumperTest.php index 24758b81..8eac4aee 100644 --- a/Tests/DumperTest.php +++ b/Tests/DumperTest.php @@ -216,6 +216,63 @@ public function testObjectSupportDisabledWithExceptions() $this->dumper->dump(['foo' => new A(), 'bar' => 1], 0, 0, Yaml::DUMP_EXCEPTION_ON_INVALID_TYPE); } + public function testDumpWithMultipleNullFlagsFormatsThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The Yaml::DUMP_NULL_AS_EMPTY and Yaml::DUMP_NULL_AS_TILDE flags cannot be used together.'); + + $this->dumper->dump(['foo' => 'bar'], 0, 0, Yaml::DUMP_NULL_AS_EMPTY | Yaml::DUMP_NULL_AS_TILDE); + } + + public function testDumpNullAsEmptyInExpandedMapping() + { + $expected = "qux:\n foo: bar\n baz: \n"; + + $this->assertSame($expected, $this->dumper->dump(['qux' => ['foo' => 'bar', 'baz' => null]], 2, flags: Yaml::DUMP_NULL_AS_EMPTY)); + } + + public function testDumpNullAsEmptyWithObject() + { + $class = new \stdClass(); + $class->foo = 'bar'; + $class->baz = null; + + $this->assertSame("foo: bar\nbaz: \n", $this->dumper->dump($class, 2, flags: Yaml::DUMP_NULL_AS_EMPTY | Yaml::DUMP_OBJECT_AS_MAP)); + } + + public function testDumpNullAsEmptyDumpsWhenInInlineMapping() + { + $expected = "foo: \nqux: { foo: bar, baz: }\n"; + + $this->assertSame($expected, $this->dumper->dump(['foo' => null, 'qux' => ['foo' => 'bar', 'baz' => null]], 1, flags: Yaml::DUMP_NULL_AS_EMPTY)); + } + + public function testDumpNullAsEmptyDumpsNestedMaps() + { + $expected = "foo: \nqux:\n foo: bar\n baz: \n"; + + $this->assertSame($expected, $this->dumper->dump(['foo' => null, 'qux' => ['foo' => 'bar', 'baz' => null]], 10, flags: Yaml::DUMP_NULL_AS_EMPTY)); + } + + public function testDumpNullAsEmptyInExpandedSequence() + { + $expected = "qux:\n - foo\n - \n - bar\n"; + + $this->assertSame($expected, $this->dumper->dump(['qux' => ['foo', null, 'bar']], 2, flags: Yaml::DUMP_NULL_AS_EMPTY)); + } + + public function testDumpNullAsEmptyWhenInInlineSequence() + { + $expected = "foo: \nqux: [foo, , bar]\n"; + + $this->assertSame($expected, $this->dumper->dump(['foo' => null, 'qux' => ['foo', null, 'bar']], 1, flags: Yaml::DUMP_NULL_AS_EMPTY)); + } + + public function testDumpNullAsEmptyAtRoot() + { + $this->assertSame('null', $this->dumper->dump(null, 2, flags: Yaml::DUMP_NULL_AS_EMPTY)); + } + /** * @dataProvider getEscapeSequences */ @@ -853,6 +910,87 @@ public function testDumpNullAsTilde() $this->assertSame('{ foo: ~ }', $this->dumper->dump(['foo' => null], 0, 0, Yaml::DUMP_NULL_AS_TILDE)); } + /** + * @dataProvider getForceQuotesOnValuesData + */ + public function testCanForceQuotesOnValues(array $input, string $expected) + { + $this->assertSame($expected, $this->dumper->dump($input, 0, 0, Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES)); + } + + public static function getForceQuotesOnValuesData(): iterable + { + yield 'empty string' => [ + ['foo' => ''], + '{ foo: \'\' }', + ]; + + yield 'double quote' => [ + ['foo' => '"'], + '{ foo: "\"" }', + ]; + + yield 'single quote' => [ + ['foo' => "'"], + '{ foo: "\'" }', + ]; + + yield 'line break' => [ + ['foo' => "line\nbreak"], + '{ foo: "line\nbreak" }', + ]; + + yield 'tab character' => [ + ['foo' => "tab\tcharacter"], + '{ foo: "tab\tcharacter" }', + ]; + + yield 'backslash' => [ + ['foo' => 'back\\slash'], + '{ foo: "back\\\\slash" }', + ]; + + yield 'colon' => [ + ['foo' => 'colon: value'], + '{ foo: "colon: value" }', + ]; + + yield 'dash' => [ + ['foo' => '- dash'], + '{ foo: "- dash" }', + ]; + + yield 'numeric' => [ + ['foo' => 23], + '{ foo: 23 }', + ]; + + yield 'boolean' => [ + ['foo' => true], + '{ foo: true }', + ]; + + yield 'null' => [ + ['foo' => null], + '{ foo: null }', + ]; + + yield 'nested' => [ + ['foo' => ['bar' => 'bat', 'baz' => 23]], + '{ foo: { bar: "bat", baz: 23 } }', + ]; + + yield 'mix of values' => [ + ['foo' => 'bat', 'bar' => 23, 'baz' => true, 'qux' => "line\nbreak"], + '{ foo: "bat", bar: 23, baz: true, qux: "line\nbreak" }', + ]; + + yield 'special YAML characters' => [ + ['foo' => 'colon: value', 'bar' => '- dash', 'baz' => '? question', 'qux' => '# hash'], + '{ foo: "colon: value", bar: "- dash", baz: "? question", qux: "# hash" }', + ]; + } + /** * @dataProvider getNumericKeyData */ @@ -1004,6 +1142,177 @@ public static function getDateTimeData() ]; } + public static function getDumpCompactNestedMapping() + { + $data = [ + 'planets' => [ + [ + 'name' => 'Mercury', + 'distance' => 57910000, + 'properties' => [ + ['name' => 'size', 'value' => 4879], + ['name' => 'moons', 'value' => 0], + [[[]]], + ], + ], + [ + 'name' => 'Jupiter', + 'distance' => 778500000, + 'properties' => [ + ['name' => 'size', 'value' => 139820], + ['name' => 'moons', 'value' => 79], + [[]], + ], + ], + ], + ]; + + yield 'Compact nested mapping 1' => [ + $data, + << [ + $data, + << [ + $data, + << [ + $data, + << [ + $data, + <<dump($data, $inline, 0, Yaml::DUMP_COMPACT_NESTED_MAPPING); + $this->assertSame($expected, $actual); + $this->assertSameData($data, $this->parser->parse($actual)); + } + private function assertSameData($expected, $actual) { $this->assertEquals($expected, $actual); diff --git a/Tests/ParserTest.php b/Tests/ParserTest.php index 87b39da3..836ec23f 100644 --- a/Tests/ParserTest.php +++ b/Tests/ParserTest.php @@ -52,6 +52,33 @@ public function testTopLevelNull() $this->assertSameData($expected, $data); } + public function testEmptyValueInExpandedMappingIsSupported() + { + $yml = <<<'YAML' +foo: + bar: + baz: qux +YAML; + + $data = $this->parser->parse($yml); + $expected = ['foo' => ['bar' => null, 'baz' => 'qux']]; + $this->assertSameData($expected, $data); + } + + public function testEmptyValueInExpandedSequenceIsSupported() + { + $yml = <<<'YAML' +foo: + - bar + - + - baz +YAML; + + $data = $this->parser->parse($yml); + $expected = ['foo' => ['bar', null, 'baz']]; + $this->assertSameData($expected, $data); + } + public function testTaggedValueTopLevelNumber() { $yml = '!number 5'; diff --git a/Yaml.php b/Yaml.php index 36b45198..57625a53 100644 --- a/Yaml.php +++ b/Yaml.php @@ -35,6 +35,9 @@ class Yaml public const DUMP_EMPTY_ARRAY_AS_SEQUENCE = 1024; public const DUMP_NULL_AS_TILDE = 2048; public const DUMP_NUMERIC_KEY_AS_STRING = 4096; + public const DUMP_NULL_AS_EMPTY = 8192; + public const DUMP_COMPACT_NESTED_MAPPING = 16384; + public const DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES = 32768; /** * Parses a YAML file into a PHP value.