Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion packages/guides-cli/src/Config/XmlFileLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

namespace phpDocumentor\Guides\Cli\Config;

use DOMAttr;
use DOMElement;
use Symfony\Component\Config\Loader\FileLoader;
use Symfony\Component\Config\Util\Exception\XmlParsingException;
use Symfony\Component\Config\Util\XmlUtils;
Expand All @@ -22,6 +24,7 @@
use function is_array;
use function is_string;
use function sprintf;
use function trim;

final class XmlFileLoader extends FileLoader
{
Expand All @@ -36,8 +39,42 @@ public function load(mixed $resource, string|null $type = null): array
throw new XmlParsingException(sprintf('The XML file "%s" is not valid.', $resource));
}

// convertDomElementToArray() below runs phpize() on every attribute value, which turns
// "0.10" into 0.1 and "1.0" into 1. The <project> attributes are all strings, so they are
// read from the DOM and the element is detached before that call.
$projectConfig = null;
$project = $this->firstChildElement($element, 'project');
if ($project !== null) {
$projectConfig = [];
foreach ($project->attributes as $attribute) {
if (!($attribute instanceof DOMAttr)) {
continue;
}

$value = $attribute->value;

// Files that adopted the version="'3.0'" workaround against the old phpize()
// call must keep rendering 3.0; the quotes are needed nowhere else.
if ($attribute->name === 'version' || $attribute->name === 'release') {
$value = trim($value, "'");
}

$projectConfig[$attribute->name] = $value;
}

$project->parentNode?->removeChild($project);
}

// A file whose only child was <project> leaves an empty root here, for which
// convertDomElementToArray() returns null.
$rootConfig = XmlUtils::convertDomElementToArray($element);
assert(is_array($rootConfig));
if (!is_array($rootConfig)) {
$rootConfig = [];
}

if ($projectConfig !== null) {
$rootConfig['project'] = $projectConfig;
}

$configs = [];
if (isset($rootConfig['import'])) {
Expand All @@ -56,6 +93,21 @@ public function load(mixed $resource, string|null $type = null): array
return $configs;
}

/**
* Returns the first DIRECT child of the given name. A subtree search would also find a <project>
* nested in an <extension>, which the schema allows, and take it for the project configuration.
*/
private function firstChildElement(DOMElement $element, string $name): DOMElement|null
{
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement && $child->localName === $name) {
return $child;
}
}

return null;
}

