diff --git a/box.json.dist b/box.json.dist
index 987325ce..d1a9f091 100644
--- a/box.json.dist
+++ b/box.json.dist
@@ -7,6 +7,7 @@
"main": "bin/roave-backward-compatibility-check.php",
"output": "dist/roave-backward-compatibility-check.phar",
"files-bin": [
+ "resources/schema.xsd",
"LICENSE",
"vendor/composer/composer/LICENSE"
],
diff --git a/composer.json b/composer.json
index aaae80ef..85364649 100644
--- a/composer.json
+++ b/composer.json
@@ -3,7 +3,10 @@
"description": "Tool to compare two revisions of a public API to check for BC breaks",
"require": {
"php": "~8.1.0 || ~8.2.0",
+ "ext-dom": "*",
"ext-json": "*",
+ "ext-libxml": "*",
+ "ext-simplexml": "*",
"azjezz/psl": "^2.3.1",
"composer/composer": "^2.5.1",
"nikic/php-parser": "^4.15.3",
diff --git a/composer.lock b/composer.lock
index 79732ee0..d7c4172c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "3e7bc55a493461b07c54b86cef013320",
+ "content-hash": "afaf26813115656f49a708d620d8f393",
"packages": [
{
"name": "azjezz/psl",
@@ -6961,7 +6961,10 @@
"prefer-lowest": false,
"platform": {
"php": "~8.1.0 || ~8.2.0",
- "ext-json": "*"
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-simplexml": "*"
},
"platform-dev": [],
"platform-overrides": {
diff --git a/resources/schema.xsd b/resources/schema.xsd
new file mode 100644
index 00000000..14cc97f4
--- /dev/null
+++ b/resources/schema.xsd
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ This schema file defines the structure for the XML configuration file of roave/backward-compatibility-check.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Configuration/InvalidConfigurationStructure.php b/src/Configuration/InvalidConfigurationStructure.php
new file mode 100644
index 00000000..8365351f
--- /dev/null
+++ b/src/Configuration/InvalidConfigurationStructure.php
@@ -0,0 +1,33 @@
+ $errors */
+ public static function fromLibxmlErrors(array $errors): self
+ {
+ $message = 'The provided configuration is invalid, errors:' . PHP_EOL;
+
+ foreach ($errors as $error) {
+ $message .= sprintf(
+ ' - [Line %d] %s' . PHP_EOL,
+ $error->line,
+ trim($error->message),
+ );
+ }
+
+ return new self($message);
+ }
+}
diff --git a/src/Configuration/ParseConfigurationFile.php b/src/Configuration/ParseConfigurationFile.php
index 4717b848..ffc3723d 100644
--- a/src/Configuration/ParseConfigurationFile.php
+++ b/src/Configuration/ParseConfigurationFile.php
@@ -1,9 +1,11 @@
validateStructure($xmlContents);
+ } catch (File\Exception\InvalidArgumentException) {
+ return Configuration::default();
+ }
+
+ $configuration = new SimpleXMLElement($xmlContents);
+
+ return Configuration::fromFile(
+ $this->parseBaseline($configuration),
+ $filename,
+ );
+ }
+
+ private function validateStructure(string $xmlContents): void
+ {
+ $previousConfiguration = libxml_use_internal_errors(true);
+
+ $xmlDocument = new DOMDocument();
+ $xmlDocument->loadXML($xmlContents);
+
+ $configurationIsValid = $xmlDocument->schemaValidate(self::SCHEMA);
+
+ $parsingErrors = array_values(libxml_get_errors());
+ libxml_use_internal_errors($previousConfiguration);
+
+ if ($configurationIsValid) {
+ return;
+ }
+
+ throw InvalidConfigurationStructure::fromLibxmlErrors($parsingErrors);
+ }
+
+ private function parseBaseline(SimpleXMLElement $element): Baseline
+ {
+ $ignoredItems = [];
+
+ foreach ($element->xpath('baseline/ignored-regex') ?? [] as $node) {
+ $ignoredItems[] = (string) $node;
+ }
+
+ return Baseline::fromList(...$ignoredItems);
+ }
+}
diff --git a/test/unit/Configuration/ParseXmlConfigurationFileTest.php b/test/unit/Configuration/ParseXmlConfigurationFileTest.php
new file mode 100644
index 00000000..661fb51a
--- /dev/null
+++ b/test/unit/Configuration/ParseXmlConfigurationFileTest.php
@@ -0,0 +1,193 @@
+temporaryDirectory = Filesystem\create_temporary_file(
+ Env\temp_dir(),
+ 'roave-backward-compatibility-xml-config-test',
+ );
+
+ self::assertNotEmpty($this->temporaryDirectory);
+ self::assertFileExists($this->temporaryDirectory);
+
+ Filesystem\delete_file($this->temporaryDirectory);
+ Filesystem\create_directory($this->temporaryDirectory);
+ }
+
+ /** @after */
+ public function cleanUpFilesystem(): void
+ {
+ Shell\execute('rm', ['-rf', $this->temporaryDirectory]);
+ }
+
+ /** @test */
+ public function defaultConfigurationShouldBeUsedWhenFileDoesNotExist(): void
+ {
+ $config = (new ParseXmlConfigurationFile())->parse($this->temporaryDirectory);
+
+ self::assertEquals(Configuration::default(), $config);
+ }
+
+ /**
+ * @test
+ * @dataProvider invalidConfiguration
+ */
+ public function exceptionShouldBeRaisedWhenStructureIsInvalid(
+ string $xmlContents,
+ string $expectedError,
+ ): void {
+ File\write($this->temporaryDirectory . '/.roave-backward-compatibility-check.xml', $xmlContents);
+
+ $this->expectException(InvalidConfigurationStructure::class);
+ $this->expectExceptionMessage($expectedError);
+
+ (new ParseXmlConfigurationFile())->parse($this->temporaryDirectory);
+ }
+
+ /** @return iterable */
+ public static function invalidConfiguration(): iterable
+ {
+ yield 'invalid root element' => [
+ <<<'XML'
+
+
+XML,
+ '[Line 2] Element \'anything\': No matching global declaration available for the validation root',
+ ];
+
+ yield 'invalid root child' => [
+ <<<'XML'
+
+
+
+
+XML,
+ '[Line 3] Element \'something\': This element is not expected. Expected is ( baseline )',
+ ];
+
+ yield 'multiple baseline tags' => [
+ <<<'XML'
+
+
+
+
+
+XML,
+ '[Line 4] Element \'baseline\': This element is not expected',
+ ];
+
+ yield 'invalid baseline child' => [
+ <<<'XML'
+
+
+
+
+
+
+XML,
+ '[Line 4] Element \'nothing\': This element is not expected. Expected is ( ignored-regex )',
+ ];
+
+ yield 'invalid ignored item type' => [
+ <<<'XML'
+
+
+
+
+
+
+
+
+XML,
+ '[Line 4] Element \'ignored-regex\': Element content is not allowed, because the type definition is simple',
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider validConfiguration
+ */
+ public function baselineShouldBeParsed(
+ string $xmlContents,
+ Baseline $expectedBaseline,
+ ): void {
+ File\write($this->temporaryDirectory . '/.roave-backward-compatibility-check.xml', $xmlContents);
+
+ self::assertEquals(
+ Configuration::fromFile(
+ $expectedBaseline,
+ $this->temporaryDirectory . '/.roave-backward-compatibility-check.xml',
+ ),
+ (new ParseXmlConfigurationFile())->parse($this->temporaryDirectory),
+ );
+ }
+
+ /** @return iterable */
+ public static function validConfiguration(): iterable
+ {
+ yield 'no baseline' => [
+ <<<'XML'
+
+
+XML,
+ Baseline::empty(),
+ ];
+
+ yield 'empty baseline' => [
+ <<<'XML'
+
+
+
+
+XML,
+ Baseline::empty(),
+ ];
+
+ yield 'baseline with single element' => [
+ <<<'XML'
+
+
+
+ #\[BC\] CHANGED: The parameter \$a of TestArtifact\\TheClass\#method.*#
+
+
+XML,
+ Baseline::fromList('#\[BC\] CHANGED: The parameter \$a of TestArtifact\\\\TheClass\#method.*#'),
+ ];
+
+ yield 'baseline with multiple elements' => [
+ <<<'XML'
+
+
+
+ #\[BC\] CHANGED: The parameter \$a of TestArtifact\\TheClass\#method.*#
+ #\[BC\] ADDED: Method .*\(\) was added to interface TestArtifact\\TheInterface.*#
+
+
+XML,
+ Baseline::fromList(
+ '#\[BC\] CHANGED: The parameter \$a of TestArtifact\\\\TheClass\#method.*#',
+ '#\[BC\] ADDED: Method .*\(\) was added to interface TestArtifact\\\\TheInterface.*#',
+ ),
+ ];
+ }
+}