diff --git a/composer.json b/composer.json index 8602fd1..0e4016c 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "php": "^8.2", "horde/composer": "^1 || dev-FRAMEWORK_6_0", "horde/version": "^1 || dev-FRAMEWORK_6_0", - "horde/yaml": "^3 || dev-FRAMEWORK_6_0" + "horde/yaml": "^3.0.0RC1 || dev-FRAMEWORK_6_0" }, "require-dev": { "phpunit/phpunit": "^12", diff --git a/src/ChangelogYmlFile.php b/src/ChangelogYmlFile.php index af07e7e..bc654f2 100644 --- a/src/ChangelogYmlFile.php +++ b/src/ChangelogYmlFile.php @@ -8,13 +8,25 @@ use RuntimeException; use stdClass; use Stringable; -use Horde\Yaml\Yaml; -use Horde\Yaml\Exception as YamlException; +use Horde\Yaml\Document\Exception as DocumentException; +use Horde\Yaml\Document\Node\AliasNode; +use Horde\Yaml\Document\Node\MapEntry; +use Horde\Yaml\Document\Node\MapNode; +use Horde\Yaml\Document\Node\Node; +use Horde\Yaml\Document\Node\ScalarNode; +use Horde\Yaml\Document\Node\SequenceItem; +use Horde\Yaml\Document\Node\SequenceNode; +use Horde\Yaml\Document\YamlDocument; +use Horde\Yaml\Document\YamlFileDumper; +use Horde\Yaml\Document\YamlFileLoader; +use Horde\Yaml\Document\YamlStream; +use Horde\Yaml\Document\YamlStringDumper; class ChangelogYmlFile implements Stringable { private stdClass $changelogYml; private string $originalContent; + private YamlStream $stream; public function __construct( private string $filePath @@ -26,17 +38,85 @@ public function __construct( throw new InvalidChangelogFileException("File is not readable: {$this->filePath}"); } $content = file_get_contents($this->filePath); - // Load YAML representation + // Load YAML representation via the document layer. The + // document layer preserves comments, blank lines, and + // formatting on the AST so a future save() can write back + // byte-identical for untouched parts of the file. try { - $data = Yaml::load($content); - $data = $this->normalizeChangelogData($data ?? []); - $this->changelogYml = $this->arrayToObject($data); - } catch (YamlException $e) { + $this->stream = (new YamlFileLoader())->load($this->filePath); + $array = $this->streamToArray($this->stream); + $array = $this->normalizeChangelogData($array); + $this->changelogYml = $this->arrayToObject($array); + } catch (DocumentException $e) { throw new InvalidChangelogFileException("Failed to parse YAML: {$this->filePath}", 0, $e); } $this->originalContent = $content; } + /** + * Reduce a YamlStream to a plain PHP array shaped like what + * Yaml::load() returned. An empty stream (zero documents or a + * null root) yields an empty array. + */ + private function streamToArray(YamlStream $stream): array + { + if ($stream->documentCount() === 0) { + return []; + } + $root = $stream->getDocument(0)->root(); + if ($root === null) { + return []; + } + $value = $this->nodeToArray($root); + if (!is_array($value)) { + return []; + } + return $value; + } + + /** + * Recursively convert a document-layer node into a plain PHP + * value. + */ + private function nodeToArray(Node $node): mixed + { + if ($node instanceof ScalarNode) { + return $node->getValue(); + } + if ($node instanceof MapNode) { + $out = []; + foreach ($node->entries() as $entry) { + $key = $entry->getKey(); + $keyValue = $key instanceof ScalarNode ? $key->getValue() : null; + if ($keyValue === null) { + continue; + } + $out[(string) $keyValue] = $this->nodeToArray($entry->getValue()); + } + return $out; + } + if ($node instanceof SequenceNode) { + $out = []; + foreach ($node->items() as $item) { + $value = $item->getValue(); + if ($value === null) { + $out[] = null; + continue; + } + $out[] = $this->nodeToArray($value); + } + return $out; + } + if ($node instanceof AliasNode) { + $target = $node->target(); + if ($target === null) { + return $node->getTargetName(); + } + return $this->nodeToArray($target); + } + return null; + } + /** * Normalize list-format changelogs (array of objects with 'version' key) * into the expected associative format (version string => entry). @@ -90,16 +170,184 @@ private function arrayToObject(mixed $data): mixed public function __toString(): string { - // Convert objects back to arrays for YAML dumping - $data = json_decode(json_encode($this->changelogYml), true); - return Yaml::dump($data, ['wordwrap' => 0, 'indent' => 2]); + $this->syncStreamFromChangelogYml(); + return (new YamlStringDumper())->dump($this->stream); } public function save(): void { - if (file_put_contents($this->filePath, $this) === false) { - throw new RuntimeException("Failed to write to file: {$this->filePath}"); + $this->syncStreamFromChangelogYml(); + try { + (new YamlFileDumper())->dump($this->stream, $this->filePath); + } catch (DocumentException $e) { + throw new RuntimeException("Failed to write to file: {$this->filePath}", 0, $e); + } + } + + /** + * Reflect the current stdClass facade into the AST. Top-level + * diff-and-apply: each version key becomes (or replaces) an + * entry in the document's root MapNode. Trivia around + * unchanged version entries (file headers, blank lines, + * inter-version comments) is preserved. When a version + * entry's value subtree changes, only that subtree is rebuilt; + * trivia inside a rebuilt subtree is lost. + * + * After applying entry-level diffs the children list is + * reordered to match the stdClass property order, so + * addVersionEntry's version_compare-based sort surfaces in the + * emitted YAML. + */ + private function syncStreamFromChangelogYml(): void + { + if ($this->stream->documentCount() === 0) { + $doc = new YamlDocument(); + $doc->setRootInternal(new MapNode()); + $this->stream->appendInternalDocument($doc); + } + $doc = $this->stream->getDocument(0); + $root = $doc->root(); + if (!$root instanceof MapNode) { + $newRoot = $this->buildNode($this->changelogYml) ?? new MapNode(); + if (!$newRoot instanceof MapNode + && !$newRoot instanceof SequenceNode + && !$newRoot instanceof ScalarNode + ) { + $newRoot = new MapNode(); + } + $doc->setRootInternal($newRoot); + return; + } + + $current = (array) $this->changelogYml; + $existingKeys = []; + foreach ($root->entries() as $entry) { + $keyNode = $entry->getKey(); + if ($keyNode instanceof ScalarNode) { + $keyValue = $keyNode->getValue(); + if ($keyValue !== null) { + $existingKeys[(string) $keyValue] = $entry; + } + } + } + + // Remove keys present in AST but absent from the stdClass. + foreach ($existingKeys as $key => $entry) { + if (!array_key_exists($key, $current)) { + $root->removeEntry($entry); + } + } + + // Update or insert. New keys land at the position dictated + // by the stdClass property order: between the previous and + // the next existing key. Append only when no existing key + // follows. This makes addVersionEntry's version_compare + // sort surface in the emitted YAML. + $stdKeys = array_keys($current); + foreach ($current as $key => $value) { + $key = (string) $key; + $existing = $root->entry($key); + if ($existing === null) { + $newNode = $this->buildNode($value) ?? new MapNode(); + $stdIdx = array_search($key, $stdKeys, true); + $insertedBefore = false; + if ($stdIdx !== false) { + for ($i = $stdIdx + 1; $i < count($stdKeys); $i++) { + $followingKey = (string) $stdKeys[$i]; + $followingEntry = $root->entry($followingKey); + if ($followingEntry !== null) { + $root->insertEntryBefore($followingEntry, $key, $newNode); + $insertedBefore = true; + break; + } + } + } + if (!$insertedBefore) { + $root->appendChildInternal(new MapEntry(new ScalarNode($key), $newNode)); + } + continue; + } + if ($this->valuesEqual($existing->getValue(), $value)) { + continue; + } + $existing->setValueInternal($this->buildNode($value) ?? new MapNode()); + } + } + + /** + * Build a value-position AST node from a plain PHP value. + */ + private function buildNode(mixed $value): MapNode|SequenceNode|ScalarNode|null + { + if ($value instanceof stdClass) { + $map = new MapNode(); + foreach ((array) $value as $key => $child) { + $childNode = $this->buildNode($child) ?? new MapNode(); + $entry = new MapEntry(new ScalarNode((string) $key), $childNode); + $map->appendChildInternal($entry); + } + return $map; + } + if (is_array($value)) { + $isList = $value === [] || array_is_list($value); + if ($isList) { + $seq = new SequenceNode(); + foreach ($value as $child) { + $childNode = $this->buildNode($child) ?? new ScalarNode(null); + $seq->appendChildInternal(new SequenceItem($childNode)); + } + return $seq; + } + $map = new MapNode(); + foreach ($value as $key => $child) { + $childNode = $this->buildNode($child) ?? new MapNode(); + $entry = new MapEntry(new ScalarNode((string) $key), $childNode); + $map->appendChildInternal($entry); + } + return $map; + } + if ($value === null || is_scalar($value)) { + return new ScalarNode($value); + } + if ($value instanceof Stringable) { + return new ScalarNode((string) $value); + } + return null; + } + + /** + * Compare an existing AST node's logical value against a + * stdClass-or-array-or-scalar value drawn from the stdClass + * facade. Returns true when they would emit identically. + */ + private function valuesEqual(Node $node, mixed $value): bool + { + $nodeValue = $this->nodeToArray($node); + $stdValue = $this->stdClassToArray($value); + return $nodeValue === $stdValue; + } + + /** + * Recursively convert a stdClass-or-array tree into the same + * shape that nodeToArray produces. + */ + private function stdClassToArray(mixed $value): mixed + { + if ($value instanceof stdClass) { + $out = []; + foreach ((array) $value as $k => $v) { + $out[(string) $k] = $this->stdClassToArray($v); + } + return $out; + } + if (is_array($value)) { + $out = []; + foreach ($value as $k => $v) { + $out[$k] = $this->stdClassToArray($v); + } + return $out; } + return $value; } // ========== Version Checking ========== diff --git a/src/HordeYmlFile.php b/src/HordeYmlFile.php index 4bcc56e..10a8d83 100644 --- a/src/HordeYmlFile.php +++ b/src/HordeYmlFile.php @@ -10,11 +10,25 @@ use Stringable; use Horde\Yaml\Yaml; use Horde\Yaml\Exception as YamlException; +use Horde\Yaml\Document\Exception as DocumentException; +use Horde\Yaml\Document\Node\AliasNode; +use Horde\Yaml\Document\Node\MapEntry; +use Horde\Yaml\Document\Node\MapNode; +use Horde\Yaml\Document\Node\Node; +use Horde\Yaml\Document\Node\ScalarNode; +use Horde\Yaml\Document\Node\SequenceItem; +use Horde\Yaml\Document\Node\SequenceNode; +use Horde\Yaml\Document\YamlDocument; +use Horde\Yaml\Document\YamlFileDumper; +use Horde\Yaml\Document\YamlFileLoader; +use Horde\Yaml\Document\YamlStream; +use Horde\Yaml\Document\YamlStringDumper; class HordeYmlFile implements Stringable { private stdClass $hordeYml; private string $originalContent; + private YamlStream $stream; public function __construct( private string $filePath @@ -26,21 +40,94 @@ public function __construct( throw new InvalidHordeYmlFileException("File is not readable: {$this->filePath}"); } $content = file_get_contents($this->filePath); - // Load YAML representation + // Load YAML representation via the document layer. The + // document layer preserves comments, blank lines, and + // formatting on the AST so a future save() can write back + // byte-identical for untouched parts of the file. try { - // Handle empty files - if (trim($content) === '' || trim($content) === '---') { - $this->hordeYml = new stdClass(); - } else { - $data = Yaml::load($content); - $this->hordeYml = $this->arrayToObject($data ?? []); - } - } catch (YamlException $e) { + $this->stream = (new YamlFileLoader())->load($this->filePath); + $array = $this->streamToArray($this->stream); + $this->hordeYml = $this->arrayToObject($array); + } catch (DocumentException $e) { throw new InvalidHordeYmlFileException("Failed to parse YAML: {$this->filePath}", 0, $e); } $this->originalContent = $content; } + /** + * Reduce a YamlStream to a plain PHP array shaped like what + * Yaml::load() returned. An empty stream (zero documents or a + * null root) yields an empty array. Multi-document files + * collapse to the first document's root, matching the legacy + * loader's behaviour. + */ + private function streamToArray(YamlStream $stream): array + { + if ($stream->documentCount() === 0) { + return []; + } + $root = $stream->getDocument(0)->root(); + if ($root === null) { + return []; + } + $value = $this->nodeToArray($root); + if (!is_array($value)) { + // A document whose root is a bare scalar does not match + // the .horde.yml shape. Surface as an empty top-level + // map; callers asking for typed accessors will see + // missing keys rather than a type error. + return []; + } + return $value; + } + + /** + * Recursively convert a document-layer node into a plain PHP + * value. Maps become associative arrays. Sequences become + * 0-indexed arrays. Scalars unwrap to their typed value. + * Aliases dereference to their target's typed value (with a + * fallback to the alias name as a string when the target is + * absent, matching the legacy loader). + */ + private function nodeToArray(Node $node): mixed + { + if ($node instanceof ScalarNode) { + return $node->getValue(); + } + if ($node instanceof MapNode) { + $out = []; + foreach ($node->entries() as $entry) { + $key = $entry->getKey(); + $keyValue = $key instanceof ScalarNode ? $key->getValue() : null; + if ($keyValue === null) { + continue; + } + $out[(string) $keyValue] = $this->nodeToArray($entry->getValue()); + } + return $out; + } + if ($node instanceof SequenceNode) { + $out = []; + foreach ($node->items() as $item) { + $value = $item->getValue(); + if ($value === null) { + $out[] = null; + continue; + } + $out[] = $this->nodeToArray($value); + } + return $out; + } + if ($node instanceof AliasNode) { + $target = $node->target(); + if ($target === null) { + return $node->getTargetName(); + } + return $this->nodeToArray($target); + } + return null; + } + /** * Recursively convert arrays to stdClass objects. */ @@ -69,17 +156,190 @@ private function arrayToObject(mixed $data): mixed public function __toString(): string { - // Convert objects back to arrays for YAML dumping - $data = json_decode(json_encode($this->hordeYml), true); - return Yaml::dump($data, ['wordwrap' => 78, 'indent' => 2]); + $this->syncStreamFromHordeYml(); + return (new YamlStringDumper())->dump($this->stream); } public function save(): void { $this->applyGracefulUpdates(); - if (file_put_contents($this->filePath, $this) === false) { - throw new RuntimeException("Failed to write to file: {$this->filePath}"); + $this->syncStreamFromHordeYml(); + try { + (new YamlFileDumper())->dump($this->stream, $this->filePath); + } catch (DocumentException $e) { + throw new RuntimeException("Failed to write to file: {$this->filePath}", 0, $e); + } + } + + /** + * Reflect the current stdClass facade into the AST. Top-level + * diff-and-apply: each property of the stdClass becomes (or + * replaces) an entry in the document's root MapNode. Trivia + * around unchanged entries (comments, blank lines, EOL + * comments) is preserved. When a top-level entry's value + * subtree changes, only that subtree is rebuilt; trivia inside + * a rebuilt subtree is lost. + */ + private function syncStreamFromHordeYml(): void + { + // Ensure the stream has a document. + if ($this->stream->documentCount() === 0) { + $doc = new YamlDocument(); + $doc->setRootInternal(new MapNode()); + $this->stream->appendInternalDocument($doc); + } + $doc = $this->stream->getDocument(0); + $root = $doc->root(); + // Bare-scalar root, sequence root, or no root at all: rebuild + // from scratch. The .horde.yml shape is always a top-level + // map so this only fires on degenerate input. + if (!$root instanceof MapNode) { + $newRoot = $this->buildNode($this->hordeYml) ?? new MapNode(); + if (!$newRoot instanceof MapNode + && !$newRoot instanceof SequenceNode + && !$newRoot instanceof ScalarNode + ) { + $newRoot = new MapNode(); + } + $doc->setRootInternal($newRoot); + return; + } + + $current = (array) $this->hordeYml; + $existingKeys = []; + foreach ($root->entries() as $entry) { + $keyNode = $entry->getKey(); + if ($keyNode instanceof ScalarNode) { + $keyValue = $keyNode->getValue(); + if ($keyValue !== null) { + $existingKeys[(string) $keyValue] = $entry; + } + } + } + + // Remove keys present in AST but absent from the stdClass. + foreach ($existingKeys as $key => $entry) { + if (!array_key_exists($key, $current)) { + $root->removeEntry($entry); + } + } + + // Update or insert. New keys land at the position dictated + // by the stdClass property order: between the previous and + // the next existing key. Append only when no existing key + // follows. + $stdKeys = array_keys($current); + foreach ($current as $key => $value) { + $key = (string) $key; + $existing = $root->entry($key); + if ($existing === null) { + $newNode = $this->buildNode($value) ?? new MapNode(); + $stdIdx = array_search($key, $stdKeys, true); + $insertedBefore = false; + if ($stdIdx !== false) { + for ($i = $stdIdx + 1; $i < count($stdKeys); $i++) { + $followingKey = (string) $stdKeys[$i]; + $followingEntry = $root->entry($followingKey); + if ($followingEntry !== null) { + $root->insertEntryBefore($followingEntry, $key, $newNode); + $insertedBefore = true; + break; + } + } + } + if (!$insertedBefore) { + $root->appendChildInternal(new MapEntry(new ScalarNode($key), $newNode)); + } + continue; + } + if ($this->valuesEqual($existing->getValue(), $value)) { + continue; + } + $existing->setValueInternal($this->buildNode($value) ?? new MapNode()); + } + } + + /** + * Compare an existing AST node's logical value against a + * stdClass-or-array-or-scalar value drawn from the stdClass + * facade. Returns true when they would emit identically. Used + * to skip rebuilding subtrees that did not change. + */ + private function valuesEqual(Node $node, mixed $value): bool + { + $nodeValue = $this->nodeToArray($node); + $stdValue = $this->stdClassToArray($value); + return $nodeValue === $stdValue; + } + + /** + * Recursively convert a stdClass-or-array tree into the same + * shape that nodeToArray produces, so the two can be compared + * with === in valuesEqual. + */ + private function stdClassToArray(mixed $value): mixed + { + if ($value instanceof stdClass) { + $out = []; + foreach ((array) $value as $k => $v) { + $out[(string) $k] = $this->stdClassToArray($v); + } + return $out; + } + if (is_array($value)) { + $out = []; + foreach ($value as $k => $v) { + $out[$k] = $this->stdClassToArray($v); + } + return $out; + } + return $value; + } + + /** + * Build a value-position AST node from a plain PHP value. + * Maps stdClass to MapNode, list-arrays to SequenceNode, + * associative arrays to MapNode, scalars to ScalarNode. Returns + * null when the input is an empty stdClass with no properties + * (the caller decides whether to coerce that to an empty map + * or a null root). + */ + private function buildNode(mixed $value): MapNode|SequenceNode|ScalarNode|null + { + if ($value instanceof stdClass) { + $map = new MapNode(); + foreach ((array) $value as $key => $child) { + $childNode = $this->buildNode($child) ?? new MapNode(); + $entry = new MapEntry(new ScalarNode((string) $key), $childNode); + $map->appendChildInternal($entry); + } + return $map; + } + if (is_array($value)) { + $isList = $value === [] || array_is_list($value); + if ($isList) { + $seq = new SequenceNode(); + foreach ($value as $child) { + $childNode = $this->buildNode($child) ?? new ScalarNode(null); + $seq->appendChildInternal(new SequenceItem($childNode)); + } + return $seq; + } + $map = new MapNode(); + foreach ($value as $key => $child) { + $childNode = $this->buildNode($child) ?? new MapNode(); + $entry = new MapEntry(new ScalarNode((string) $key), $childNode); + $map->appendChildInternal($entry); + } + return $map; + } + if ($value === null || is_scalar($value)) { + return new ScalarNode($value); + } + if ($value instanceof Stringable) { + return new ScalarNode((string) $value); } + return null; } public function getName(bool $failIfMissing = false): string diff --git a/test/fixtures/roundtrip/changelog-with-comments.yml b/test/fixtures/roundtrip/changelog-with-comments.yml new file mode 100644 index 0000000..8c2ccca --- /dev/null +++ b/test/fixtures/roundtrip/changelog-with-comments.yml @@ -0,0 +1,26 @@ +--- +# Changelog for the Test component. +# Newest entries first. + +3.0.0: + api: 3.0.0 + state: + release: stable + api: stable + date: 2026-02-28 + license: + identifier: BSD-2-Clause + uri: http://www.horde.org/licenses/bsd + notes: First stable release. + +# Pre-release line below. +2.1.0: + api: 1.0.0 + state: + release: stable + api: stable + date: 2021-10-24 + license: + identifier: BSD-2-Clause + uri: http://www.horde.org/licenses/bsd + notes: Backport fixes. diff --git a/test/fixtures/roundtrip/with-eol-comments.horde.yml b/test/fixtures/roundtrip/with-eol-comments.horde.yml new file mode 100644 index 0000000..ced48f1 --- /dev/null +++ b/test/fixtures/roundtrip/with-eol-comments.horde.yml @@ -0,0 +1,7 @@ +id: TestComponent # the stable component id +name: TestComponent +vendor: horde +type: library # always library for our packages +version: + release: 1.2.3 # current target + api: 1.0.0 diff --git a/test/fixtures/roundtrip/with-header-comments.horde.yml b/test/fixtures/roundtrip/with-header-comments.horde.yml new file mode 100644 index 0000000..3acc016 --- /dev/null +++ b/test/fixtures/roundtrip/with-header-comments.horde.yml @@ -0,0 +1,19 @@ +# Component manifest header +# Maintained by hand. Do not auto-rewrite blindly. + +id: TestComponent +name: TestComponent + +# Bump on every release. +vendor: horde +type: library + +# Version block +version: + release: 1.2.3 + api: 1.0.0 + +# State block +state: + release: stable + api: stable diff --git a/test/unit/RoundTripChangelogTest.php b/test/unit/RoundTripChangelogTest.php new file mode 100644 index 0000000..e8c788b --- /dev/null +++ b/test/unit/RoundTripChangelogTest.php @@ -0,0 +1,96 @@ +assertSame($source, $output); + } + + public function testCommentsSurviveAddingNewVersion(): void + { + $path = self::FIXTURE_DIR . '/changelog-with-comments.yml'; + + $file = new ChangelogYmlFile($path); + $file->addVersionEntry('3.1.0', [ + 'api' => '3.0.0', + 'state' => ['release' => 'stable', 'api' => 'stable'], + 'date' => '2026-09-01', + 'license' => [ + 'identifier' => 'BSD-2-Clause', + 'uri' => 'http://www.horde.org/licenses/bsd', + ], + 'notes' => 'New stable.', + ]); + $output = (string) $file; + + // Header banner survives. + $this->assertStringContainsString("# Changelog for the Test component.\n", $output); + $this->assertStringContainsString("# Newest entries first.\n", $output); + + // Inter-entry comment between 3.0.0 and 2.1.0 survives. + $this->assertStringContainsString("# Pre-release line below.\n", $output); + + // The new version is in the output. + $this->assertStringContainsString("3.1.0:\n", $output); + $this->assertStringContainsString('New stable.', $output); + + // Existing entries are still there verbatim. + $this->assertStringContainsString("3.0.0:\n", $output); + $this->assertStringContainsString("2.1.0:\n", $output); + } + + public function testNewVersionLandsAtVersionCompareSortedPosition(): void + { + $path = self::FIXTURE_DIR . '/changelog-with-comments.yml'; + + $file = new ChangelogYmlFile($path); + $file->addVersionEntry('3.1.0', [ + 'api' => '3.0.0', + 'state' => ['release' => 'stable', 'api' => 'stable'], + 'date' => '2026-09-01', + 'license' => [ + 'identifier' => 'BSD-2-Clause', + 'uri' => 'http://www.horde.org/licenses/bsd', + ], + 'notes' => 'New stable.', + ]); + $output = (string) $file; + + // 3.1.0 must appear before 3.0.0 on disk; 3.0.0 must appear + // before 2.1.0. version_compare order is reflected in the + // emitted YAML, not just in getVersions(). + $pos310 = strpos($output, '3.1.0:'); + $pos300 = strpos($output, '3.0.0:'); + $pos210 = strpos($output, '2.1.0:'); + $this->assertNotFalse($pos310); + $this->assertNotFalse($pos300); + $this->assertNotFalse($pos210); + $this->assertLessThan($pos300, $pos310); + $this->assertLessThan($pos210, $pos300); + } +} diff --git a/test/unit/RoundTripFidelityTest.php b/test/unit/RoundTripFidelityTest.php new file mode 100644 index 0000000..f541e24 --- /dev/null +++ b/test/unit/RoundTripFidelityTest.php @@ -0,0 +1,120 @@ +assertSame($source, $output); + } + + public function testEolCommentsRoundTripWithoutMutation(): void + { + $path = self::FIXTURE_DIR . '/with-eol-comments.horde.yml'; + $source = file_get_contents($path); + + $file = new HordeYmlFile($path); + $output = (string) $file; + + $this->assertSame($source, $output); + } + + public function testHeaderCommentsSurviveSingleEntryMutation(): void + { + $path = self::FIXTURE_DIR . '/with-header-comments.horde.yml'; + + $file = new HordeYmlFile($path); + $file->setName('Renamed'); + $output = (string) $file; + + // The new name lands at the same position. + $this->assertStringContainsString("name: Renamed\n", $output); + + // The header banner survives. + $this->assertStringContainsString("# Component manifest header\n", $output); + $this->assertStringContainsString("# Maintained by hand. Do not auto-rewrite blindly.\n", $output); + + // Inter-entry comments survive. + $this->assertStringContainsString("# Bump on every release.\n", $output); + $this->assertStringContainsString("# Version block\n", $output); + $this->assertStringContainsString("# State block\n", $output); + + // Untouched entries emit verbatim. + $this->assertStringContainsString("id: TestComponent\n", $output); + $this->assertStringContainsString("vendor: horde\n", $output); + $this->assertStringContainsString("type: library\n", $output); + } + + public function testEolCommentsSurviveOnUnchangedEntries(): void + { + $path = self::FIXTURE_DIR . '/with-eol-comments.horde.yml'; + + $file = new HordeYmlFile($path); + // Mutate vendor only. Other EOL comments stay. + $file->setVendor('maintaina'); + $output = (string) $file; + + // Mutation took effect. + $this->assertStringContainsString("vendor: maintaina", $output); + + // EOL comments on unchanged entries survive verbatim. + $this->assertStringContainsString("id: TestComponent # the stable component id\n", $output); + $this->assertStringContainsString("type: library # always library for our packages\n", $output); + } + + public function testNewKeyAppendsWithoutDisturbingComments(): void + { + $path = self::FIXTURE_DIR . '/with-header-comments.horde.yml'; + + $file = new HordeYmlFile($path); + $file->setHomePage('https://www.horde.org/example'); + $output = (string) $file; + + // New key is in the output. + $this->assertStringContainsString('homepage: ', $output); + $this->assertStringContainsString('https://www.horde.org/example', $output); + + // Original comments survive. + $this->assertStringContainsString("# Component manifest header\n", $output); + $this->assertStringContainsString("# Bump on every release.\n", $output); + } + + public function testValueStaysUntouchedWhenSetToSameValue(): void + { + // Setting an entry to its current value should be a no-op + // for round-trip purposes: the AST entry's value node and + // surrounding trivia stay untouched. + $path = self::FIXTURE_DIR . '/with-header-comments.horde.yml'; + $source = file_get_contents($path); + + $file = new HordeYmlFile($path); + $file->setName($file->getName()); + $output = (string) $file; + + $this->assertSame($source, $output); + } +}