public function supports(mixed $resource, string|null $type = null): bool
{
return $type === 'xml' && is_string($resource);
Expand Down
142 changes: 142 additions & 0 deletions packages/guides-cli/tests/unit/Config/XmlFileLoaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/

namespace phpDocumentor\Guides\Cli\Config;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\FileLocator;

use function count;
use function file_put_contents;
use function sys_get_temp_dir;
use function tempnam;
use function unlink;

/**
* The integration fixtures render a whole site and prove what a reader sees. This covers the shapes
* an author is unlikely to write but the loader handles explicitly, and pins which attributes the
* backward-compatible quote stripping is allowed to touch.
*/
final class XmlFileLoaderTest extends TestCase
{
private string|null $file = null;

protected function tearDown(): void
{
if ($this->file === null) {
return;
}

unlink($this->file);
$this->file = null;
}

/** @param array<string, string> $expected */
#[DataProvider('provideProjectElements')]
public function testReadsProjectAttributes(string $projectElement, array $expected): void
{
$root = $this->loadRoot($projectElement);

// Asserted separately from the contents: an empty project config and a missing one are two
// different states, and `?? []` would report them as the same.
self::assertArrayHasKey('project', $root);
self::assertSame($expected, $root['project']);
}

/** @return iterable<string, array{string, array<string, string>}> */
public static function provideProjectElements(): iterable
{
// phpize() would turn this into the float 0.1. The DOM keeps the digit.
yield 'a version is read as written' => [
'<project title="T" version="0.10" release="0.10.0"/>',
['title' => 'T', 'version' => '0.10', 'release' => '0.10.0'],
];

// release gets no special treatment beyond version, and a release is not always longer than
// the version it belongs to: `1.0` is the shape phpize turns into the int 1.
yield 'a trailing zero survives in release as well' => [
'<project version="1.0" release="1.0"/>',
['version' => '1.0', 'release' => '1.0'],
];

// The workaround for the coercion this branch removes; still honoured for files that adopted it.
yield 'single quotes are stripped from version and release' => [
'<project version="\'3.0\'" release="\'3.0.0\'"/>',
['version' => '3.0', 'release' => '3.0.0'],
];

// The stripping is backward compatibility for two attributes, not a general unquoting rule:
// a title that really is quoted keeps its quotes.
yield 'single quotes are kept everywhere else' => [
'<project title="\'T\'" copyright="\'2026\'"/>',
['title' => "'T'", 'copyright' => "'2026'"],
];

yield 'an attribute-less project yields an empty project config' => [
'<project/>',
[],
];
}

/**
* Detaching <project> from a file that has no other child leaves convertDomElementToArray() with an
* empty element, for which it returns null rather than an array.
*/
public function testAProjectOnlyFileStillYieldsItsProject(): void
{
self::assertSame(
['version' => '0.10'],
$this->loadRoot('<project version="0.10"/>')['project'] ?? [],
);
}

/** The schema lets an extension carry arbitrary children; a <project> among them is not the project. */
public function testAProjectInsideAnExtensionIsNotRead(): void
{
$root = $this->loadRoot(
'<extension class="Some\Extension"><project version="9.9"/></extension>'
. '<project version="0.10"/>',
);

self::assertSame(['version' => '0.10'], $root['project'] ?? []);
}

public function testAFileWithoutAProjectHasNoProjectKey(): void
{
self::assertArrayNotHasKey('project', $this->loadRoot(''));
}

/**
* Loads a guides.xml built around $body and returns the config of the file itself, which the loader
* appends last.
*
* @return array<string, mixed>
*/
private function loadRoot(string $body): array
{
$file = tempnam(sys_get_temp_dir(), 'guides-xml-');
self::assertIsString($file);
$this->file = $file;

file_put_contents(
$file,
'<?xml version="1.0" encoding="UTF-8" ?>'
. '<guides xmlns="https://www.phpdoc.org/guides">' . $body . '</guides>',
);

$configs = (new XmlFileLoader(new FileLocator()))->load($file, 'xml');

return $configs[count($configs) - 1];
}
}
43 changes: 10 additions & 33 deletions packages/guides/src/DependencyInjection/GuidesExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
use function is_int;
use function is_string;
use function pathinfo;
use function trim;
use function var_export;

final class GuidesExtension extends Extension implements CompilerPassInterface, ConfigurationInterface, PrependExtensionInterface
Expand All @@ -57,6 +56,14 @@ public function getConfigTreeBuilder(): TreeBuilder
$rootNode = $treeBuilder->getRootNode();
assert($rootNode instanceof ArrayNodeDefinition);

// XmlFileLoader hands version/release over as strings, but
// ContainerFactory::loadExtensionConfig() feeds a raw PHP array into this same tree, where the
// value can be a native float — and `(string) 3.0` is "3". var_export keeps the literal;
// null passes through so the isset() below sees "not configured" rather than "NULL".
$keepNonStringLiteral = static fn ($value) => $value === null || is_string($value) || is_int($value)
? $value
: var_export($value, true);

$rootNode
->fixXmlConfig('template')
->fixXmlConfig('inventory', 'inventories')
Expand All @@ -65,40 +72,10 @@ public function getConfigTreeBuilder(): TreeBuilder
->children()
->scalarNode('title')->end()
->scalarNode('version')
->beforeNormalization()
->always(
// We need to revert the phpize call in XmlUtils. Version is always a string!
static function ($value) {
if (!is_int($value) && !is_string($value)) {
return var_export($value, true);
}

if (is_string($value)) {
return trim($value, "'");
}

return $value;
},
)
->end()
->beforeNormalization()->always($keepNonStringLiteral)->end()
->end()
->scalarNode('release')
->beforeNormalization()
->always(
// We need to revert the phpize call in XmlUtils. Version is always a string!
static function ($value) {
if (!is_int($value) && !is_string($value)) {
return var_export($value, true);
}

if (is_string($value)) {
return trim($value, "'");
}

return $value;
},
)
->end()
->beforeNormalization()->always($keepNonStringLiteral)->end()
->end()
->scalarNode('copyright')->end()
->end()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

namespace phpDocumentor\Guides\DependencyInjection;

use phpDocumentor\Guides\Settings\ProjectSettings;
use phpDocumentor\Guides\Settings\SettingsManager;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand Down Expand Up @@ -84,5 +86,48 @@ public static function provideConfigs(): iterable
],
$sanitizerAssertions,
];

