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.*#', + ), + ]; + } +}