diff --git a/src/Config/ConfigData.php b/src/Config/ConfigData.php index c841db0..0cfb46b 100644 --- a/src/Config/ConfigData.php +++ b/src/Config/ConfigData.php @@ -7,6 +7,7 @@ final readonly class ConfigData { /** + * @param array $projectRoots * @param list $sniffs * @param list $includePaths * @param list $excludePatterns @@ -14,6 +15,7 @@ * @param string $basePath */ public function __construct( + private array $projectRoots, private array $sniffs, private array $includePaths, private array $excludePatterns, @@ -22,6 +24,12 @@ public function __construct( ) { } + /** @return array */ + public function getProjectRoots(): array + { + return $this->projectRoots; + } + /** @return list */ public function getSniffs(): array { diff --git a/src/Config/ConfigParser.php b/src/Config/ConfigParser.php index 4db00e3..c1fdf82 100644 --- a/src/Config/ConfigParser.php +++ b/src/Config/ConfigParser.php @@ -67,20 +67,47 @@ private function parse(\SimpleXMLElement $root, string $basePath): ConfigData // Register the namespace for xpath queries. $root->registerXPathNamespace('d', self::NAMESPACE_URI); - $sniffs = $this->parseSniffs($root); - $includePaths = $this->parsePaths($root, $basePath); - $excludePatterns = $this->parseExcludePatterns($root); - $entityPaths = $this->parseEntityPaths($root, $basePath); - return new ConfigData( - sniffs: $sniffs, - includePaths: $includePaths, - excludePatterns: $excludePatterns, - entityPaths: $entityPaths, + projectRoots: $this->parseProjectRoots($root, $basePath), + sniffs: $this->parseSniffs($root), + includePaths: $this->parsePaths($root, $basePath), + excludePatterns: $this->parseExcludePatterns($root), + entityPaths: $this->parseEntityPaths($root, $basePath), basePath: $basePath, ); } + /** + * @return array + * @throws ConfigParserException if the element is missing or if + * the base path is not within any of the specified project roots. + */ + private function parseProjectRoots(\SimpleXMLElement $root, string $basePath): array + { + if (!isset($root->project)) { + $name = basename($basePath); + + return [ + dirname($basePath) . '/' . $name => $name, + $basePath => $name, + ]; + } + + $directories = []; + + foreach ($root->project->directory ?: [] as $directory) { + $directoryName = (string) $directory; + $directories[dirname($basePath) . '/' . $directoryName] = $directoryName; + + if ($directory->attributes()->alias) { + $alias = (string) $directory->attributes()->alias; + $directories[dirname($basePath) . '/' . $alias] = $directoryName; + } + } + + return $directories; + } + /** * @return list * @throws ConfigParserException if required elements or attributes are missing. diff --git a/src/Path/EntityResolver.php b/src/Path/EntityResolver.php index 4ce0674..f505943 100644 --- a/src/Path/EntityResolver.php +++ b/src/Path/EntityResolver.php @@ -6,46 +6,57 @@ final class EntityResolver { - /** @var list */ - private array $entityPaths; - private string $extension; /** + * @param array $projectRoots * @param list $entityPaths */ - public function __construct(array $entityPaths, string $extension = 'ent') - { - $this->entityPaths = $entityPaths; + public function __construct( + private readonly array $projectRoots, + private readonly array $entityPaths, + string $extension = 'ent' + ) { $this->extension = ltrim($extension, '.'); } /** - * @return list - * @throws \UnexpectedValueException if any of the entity paths is a directory that cannot be read. + * @return array + * @throws \UnexpectedValueException if the directory cannot be read. */ public function resolve(): array { - $files = []; + $entities = []; foreach ($this->entityPaths as $path) { - if (is_file($path) && str_ends_with($path, '.' . $this->extension)) { - $files[] = str_replace('\\', '/', $path); - continue; + foreach ($this->getEntityFiles($path) as $file) { + $entities += $this->resolveFile($file); } + } - if (!is_dir($path)) { - continue; - } + return $entities; + } - foreach ($this->scanDirectory($path) as $file) { - $files[] = $file; - } + /** + * @return list + * @throws \UnexpectedValueException if the directory cannot be read. + */ + private function getEntityFiles(string $path): array + { + if (is_file($path) && $this->isEntityFile($path)) { + return [$path]; } - sort($files); + if (!is_dir($path)) { + return []; + } + + return $this->scanDirectory($path); + } - return array_values(array_unique($files)); + private function isEntityFile(string $path): bool + { + return str_ends_with($path, '.' . $this->extension); } /** @@ -54,24 +65,119 @@ public function resolve(): array */ private function scanDirectory(string $directory): array { - $found = []; + $files = []; + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, - \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS, - ), + \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS + ) ); /** @var \SplFileInfo $fileInfo */ foreach ($iterator as $fileInfo) { - if ( - $fileInfo->isFile() - && str_ends_with($fileInfo->getPathname(), '.' . $this->extension) - ) { - $found[] = str_replace('\\', '/', $fileInfo->getPathname()); + if ($fileInfo->isFile() && $this->isEntityFile($fileInfo->getPathname())) { + $files[] = str_replace('\\', '/', $fileInfo->getPathname()); + } + } + + return $files; + } + + /** + * @param array $visited + * @return array + */ + private function resolveFile( + string $filePath, + array &$visited = [], + ?string $originEntity = null + ): array { + if (isset($visited[$filePath]) || !is_readable($filePath)) { + return []; + } + + $visited[$filePath] = true; + + $content = file_get_contents($filePath); + + if ($content === false) { + return []; // @codeCoverageIgnore + } + + $entities = $this->extractEntities($content, $filePath, $visited); + + if ($originEntity !== null) { + $entities[$originEntity] = $this->normalize($content); + } + + return $entities; + } + + /** + * @param array $visited + * @return array + */ + private function extractEntities( + string $content, + string $filePath, + array &$visited + ): array { + $result = []; + + if (!str_contains($content, '/', + $content, + $matches, + PREG_SET_ORDER + ) + ) { + return $result; + } + + foreach ($matches as $match) { + $name = $match[1]; + $type = $match[2] ?: null; + $value = trim($match[4]); + + if ($type === 'SYSTEM') { + $resolvedPath = $this->resolvePath($filePath, $value); + $result += $this->resolveFile($resolvedPath, $visited, $name); + + continue; + } + + $result[$name] = $this->normalize($value); + } + + return $result; + } + + private function normalize(string $value): string + { + return preg_replace('/\s+/', ' ', trim($value)) ?: $value; + } + + private function resolvePath(string $path, string $reference): string + { + foreach ($this->projectRoots as $root => $directory) { + if (!str_contains($reference, '/' . $directory . '/')) { + continue; } + + [$prefix] = explode('/' . $directory . '/', $reference); + $reference = str_replace($prefix . '/' . $directory, $root, $reference); + } + + if (str_starts_with($reference, '/')) { + return $reference; } - return $found; + return dirname($path) . DIRECTORY_SEPARATOR . $reference; } } diff --git a/src/Path/PathMatcher.php b/src/Path/PathMatcher.php index a2b80dd..146896f 100644 --- a/src/Path/PathMatcher.php +++ b/src/Path/PathMatcher.php @@ -6,19 +6,19 @@ final readonly class PathMatcher { - /** @var list */ - private array $excludePatterns; - /** * @param list $excludePatterns */ - public function __construct(array $excludePatterns) - { - $this->excludePatterns = $excludePatterns; + public function __construct( + private string $basePath, + private array $excludePatterns + ) { } public function isExcluded(string $filePath): bool { + $filePath = str_replace($this->basePath . '/', '', $filePath); + // Normalize to forward slashes for consistent matching. $normalized = str_replace('\\', '/', $filePath); diff --git a/src/Runner/EntityPreprocessor.php b/src/Runner/EntityPreprocessor.php index 7f79803..a603684 100644 --- a/src/Runner/EntityPreprocessor.php +++ b/src/Runner/EntityPreprocessor.php @@ -7,38 +7,66 @@ final class EntityPreprocessor { private const array PREDEFINED = ['amp', 'lt', 'gt', 'quot', 'apos']; - private const string ENTITY_PATTERN = '/&([a-zA-Z_][\w.\-]*);/'; - - private string $replacement; - - public function __construct(string $replacement = '') - { - $this->replacement = $replacement; + private const string ENTITY_PATTERN = '&([a-zA-Z_][\w.\-]*);'; + private const string XML_DECLARATION_PATTERN = '/<\?xml[^?]*\?>/i'; + + /** + * @param array $entities + */ + public function __construct( + private array $entities, + ) { } - public function process(string $xmlContent): string + public function process(string $xml): string { - return $this->neutralize( - $this->stripDoctype($xmlContent) - ); + $xml = $this->stripDoctype($xml); + + return $this->expandEntities($xml); } - public function neutralize(string $xmlContent): string + private function expandEntities(string $content): string { - return (string)preg_replace_callback( - self::ENTITY_PATTERN, - function (array $matches): string { - if (in_array($matches[1], self::PREDEFINED, true)) { - return $matches[0]; // keep & etc. - } - - return $this->replacement; - }, - $xmlContent, - ); + $maxDepth = 20; + + for ($i = 0; $i < $maxDepth; $i++) { + $changed = false; + + $content = preg_replace_callback( + '/|' . self::ENTITY_PATTERN . '/', + function (array $matches) use (&$changed): string { + // If this is a comment, return as is + if (str_starts_with($matches[0], '&foo;'; + + $result = $preprocessor->process($input); + + self::assertStringContainsString('', $result); + self::assertStringContainsString('expanded', $result); + } + + #[Test] + public function itHandlesMaxDepthWithoutInfiniteLoop(): void + { + // Create a self-referencing entity (would loop forever without depth limit) + $preprocessor = new EntityPreprocessor([ + 'loop' => '&loop;', + ]); + + $input = '&loop;'; + + // Should terminate without hanging due to the maxDepth guard + $result = $preprocessor->process($input); + + self::assertIsString($result); + } } diff --git a/tests/Unit/Runner/SniffRunnerTest.php b/tests/Unit/Runner/SniffRunnerTest.php index 3646103..971803b 100644 --- a/tests/Unit/Runner/SniffRunnerTest.php +++ b/tests/Unit/Runner/SniffRunnerTest.php @@ -6,6 +6,7 @@ use DocbookCS\Config\ConfigData; use DocbookCS\Config\SniffEntry; +use DocbookCS\Path\EntityResolver; use DocbookCS\Path\PathLoader; use DocbookCS\Path\PathMatcher; use DocbookCS\Progress\NullProgress; @@ -28,6 +29,7 @@ #[CoversClass(PathMatcher::class)] #[CoversClass(NullProgress::class)] #[CoversClass(EntityPreprocessor::class)] +#[CoversClass(EntityResolver::class)] #[CoversClass(XmlFileProcessor::class)] #[CoversClass(Report::class)] #[CoversClass(SniffEntry::class)] @@ -41,6 +43,7 @@ final class SniffRunnerTest extends TestCase private function createConfig(array $sniffs = []): ConfigData { return new ConfigData( + [], $sniffs, [self::FIXTURE_DIR], [], @@ -121,8 +124,7 @@ public function setProperty(string $name, string $value): void } }; - $entry = new SniffEntry($sniff::class); - $config = $this->createConfig(sniffs: [$entry]); + $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); $runner = new SniffRunner(); $report = $runner->run($config); @@ -159,8 +161,7 @@ public function setProperty(string $name, string $value): void } }; - $entry = new SniffEntry($sniff::class); - $config = $this->createConfig(sniffs: [$entry]); + $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); $runner = new SniffRunner(); $report = $runner->run($config); @@ -195,8 +196,7 @@ public function process(\DOMDocument $document, string $content, string $filePat } }; - $entry = new SniffEntry($sniffClass::class, ['someProp' => 'someValue']); - $config = $this->createConfig(sniffs: [$entry]); + $config = $this->createConfig(sniffs: [new SniffEntry($sniffClass::class, ['someProp' => 'someValue'])]); $runner = new SniffRunner(); $runner->run($config); @@ -207,8 +207,7 @@ public function process(\DOMDocument $document, string $content, string $filePat #[Test] public function itThrowsWhenSniffClassDoesNotExist(): void { - $entry = new SniffEntry('NonExistent\\FakeSniff', []); - $config = $this->createConfig(sniffs: [$entry]); + $config = $this->createConfig(sniffs: [new SniffEntry('NonExistent\\FakeSniff')]); $runner = new SniffRunner(); @@ -221,8 +220,7 @@ public function itThrowsWhenSniffClassDoesNotExist(): void #[Test] public function itThrowsWhenClassDoesNotImplementSniffInterface(): void { - $entry = new SniffEntry(\stdClass::class, []); - $config = $this->createConfig(sniffs: [$entry]); + $config = $this->createConfig(sniffs: [new SniffEntry(\stdClass::class)]); $runner = new SniffRunner(); diff --git a/tests/Unit/Runner/XmlFileProcessorTest.php b/tests/Unit/Runner/XmlFileProcessorTest.php index f35e3c4..83c0bab 100644 --- a/tests/Unit/Runner/XmlFileProcessorTest.php +++ b/tests/Unit/Runner/XmlFileProcessorTest.php @@ -8,8 +8,8 @@ use DocbookCS\Report\Report; use DocbookCS\Report\Severity; use DocbookCS\Report\Violation; -use DocbookCS\Runner\XmlFileProcessor; use DocbookCS\Runner\EntityPreprocessor; +use DocbookCS\Runner\XmlFileProcessor; use DocbookCS\Sniff\SniffInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -23,223 +23,184 @@ final class XmlFileProcessorTest extends TestCase { #[Test] - public function itReportsParseErrorForInvalidXml(): void + public function itReportsParseErrors(): void { - $processor = new XmlFileProcessor([]); + $report = $this->processor()->processString('', 'bad.xml'); - $fileReport = $processor->processString('', 'bad.xml'); - - self::assertTrue($fileReport->hasViolations()); - self::assertStringContainsString( - 'XML parse error', - $fileReport->getViolations()[0]->message, - ); + $this->assertInternalError($report, 'XML parse error'); } #[Test] - public function itReportsErrorForMissingFile(): void + public function itReportsMissingFiles(): void { - $processor = new XmlFileProcessor([]); + $report = $this->processor()->processFile('/nonexistent/path/file.xml'); - $fileReport = $processor->processFile('/nonexistent/path/file.xml'); - - self::assertTrue($fileReport->hasViolations()); - self::assertStringContainsString( - 'Could not read file', - $fileReport->getViolations()[0]->message, - ); + $this->assertInternalError($report, 'Could not read file'); } #[Test] - public function itUsesReportPathInsteadOfFilePathWhenProvided(): void + public function itPrefersReportPathOverFilePath(): void { - $processor = new XmlFileProcessor([]); - - $fileReport = $processor->processFile('/nonexistent/path/file.xml', [], 'relative/file.xml'); + $report = $this->processor()->processFile( + '/nonexistent/path/file.xml', + [], + 'relative/file.xml' + ); - self::assertSame('relative/file.xml', $fileReport->filePath); + self::assertSame('relative/file.xml', $report->filePath); } #[Test] - public function itParsesCleanXmlWithNoViolations(): void + public function itAcceptsValidXmlWithoutViolations(): void { - $processor = new XmlFileProcessor([]); - - $xml = <<<'XML' - - - Simple text. - -XML; + $xml = $this->xml('ok'); - $fileReport = $processor->processString($xml, 'clean.xml'); + $report = $this->processor()->processString($xml); - self::assertFalse($fileReport->hasViolations()); + self::assertFalse($report->hasViolations()); } #[Test] - public function itNeutralizesEntitiesBeforeParsing(): void + public function itHandlesEntitiesWithoutParseErrors(): void { - $processor = new XmlFileProcessor([]); + $xml = $this->xml( + ' + + &link.superglobals; &php.ini; & + ' + ); - $xml = <<<'XML' - - - - &link.superglobals; are &php.ini; things and & works. - -XML; + $processor = $this->processor([], new EntityPreprocessor([ + 'link.superglobals' => '', + 'php.ini' => '', + ])); - $fileReport = $processor->processString($xml, 'entity-test.xml'); + $report = $processor->processString($xml); - $parseErrors = array_filter( - $fileReport->getViolations(), - static fn($v) => $v->sniffCode === 'DocbookCS.Internal', + self::assertCount( + 0, + array_filter( + $report->getViolations(), + fn($v) => $v->sniffCode === 'DocbookCS.Internal' + ) ); - - self::assertCount(0, $parseErrors); } #[Test] - public function itAcceptsCustomPreprocessor(): void + public function itUsesCustomPreprocessor(): void { - $preprocessor = new EntityPreprocessor('[REPLACED]'); - $processor = new XmlFileProcessor([], $preprocessor); + $processor = $this->processor([], new EntityPreprocessor([ + 'custom.entity' => '[X]', + ])); - $xml = <<<'XML' - - - &custom.entity; text. - -XML; + $xml = $this->xml('&custom.entity;'); - $fileReport = $processor->processString($xml, 'custom.xml'); + $report = $processor->processString($xml); - $parseErrors = array_filter( - $fileReport->getViolations(), - static fn($v) => $v->sniffCode === 'DocbookCS.Internal', + self::assertCount( + 0, + array_filter( + $report->getViolations(), + fn($v) => $v->sniffCode === 'DocbookCS.Internal' + ) ); - - self::assertCount(0, $parseErrors); } #[Test] - public function itReturnsZeroViolationsForEmptySniffList(): void + public function itReturnsZeroViolationsWithoutSniffs(): void { - $processor = new XmlFileProcessor([]); - - $xml = <<<'XML' - -Hello. -XML; + $xml = $this->xml('Hello'); - $fileReport = $processor->processString($xml, 'empty-sniffs.xml'); + $report = $this->processor()->processString($xml); - self::assertSame(0, $fileReport->getViolationCount()); + self::assertSame(0, $report->getViolationCount()); } #[Test] - public function itReturnsAllViolationsWhenNoChangedLinesGiven(): void + public function itReturnsAllViolationsWithoutDiffFiltering(): void { - $sniff = $this->makeSniffWithViolationsAtLines([3, 5]); - $processor = new XmlFileProcessor([$sniff]); - - $xml = <<<'XML' - - - line 3 - line 4 - line 5 - -XML; + $sniff = $this->sniff([3, 5]); + + $xml = $this->xml( + ' + 3 + 4 + 5 + ' + ); - $fileReport = $processor->processString($xml, 'all.xml'); + $report = $this->processor([$sniff])->processString($xml); - self::assertSame(2, $fileReport->getViolationCount()); + self::assertSame(2, $report->getViolationCount()); } #[Test] - public function itFiltersViolationsToChangedLinesWhenDiffProvided(): void + public function itFiltersViolationsByChangedLines(): void { - $sniff = $this->makeSniffWithViolationsAtLines([3, 5]); - $processor = new XmlFileProcessor([$sniff]); - - $xml = <<<'XML' - - - line 3 - line 4 - line 5 - -XML; + $sniff = $this->sniff([3, 5]); + + $xml = $this->xml( + ' + 3 + 4 + 5 + ' + ); - // Only line 3 changed — violation at line 5 should be suppressed. - $fileReport = $processor->processString($xml, 'filtered.xml', [3]); + $report = $this->processor([$sniff])->processString($xml, 'f.xml', [3]); - self::assertSame(1, $fileReport->getViolationCount()); - self::assertSame(3, $fileReport->getViolations()[0]->line); + self::assertSame(1, $report->getViolationCount()); + self::assertSame(3, $report->getViolations()[0]->line); } #[Test] - public function itIncludesViolationWhenChangedLineIsInsideElement(): void + public function itExpandsElementSpanForNestedChanges(): void { - // Violation is on the opening tag (line 3), but the changed - // content is on line 4 (inside the element). The filtering must - // expand to the parent element's span. - $sniff = $this->makeSniffWithViolationsAtLines([3]); - $processor = new XmlFileProcessor([$sniff]); - - $xml = <<<'XML' - - - - content on line 4 - - -XML; + $sniff = $this->sniff([3]); + + $xml = $this->xml( + ' + + + line 6 + + + ' + ); - // Line 3 is the opening; changed line is 4 (inside the element). - $fileReport = $processor->processString($xml, 'inner.xml', [4]); + $report = $this->processor([$sniff])->processString($xml, 'x.xml', [6]); - self::assertSame(1, $fileReport->getViolationCount()); - self::assertSame(3, $fileReport->getViolations()[0]->line); + self::assertSame(1, $report->getViolationCount()); } #[Test] - public function itSuppressesViolationWhenNoChangedLineIsInsideElement(): void + public function itIgnoresChangesOutsideElementSpan(): void { - // Violation at line 3 (), but changed line is 7 (a different element). - $sniff = $this->makeSniffWithViolationsAtLines([3]); - $processor = new XmlFileProcessor([$sniff]); + $sniff = $this->sniff([3]); - $xml = <<<'XML' - - - - content - - other element at line 7 - -XML; + $xml = $this->xml( + ' + text + line 7 + ' + ); - $fileReport = $processor->processString($xml, 'suppress.xml', [7]); + $report = $this->processor([$sniff])->processString($xml, 'x.xml', [7]); - self::assertSame(0, $fileReport->getViolationCount()); + self::assertSame(0, $report->getViolationCount()); } #[Test] - public function itKeepsInternalErrorsRegardlessOfChangedLines(): void + public function itKeepsInternalErrorsEvenWithDiffFiltering(): void { - $processor = new XmlFileProcessor([]); - - $fileReport = $processor->processFile('/nonexistent/path/file.xml', [42]); + $report = $this->processor()->processFile('/nonexistent/path/file.xml', [42]); - self::assertTrue($fileReport->hasViolations()); - self::assertSame('DocbookCS.Internal', $fileReport->getViolations()[0]->sniffCode); + self::assertTrue($report->hasViolations()); + self::assertSame('DocbookCS.Internal', $report->getViolations()[0]->sniffCode); } /** @param list $lines */ - private function makeSniffWithViolationsAtLines(array $lines): SniffInterface + private function sniff(array $lines): SniffInterface { return new class ($lines) implements SniffInterface { /** @param list $lines */ @@ -260,9 +221,9 @@ public function process(\DOMDocument $document, string $content, string $filePat filePath: $filePath, line: $line, message: "violation at line {$line}", - severity: Severity::WARNING, + severity: Severity::WARNING ), - $this->lines, + $this->lines ); } @@ -272,50 +233,27 @@ public function setProperty(string $name, string $value): void }; } - #[Test] - public function itExpandsViolationSpanThroughDeeplyNestedElements(): void + /** @param list $sniffs */ + private function processor(array $sniffs = [], ?EntityPreprocessor $pre = null): XmlFileProcessor { - $sniff = $this->makeSniffWithViolationsAtLines([3]); - $processor = new XmlFileProcessor([$sniff]); - - $xml = <<<'XML' - - - - - - deeply nested content on line 6 - - - - -XML; - - $fileReport = $processor->processString($xml, 'deep-nest.xml', [6]); - - self::assertSame(1, $fileReport->getViolationCount()); - self::assertSame(3, $fileReport->getViolations()[0]->line); + return new XmlFileProcessor( + $sniffs, + $pre ?? new EntityPreprocessor([]) // always pass array + ); } - #[Test] - public function itDoesNotExpandSpanWhenNestedElementEndsBeforeMax(): void + private function xml(string $body): string { - $sniff = $this->makeSniffWithViolationsAtLines([3]); - $processor = new XmlFileProcessor([$sniff]); - - $xml = <<<'XML' + return << - - - short - text content stretching to line 5 - - unrelated line 7 - +$body XML; + } - $fileReport = $processor->processString($xml, 'shallow-nest.xml', [7]); - - self::assertSame(0, $fileReport->getViolationCount()); + private function assertInternalError(FileReport $report, string $messagePart): void + { + self::assertTrue($report->hasViolations()); + self::assertSame('DocbookCS.Internal', $report->getViolations()[0]->sniffCode); + self::assertStringContainsString($messagePart, $report->getViolations()[0]->message); } } diff --git a/tests/Unit/Sniff/WhitespaceSniffTest.php b/tests/Unit/Sniff/WhitespaceSniffTest.php new file mode 100644 index 0000000..17d81c0 --- /dev/null +++ b/tests/Unit/Sniff/WhitespaceSniffTest.php @@ -0,0 +1,121 @@ +loadXML($xml); + + return $doc; + } + + #[Test] + public function itReturnsEmptyWhenNoViolations(): void + { + $content = "" . PHP_EOL . + " value" . PHP_EOL . + ""; + + $doc = $this->createDocument($content); + $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + + self::assertSame([], $violations); + } + + #[Test] + public function itDetectsTrailingWhitespace(): void + { + $content = " " . PHP_EOL . + ""; + + $doc = $this->createDocument($content); + $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + + self::assertCount(1, $violations); + self::assertSame('Trailing whitespace detected.', $violations[0]->message); + self::assertSame(1, $violations[0]->line); + } + + #[Test] + public function itDetectsSpaceBeforeTab(): void + { + $content = "" . PHP_EOL . + " \t" . PHP_EOL . + ""; + + $doc = $this->createDocument($content); + $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + + self::assertCount(1, $violations); + self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message); + self::assertSame(2, $violations[0]->line); + } + + #[Test] + public function itDetectsMixedIndentationSpacesThenTabs(): void + { + $content = "" . PHP_EOL . + " \t" . PHP_EOL . + ""; + + $doc = $this->createDocument($content); + $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + + self::assertCount(1, $violations); + self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message); + } + + #[Test] + public function itDetectsMixedIndentationTabsThenSpaces(): void + { + $content = "" . PHP_EOL . + "\t \t" . PHP_EOL . + ""; + + $doc = $this->createDocument($content); + $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + + self::assertCount(1, $violations); + self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message); + } + + #[Test] + public function itHandlesMultipleViolations(): void + { + $content = " " . PHP_EOL . + " \t" . PHP_EOL . + "\t \t" . PHP_EOL . + ""; + + $doc = $this->createDocument($content); + $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + + self::assertCount(3, $violations); + } + + #[Test] + public function itIncludesFilePathInViolation(): void + { + $content = " " . PHP_EOL . + ""; + + $doc = $this->createDocument($content); + $violations = (new WhitespaceSniff())->process($doc, $content, 'my-file.xml'); + + self::assertCount(1, $violations); + self::assertSame('my-file.xml', $violations[0]->filePath); + } +} diff --git a/tests/fixtures/entity_tree/broken.ent b/tests/fixtures/entity_tree/broken.ent new file mode 100644 index 0000000..2cad3ee --- /dev/null +++ b/tests/fixtures/entity_tree/broken.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/circular/b.ent b/tests/fixtures/entity_tree/circular/b.ent new file mode 100644 index 0000000..3c0d653 --- /dev/null +++ b/tests/fixtures/entity_tree/circular/b.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/custom/custom.dtd b/tests/fixtures/entity_tree/custom/custom.dtd new file mode 100644 index 0000000..bc5df96 --- /dev/null +++ b/tests/fixtures/entity_tree/custom/custom.dtd @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/duplicates/a_dup.ent b/tests/fixtures/entity_tree/duplicates/a_dup.ent new file mode 100644 index 0000000..1057c32 --- /dev/null +++ b/tests/fixtures/entity_tree/duplicates/a_dup.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/duplicates/b_dup.ent b/tests/fixtures/entity_tree/duplicates/b_dup.ent new file mode 100644 index 0000000..91528f8 --- /dev/null +++ b/tests/fixtures/entity_tree/duplicates/b_dup.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/global.ent b/tests/fixtures/entity_tree/global.ent index 0df9c6c..51563f9 100644 --- a/tests/fixtures/entity_tree/global.ent +++ b/tests/fixtures/entity_tree/global.ent @@ -1 +1,3 @@ - + + + diff --git a/tests/fixtures/entity_tree/multiline.ent b/tests/fixtures/entity_tree/multiline.ent new file mode 100644 index 0000000..9ca9d9c --- /dev/null +++ b/tests/fixtures/entity_tree/multiline.ent @@ -0,0 +1,3 @@ + diff --git a/tests/fixtures/entity_tree/nested/deep/inner.ent b/tests/fixtures/entity_tree/nested/deep/inner.ent new file mode 100644 index 0000000..ec8cce3 --- /dev/null +++ b/tests/fixtures/entity_tree/nested/deep/inner.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/not_an_entity.xml b/tests/fixtures/entity_tree/not_an_entity.xml index dfa8d65..ee80ed7 100644 --- a/tests/fixtures/entity_tree/not_an_entity.xml +++ b/tests/fixtures/entity_tree/not_an_entity.xml @@ -1,2 +1 @@ - - + diff --git a/tests/fixtures/entity_tree/plain.ent b/tests/fixtures/entity_tree/plain.ent new file mode 100644 index 0000000..86724fc --- /dev/null +++ b/tests/fixtures/entity_tree/plain.ent @@ -0,0 +1 @@ +just some XML, no entities here diff --git a/tests/fixtures/entity_tree/project_root/main.ent b/tests/fixtures/entity_tree/project_root/main.ent new file mode 100644 index 0000000..ea289a2 --- /dev/null +++ b/tests/fixtures/entity_tree/project_root/main.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/project_root/real-root/included.ent b/tests/fixtures/entity_tree/project_root/real-root/included.ent new file mode 100644 index 0000000..e4b1915 --- /dev/null +++ b/tests/fixtures/entity_tree/project_root/real-root/included.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/quoted.ent b/tests/fixtures/entity_tree/quoted.ent new file mode 100644 index 0000000..ba7fcfe --- /dev/null +++ b/tests/fixtures/entity_tree/quoted.ent @@ -0,0 +1,2 @@ + + diff --git a/tests/fixtures/entity_tree/sub/nested.ent b/tests/fixtures/entity_tree/sub/nested.ent deleted file mode 100644 index 1b69738..0000000 --- a/tests/fixtures/entity_tree/sub/nested.ent +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/fixtures/entity_tree/system/included.ent b/tests/fixtures/entity_tree/system/included.ent new file mode 100644 index 0000000..82fb9a7 --- /dev/null +++ b/tests/fixtures/entity_tree/system/included.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/system/main.ent b/tests/fixtures/entity_tree/system/main.ent new file mode 100644 index 0000000..d3664dc --- /dev/null +++ b/tests/fixtures/entity_tree/system/main.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/entity_tree/system/missing_target.ent b/tests/fixtures/entity_tree/system/missing_target.ent new file mode 100644 index 0000000..e8bc5f2 --- /dev/null +++ b/tests/fixtures/entity_tree/system/missing_target.ent @@ -0,0 +1 @@ + diff --git a/tests/fixtures/valid_full.xml b/tests/fixtures/valid_full.xml index fb748e2..09ec241 100644 --- a/tests/fixtures/valid_full.xml +++ b/tests/fixtures/valid_full.xml @@ -1,5 +1,9 @@ + + en + doc-base +