// ContainerFactory::loadExtensionConfig() feeds a raw PHP array into this tree without going
// through XmlFileLoader, so a native float never meets the string handling that lives there.
// `(string) 3.0` is "3": the trailing zero has to survive this path on its own.
yield 'project version and release as float' => [
[['project' => ['version' => 3.0, 'release' => 3.0]]],
static function (ContainerBuilder $container): void {
$settings = self::projectSettings($container);
self::assertSame('3.0', $settings->getVersion());
self::assertSame('3.0', $settings->getRelease());
},
];

yield 'project version as string keeps its own form' => [
[['project' => ['version' => '0.10']]],
static function (ContainerBuilder $container): void {
self::assertSame('0.10', self::projectSettings($container)->getVersion());
},
];

// An explicit null means "not configured", so the default has to survive it. Turning it into
// a literal renders the string "NULL" as the project version.
yield 'project version as null leaves the default' => [
[['project' => ['version' => null]]],
static function (ContainerBuilder $container): void {
self::assertSame('', self::projectSettings($container)->getVersion());
},
];
}

/** Reads back the ProjectSettings the extension hands to the SettingsManager definition. */
private static function projectSettings(ContainerBuilder $container): ProjectSettings
{
$calls = array_values(array_filter(
$container->getDefinition(SettingsManager::class)->getMethodCalls(),
static fn (array $call) => $call[0] === 'setProjectSettings',
));

self::assertCount(1, $calls);
$settings = $calls[0][1][0];
self::assertInstanceOf(ProjectSettings::class, $settings);

return $settings;
}
}
13 changes: 0 additions & 13 deletions phpunit-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -557,19 +557,6 @@
<issue><![CDATA[Symfony\Component\DependencyInjection\Loader\FileLoader::import(): Implicitly marking parameter $sourceResource as nullable is deprecated, the explicit nullable type must be used instead]]></issue>
</line>
</file>
<file path="vendor/symfony/config/Loader/FileLoader.php">
<line number="34" hash="c57dd86a228e45682dc05033af2f03c30e27ad07">
<issue><![CDATA[Symfony\Component\Config\Loader\FileLoader::__construct(): Implicitly marking parameter $env as nullable is deprecated, the explicit nullable type must be used instead]]></issue>
</line>
<line number="73" hash="17cce718f8ca40d7d3cb0c1db676397650eed560">
<issue><![CDATA[Symfony\Component\Config\Loader\FileLoader::import(): Implicitly marking parameter $type as nullable is deprecated, the explicit nullable type must be used instead]]></issue>
<issue><![CDATA[Symfony\Component\Config\Loader\FileLoader::import(): Implicitly marking parameter $sourceResource as nullable is deprecated, the explicit nullable type must be used instead]]></issue>
</line>
<line number="136" hash="7de240494deefcf3ce9db14ab25bbdab945c4d91">
<issue><![CDATA[Symfony\Component\Config\Loader\FileLoader::doImport(): Implicitly marking parameter $type as nullable is deprecated, the explicit nullable type must be used instead]]></issue>
<issue><![CDATA[Symfony\Component\Config\Loader\FileLoader::doImport(): Implicitly marking parameter $sourceResource as nullable is deprecated, the explicit nullable type must be used instead]]></issue>
</line>
</file>
<file path="vendor/symfony/config/Loader/Loader.php">
<line number="26" hash="14142d9ff33363b11652cb9db2e5c22f06cc3353">
<issue><![CDATA[Symfony\Component\Config\Loader\Loader::__construct(): Implicitly marking parameter $env as nullable is deprecated, the explicit nullable type must be used instead]]></issue>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Some Document - Render guides</title>

</head>
<body>
<!-- content start -->
<div class="section" id="some-document">
<h1>Some Document</h1>

<p>Project Render guides in version 0.10, release 0.10.0.</p>

</div>
<!-- content end -->
</body>
</html>
Loading
Loading