Skip to content

Commit 91fcd78

Browse files
committed
Implement XML configuration parser
This ships a parser and the expected schema to ease the validation of XML documents. Signed-off-by: Luís Cobucci <[email protected]>
1 parent 5d34621 commit 91fcd78

File tree

8 files changed

+333
-2
lines changed

8 files changed

+333
-2
lines changed

box.json.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"main": "bin/roave-backward-compatibility-check.php",
88
"output": "dist/roave-backward-compatibility-check.phar",
99
"files-bin": [
10+
"resources/schema.xsd",
1011
"LICENSE",
1112
"vendor/composer/composer/LICENSE"
1213
],

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
"description": "Tool to compare two revisions of a public API to check for BC breaks",
44
"require": {
55
"php": "~8.1.0 || ~8.2.0",
6+
"ext-dom": "*",
67
"ext-json": "*",
8+
"ext-libxml": "*",
9+
"ext-simplexml": "*",
710
"azjezz/psl": "^2.3.1",
811
"composer/composer": "^2.5.1",
912
"nikic/php-parser": "^4.15.3",

composer.lock

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/schema.xsd

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
3+
<xs:annotation>
4+
<xs:appinfo source="https://github.com/Roave/BackwardCompatibilityCheck"/>
5+
6+
<xs:documentation source="https://github.com/Roave/BackwardCompatibilityCheck">
7+
This schema file defines the structure for the XML configuration file of roave/backward-compatibility-check.
8+
</xs:documentation>
9+
</xs:annotation>
10+
11+
<xs:element name="roave-bc-check" type="bcCheckType" />
12+
13+
<xs:complexType name="bcCheckType">
14+
<xs:sequence>
15+
<xs:element name="baseline" type="baselineType" minOccurs="0" />
16+
</xs:sequence>
17+
</xs:complexType>
18+
19+
<xs:complexType name="baselineType">
20+
<xs:sequence>
21+
<xs:element name="ignored-regex" minOccurs="0" maxOccurs="unbounded" type="xs:string" />
22+
</xs:sequence>
23+
</xs:complexType>
24+
</xs:schema>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Roave\BackwardCompatibility\Configuration;
6+
7+
use LibXMLError;
8+
use RuntimeException;
9+
10+
use function sprintf;
11+
use function trim;
12+
13+
use const PHP_EOL;
14+
15+
/** @internal */
16+
final class InvalidConfigurationStructure extends RuntimeException
17+
{
18+
/** @param list<LibXMLError> $errors */
19+
public static function fromLibxmlErrors(array $errors): self
20+
{
21+
$message = 'The provided configuration is invalid, errors:' . PHP_EOL;
22+
23+
foreach ($errors as $error) {
24+
$message .= sprintf(
25+
' - [Line %d] %s' . PHP_EOL,
26+
$error->line,
27+
trim($error->message),
28+
);
29+
}
30+
31+
return new self($message);
32+
}
33+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
<?php
2+
23
declare(strict_types=1);
34

45
namespace Roave\BackwardCompatibility\Configuration;
56

67
interface ParseConfigurationFile
78
{
9+
/** @throws InvalidConfigurationStructure When an incorrect file was found on the directory. */
810
public function parse(string $currentDirectory): Configuration;
911
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Roave\BackwardCompatibility\Configuration;
6+
7+
use DOMDocument;
8+
use Psl\File;
9+
use Roave\BackwardCompatibility\Baseline;
10+
use SimpleXMLElement;
11+
12+
use function array_values;
13+
use function libxml_get_errors;
14+
use function libxml_use_internal_errors;
15+
16+
/** @internal */
17+
final class ParseXmlConfigurationFile implements ParseConfigurationFile
18+
{
19+
private const CONFIGURATION_FILENAME = '.roave-backward-compatibility-check.xml';
20+
21+
private const SCHEMA = __DIR__ . '/../../resources/schema.xsd';
22+
23+
public function parse(string $currentDirectory): Configuration
24+
{
25+
$filename = $currentDirectory . '/' . self::CONFIGURATION_FILENAME;
26+
27+
try {
28+
$xmlContents = File\read($filename);
29+
30+
$this->validateStructure($xmlContents);
31+
} catch (File\Exception\InvalidArgumentException) {
32+
return Configuration::default();
33+
}
34+
35+
$configuration = new SimpleXMLElement($xmlContents);
36+
37+
return Configuration::fromFile(
38+
$this->parseBaseline($configuration),
39+
$filename,
40+
);
41+
}
42+
43+
private function validateStructure(string $xmlContents): void
44+
{
45+
$previousConfiguration = libxml_use_internal_errors(true);
46+
47+
$xmlDocument = new DOMDocument();
48+
$xmlDocument->loadXML($xmlContents);
49+
50+
$configurationIsValid = $xmlDocument->schemaValidate(self::SCHEMA);
51+
52+
$parsingErrors = array_values(libxml_get_errors());
53+
libxml_use_internal_errors($previousConfiguration);
54+
55+
if ($configurationIsValid) {
56+
return;
57+
}
58+
59+
throw InvalidConfigurationStructure::fromLibxmlErrors($parsingErrors);
60+
}
61+
62+
private function parseBaseline(SimpleXMLElement $element): Baseline
63+
{
64+
$ignoredItems = [];
65+
66+
foreach ($element->xpath('baseline/ignored-regex') ?? [] as $node) {
67+
$ignoredItems[] = (string) $node;
68+
}
69+
70+
return Baseline::fromList(...$ignoredItems);
71+
}
72+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RoaveTest\BackwardCompatibility\Configuration;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Psl\Env;
9+
use Psl\File;
10+
use Psl\Filesystem;
11+
use Psl\Shell;
12+
use Roave\BackwardCompatibility\Baseline;
13+
use Roave\BackwardCompatibility\Configuration\Configuration;
14+
use Roave\BackwardCompatibility\Configuration\InvalidConfigurationStructure;
15+
use Roave\BackwardCompatibility\Configuration\ParseXmlConfigurationFile;
16+
17+
final class ParseXmlConfigurationFileTest extends TestCase
18+
{
19+
private string $temporaryDirectory;
20+
21+
/** @before */
22+
public function prepareFilesystem(): void
23+
{
24+
$this->temporaryDirectory = Filesystem\create_temporary_file(
25+
Env\temp_dir(),
26+
'roave-backward-compatibility-xml-config-test',
27+
);
28+
29+
self::assertNotEmpty($this->temporaryDirectory);
30+
self::assertFileExists($this->temporaryDirectory);
31+
32+
Filesystem\delete_file($this->temporaryDirectory);
33+
Filesystem\create_directory($this->temporaryDirectory);
34+
}
35+
36+
/** @after */
37+
public function cleanUpFilesystem(): void
38+
{
39+
Shell\execute('rm', ['-rf', $this->temporaryDirectory]);
40+
}
41+
42+
/** @test */
43+
public function defaultConfigurationShouldBeUsedWhenFileDoesNotExist(): void
44+
{
45+
$config = (new ParseXmlConfigurationFile())->parse($this->temporaryDirectory);
46+
47+
self::assertEquals(Configuration::default(), $config);
48+
}
49+
50+
/**
51+
* @test
52+
* @dataProvider invalidConfiguration
53+
*/
54+
public function exceptionShouldBeRaisedWhenStructureIsInvalid(
55+
string $xmlContents,
56+
string $expectedError,
57+
): void {
58+
File\write($this->temporaryDirectory . '/.roave-backward-compatibility-check.xml', $xmlContents);
59+
60+
$this->expectException(InvalidConfigurationStructure::class);
61+
$this->expectExceptionMessage($expectedError);
62+
63+
(new ParseXmlConfigurationFile())->parse($this->temporaryDirectory);
64+
}
65+
66+
/** @return iterable<string, array{string, string}> */
67+
public static function invalidConfiguration(): iterable
68+
{
69+
yield 'invalid root element' => [
70+
<<<'XML'
71+
<?xml version="1.0" encoding="UTF-8" ?>
72+
<anything />
73+
XML,
74+
'[Line 2] Element \'anything\': No matching global declaration available for the validation root',
75+
];
76+
77+
yield 'invalid root child' => [
78+
<<<'XML'
79+
<?xml version="1.0" encoding="UTF-8" ?>
80+
<roave-bc-check>
81+
<something />
82+
</roave-bc-check>
83+
XML,
84+
'[Line 3] Element \'something\': This element is not expected. Expected is ( baseline )',
85+
];
86+
87+
yield 'multiple baseline tags' => [
88+
<<<'XML'
89+
<?xml version="1.0" encoding="UTF-8" ?>
90+
<roave-bc-check>
91+
<baseline />
92+
<baseline />
93+
</roave-bc-check>
94+
XML,
95+
'[Line 4] Element \'baseline\': This element is not expected',
96+
];
97+
98+
yield 'invalid baseline child' => [
99+
<<<'XML'
100+
<?xml version="1.0" encoding="UTF-8" ?>
101+
<roave-bc-check>
102+
<baseline>
103+
<nothing />
104+
</baseline>
105+
</roave-bc-check>
106+
XML,
107+
'[Line 4] Element \'nothing\': This element is not expected. Expected is ( ignored-regex )',
108+
];
109+
110+
yield 'invalid ignored item type' => [
111+
<<<'XML'
112+
<?xml version="1.0" encoding="UTF-8" ?>
113+
<roave-bc-check>
114+
<baseline>
115+
<ignored-regex>
116+
<something-else />
117+
</ignored-regex>
118+
</baseline>
119+
</roave-bc-check>
120+
XML,
121+
'[Line 4] Element \'ignored-regex\': Element content is not allowed, because the type definition is simple',
122+
];
123+
}
124+
125+
/**
126+
* @test
127+
* @dataProvider validConfiguration
128+
*/
129+
public function baselineShouldBeParsed(
130+
string $xmlContents,
131+
Baseline $expectedBaseline,
132+
): void {
133+
File\write($this->temporaryDirectory . '/.roave-backward-compatibility-check.xml', $xmlContents);
134+
135+
self::assertEquals(
136+
Configuration::fromFile(
137+
$expectedBaseline,
138+
$this->temporaryDirectory . '/.roave-backward-compatibility-check.xml',
139+
),
140+
(new ParseXmlConfigurationFile())->parse($this->temporaryDirectory),
141+
);
142+
}
143+
144+
/** @return iterable<string, array{string, Baseline}> */
145+
public static function validConfiguration(): iterable
146+
{
147+
yield 'no baseline' => [
148+
<<<'XML'
149+
<?xml version="1.0" encoding="UTF-8" ?>
150+
<roave-bc-check />
151+
XML,
152+
Baseline::empty(),
153+
];
154+
155+
yield 'empty baseline' => [
156+
<<<'XML'
157+
<?xml version="1.0" encoding="UTF-8" ?>
158+
<roave-bc-check>
159+
<baseline />
160+
</roave-bc-check>
161+
XML,
162+
Baseline::empty(),
163+
];
164+
165+
yield 'baseline with single element' => [
166+
<<<'XML'
167+
<?xml version="1.0" encoding="UTF-8" ?>
168+
<roave-bc-check>
169+
<baseline>
170+
<ignored-regex>#\[BC\] CHANGED: The parameter \$a of TestArtifact\\TheClass\#method.*#</ignored-regex>
171+
</baseline>
172+
</roave-bc-check>
173+
XML,
174+
Baseline::fromList('#\[BC\] CHANGED: The parameter \$a of TestArtifact\\\\TheClass\#method.*#'),
175+
];
176+
177+
yield 'baseline with multiple elements' => [
178+
<<<'XML'
179+
<?xml version="1.0" encoding="UTF-8" ?>
180+
<roave-bc-check>
181+
<baseline>
182+
<ignored-regex>#\[BC\] CHANGED: The parameter \$a of TestArtifact\\TheClass\#method.*#</ignored-regex>
183+
<ignored-regex>#\[BC\] ADDED: Method .*\(\) was added to interface TestArtifact\\TheInterface.*#</ignored-regex>
184+
</baseline>
185+
</roave-bc-check>
186+
XML,
187+
Baseline::fromList(
188+
'#\[BC\] CHANGED: The parameter \$a of TestArtifact\\\\TheClass\#method.*#',
189+
'#\[BC\] ADDED: Method .*\(\) was added to interface TestArtifact\\\\TheInterface.*#',
190+
),
191+
];
192+
}
193+
}

0 commit comments

Comments
 (0)