diff --git a/README.md b/README.md index 5ee464c..179d77d 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,16 @@ Implement `DocbookCS\Sniff\SniffInterface` (or extend `AbstractSniff`): namespace Acme\DocbookSniffs; use DocbookCS\Sniff\AbstractSniff; +use DocbookCS\Source\File; final class MySniff extends AbstractSniff { - public function getCode(): string + public static function getCode(): string { return 'Acme.MySniff'; } - public function process(\DOMDocument $document, string $content, string $filePath): array + public function process(\DOMDocument $document, File $file): array { $violations = []; // ... inspect $document, add violations via $this->createViolation(...) @@ -63,6 +64,27 @@ Register it in your config: ``` +## CLI Scope + +By default, DocbookCS checks the current Git diff from its upstream branch point +through the working tree. Alternatively, a unified diff can be piped or file and +directory paths passed. The inspection scope is limited to the given diff or the +full contents of the given file paths. + +XML references are expanded by default, but violations and fixes remain limited +to the given scope. With `--wide`, every file, inferred from paths or diff, as +a whole will be checked and referenced `SYSTEM` XML files recursively included +in violation reports and fixing. + +| Input | `--wide` | Full File(s) | References | +|------------|---------:|-------------:|-----------:| +| none | no | no | no | +| none | yes | yes | yes | +| path | no | yes | no | +| path | yes | yes | yes | +| piped diff | no | no | no | +| piped diff | yes | yes | yes | + ## License Apache 2.0 diff --git a/bin/docbook-cs b/bin/docbook-cs index 45e5abf..5db6d29 100644 --- a/bin/docbook-cs +++ b/bin/docbook-cs @@ -14,7 +14,7 @@ use DocbookCS\Application; foreach ($candidates as $file) { if (!is_file($file)) { - continue; + continue; } require $file; @@ -25,4 +25,11 @@ use DocbookCS\Application; exit(2); })(); -new Application($argv ?? [])->run(); +try { + $application = Application::fromGlobals($argv ?? []); +} catch (\RuntimeException $e) { + fwrite(STDERR, $e->getMessage() . PHP_EOL); + exit(2); +} + +exit($application->run()); diff --git a/composer.json b/composer.json index ba5461a..db89730 100644 --- a/composer.json +++ b/composer.json @@ -11,13 +11,13 @@ "ext-simplexml": "*" }, "require-dev": { - "phpunit/phpunit": "^13.1", - "phpstan/phpstan": "^2.1", - "phpunit/php-code-coverage": "^14.1", - "squizlabs/php_codesniffer": "^4.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "shipmonk/dead-code-detector": "^1.3" + "phpunit/phpunit": "^13.2.4", + "phpstan/phpstan": "^2.2.5", + "phpunit/php-code-coverage": "^14.2.3", + "squizlabs/php_codesniffer": "^4.0.1", + "phpstan/phpstan-strict-rules": "^2.0.12", + "phpstan/phpstan-phpunit": "^2.0.18", + "shipmonk/dead-code-detector": "^1.3.2" }, "autoload": { "psr-4": { diff --git a/phpcs.xml b/phpcs.xml index 93f3b2f..3495fd9 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -2,6 +2,9 @@ DocbookCS Coding Standard + + tests/* + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eaa454f..540a1a7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,6 +21,9 @@ tests/Integration + + tests/Feature + diff --git a/src/Application.php b/src/Application.php index 9827b56..59e7e3e 100644 --- a/src/Application.php +++ b/src/Application.php @@ -7,7 +7,6 @@ use DocbookCS\Config\ConfigData; use DocbookCS\Config\ConfigParser; use DocbookCS\Config\ConfigParserException; -use DocbookCS\Diff\DiffParser; use DocbookCS\Progress\ConsoleProgress; use DocbookCS\Progress\NullProgress; use DocbookCS\Progress\ProgressInterface; @@ -15,7 +14,9 @@ use DocbookCS\Report\Reporter\ConsoleReporter; use DocbookCS\Report\Reporter\JsonReporter; use DocbookCS\Report\Reporter\ReporterInterface; -use DocbookCS\Runner\SniffRunner; +use DocbookCS\Runner\RunCoordinator; +use DocbookCS\Runner\RunMode; +use DocbookCS\Runner\RunPlanner; final class Application { @@ -32,21 +33,50 @@ final class Application /** @var resource */ private $stderr; - /** @var resource */ - private $stdin; + private ?string $stdin; + + /** + * @param list $argv + * @throws \RuntimeException if redirected stdin cannot be read. + * @api + */ + public static function fromGlobals(array $argv): self + { + $stdin = null; + $stat = fstat(STDIN); + + if ($stat === false) { + return new self($argv, stdin: $stdin); + } + + $type = $stat['mode'] & 0170000; + + if ($type === 0010000 || $type === 0100000) { + $stdin = stream_get_contents(STDIN); + + if ($stdin === false) { + throw new \RuntimeException('Could not read diff from stdin.'); + } + } + + return new self($argv, stdin: $stdin); + } /** * @param list $argv * @param ?resource $stdout * @param ?resource $stderr - * @param ?resource $stdin */ - public function __construct(array $argv, mixed $stdout = null, mixed $stderr = null, mixed $stdin = null) - { + public function __construct( + array $argv, + mixed $stdout = null, + mixed $stderr = null, + ?string $stdin = null, + ) { $this->argv = $argv; $this->stdout = $stdout ?? STDOUT; $this->stderr = $stderr ?? STDERR; - $this->stdin = $stdin ?? STDIN; + $this->stdin = $stdin; } /** @@ -54,7 +84,13 @@ public function __construct(array $argv, mixed $stdout = null, mixed $stderr = n */ public function run(): int { - $options = $this->parseArgv(); + try { + $options = $this->parseArgv(); + } catch (\InvalidArgumentException $e) { + $this->writeError('Error: ' . $e->getMessage() . PHP_EOL); + + return 2; + } if ($options['help']) { $this->printHelp(); @@ -76,31 +112,22 @@ public function run(): int return 2; } - $overridePaths = $options['paths'] !== [] ? $options['paths'] : null; - - // If override paths are relative, resolve them against cwd. - if ($overridePaths !== null) { - $overridePaths = $this->resolveOverridePaths($overridePaths); - } - - $diff = null; - - if ($options['diff'] !== null) { - try { - $diffContent = $this->readDiff($options['diff']); - $diff = (new DiffParser())->parse($diffContent); - } catch (\Throwable $e) { - $this->writeError('Error reading diff: ' . $e->getMessage() . PHP_EOL); + try { + $runPlan = new RunPlanner( + config: $config, + mode: RunMode::fromFixFlag($options['fix']), + wide: $options['wide'], + )->plan($options['paths'], $this->stdin); + } catch (\Throwable $e) { + $this->writeError('Error resolving input: ' . $e->getMessage() . PHP_EOL); - return 2; - } + return 2; } $progress = $this->createProgress($options); try { - $runner = new SniffRunner($progress); - $report = $runner->run($config, $overridePaths, $diff); + $report = new RunCoordinator($progress)->run($runPlan); } catch (\Throwable $e) { $this->writeError('Runtime error: ' . $e->getMessage() . PHP_EOL); @@ -118,27 +145,6 @@ public function run(): int return (int) $report->hasViolations(); } - /** - * @param list $paths - * @return list - */ - private function resolveOverridePaths(array $paths): array - { - $cwd = getcwd() ?: '.'; - $resolved = []; - - foreach ($paths as $path) { - if (str_starts_with($path, '/') || preg_match('#^[a-zA-Z]:[/\\\\]#', $path)) { - $resolved[] = $path; - continue; - } - - $resolved[] = $cwd . '/' . $path; - } - - return $resolved; - } - /** * @return array{ * help: bool, @@ -146,11 +152,13 @@ private function resolveOverridePaths(array $paths): array * config: string, * report: string, * colors: bool, + * fix: bool, + * wide: bool, * quiet: bool, * paths: list, - * diff: string|null, * perf: bool, * } + * @throws \InvalidArgumentException for unsupported or removed options. */ private function parseArgv(): array { @@ -160,9 +168,10 @@ private function parseArgv(): array 'config' => self::DEFAULT_CONFIG, 'report' => 'console', 'colors' => $this->detectColorSupport(), + 'fix' => false, + 'wide' => false, 'quiet' => false, 'paths' => [], - 'diff' => null, 'perf' => false, ]; @@ -227,23 +236,20 @@ private function parseArgv(): array continue; } - // --diff = read from stdin - // --diff=FILE = read from file - // --diff=- = read from stdin (explicit) - if ($arg === '--diff') { - $result['diff'] = ''; + if ($arg === '--perf') { + $result['perf'] = true; $i++; continue; } - if (str_starts_with($arg, '--diff=')) { - $result['diff'] = substr($arg, 7); + if ($arg === '--fix') { + $result['fix'] = true; $i++; continue; } - if ($arg === '--perf') { - $result['perf'] = true; + if ($arg === '--wide') { + $result['wide'] = true; $i++; continue; } @@ -251,33 +257,16 @@ private function parseArgv(): array // Anything else is a path to scan. if (!str_starts_with($arg, '-')) { $result['paths'][] = $arg; + $i++; + continue; } - $i++; + throw new \InvalidArgumentException(sprintf('Unknown option: %s', $arg)); } return $result; } - /** @throws \RuntimeException if the source cannot be read. */ - private function readDiff(string $source): string - { - if ($source === '' || $source === '-') { - $content = stream_get_contents($this->stdin); - if ($content === false) { - throw new \RuntimeException('Could not read diff from stdin.'); // @codeCoverageIgnore - } - return $content; - } - - $content = @file_get_contents($source); - if ($content === false) { - throw new \RuntimeException(sprintf('Could not read diff file: %s', $source)); - } - - return $content; - } - /** @param array{report: string, quiet: bool, colors: bool} $options */ private function createProgress(array $options): ProgressInterface { @@ -382,22 +371,27 @@ private function printHelp(): void --report= Output format: console (default), checkstyle, json. --colors Force ANSI color output. --no-colors Disable ANSI color output. - --diff[=] Restrict analysis to files changed in a unified diff. - Omit the value or pass "-" to read the diff from stdin. - Violations are only reported when the violating element - is on or contains a changed line (parent-context aware). + --fix Automatically fix violations when fixers exist + (experimental). + --wide Check whole selected files and recursively include + referenced XML files. Arguments: One or more files or directories to scan. - If omitted, the paths from the config file are used. + Paths cannot be combined with diff input. Examples: docbook-cs docbook-cs --config=myconfig.xml reference/ docbook-cs --report=checkstyle --no-colors > report.xml + docbook-cs . --fix + docbook-cs reference/ docbook-cs reference/strings/functions/strlen.xml - git diff HEAD | docbook-cs --diff --report=checkstyle - docbook-cs --diff=changes.patch --report=json + docbook-cs reference/strings/functions/strlen.xml --wide + docbook-cs reference/strings/functions/strlen.xml --wide --fix + git diff HEAD | docbook-cs + git diff HEAD | docbook-cs --wide + git diff HEAD | docbook-cs --wide --fix --report=checkstyle HELP; diff --git a/src/Config/ConfigParserException.php b/src/Config/ConfigParserException.php index 46e92f7..75f087c 100644 --- a/src/Config/ConfigParserException.php +++ b/src/Config/ConfigParserException.php @@ -23,12 +23,6 @@ public static function missingElement(string $element): self public static function missingAttribute(string $element, string $attribute): self { - return new self( - sprintf( - 'Element <%s> is missing required attribute "%s".', - $element, - $attribute, - ) - ); + return new self(sprintf('Element <%s> is missing required attribute "%s".', $element, $attribute)); } } diff --git a/src/Config/SniffEntry.php b/src/Config/SniffEntry.php index 3dae442..2264e06 100644 --- a/src/Config/SniffEntry.php +++ b/src/Config/SniffEntry.php @@ -4,39 +4,23 @@ namespace DocbookCS\Config; -final class SniffEntry +final readonly class SniffEntry { - /** @var array */ - private array $properties; - /** * @param array $properties * @throws \InvalidArgumentException if $className is empty or only whitespace. */ public function __construct( - private readonly string $className, - array $properties = [], + public string $className, + public array $properties = [], ) { if (trim($className) === '') { throw new \InvalidArgumentException('Sniff class name must not be empty.'); } - - $this->properties = $properties; - } - - public function getClassName(): string - { - return $this->className; } public function getProperty(string $name, ?string $default = null): ?string { return $this->properties[$name] ?? $default; } - - /** @return array */ - public function getProperties(): array - { - return $this->properties; - } } diff --git a/src/Diff/Diff.php b/src/Diff/DiffChangeset.php similarity index 72% rename from src/Diff/Diff.php rename to src/Diff/DiffChangeset.php index 83b3bd9..fe04d62 100644 --- a/src/Diff/Diff.php +++ b/src/Diff/DiffChangeset.php @@ -4,7 +4,7 @@ namespace DocbookCS\Diff; -final readonly class Diff +final readonly class DiffChangeset { /** @param list $fileChanges */ public function __construct(public array $fileChanges) @@ -18,10 +18,8 @@ public function changeFor(string $filePath): ?FileChange foreach ($this->fileChanges as $fileChange) { $normalisedDiffPath = str_replace('\\', '/', $fileChange->filePath); - if ( - $normalisedPath === $normalisedDiffPath - || str_ends_with($normalisedPath, '/' . ltrim($normalisedDiffPath, '/')) - ) { + $needle = '/' . ltrim($normalisedDiffPath, '/'); + if ($normalisedPath === $normalisedDiffPath || str_ends_with($normalisedPath, $needle)) { return $fileChange; } } diff --git a/src/Diff/DiffParser.php b/src/Diff/DiffParser.php index 9dbd2fe..b43c2c3 100644 --- a/src/Diff/DiffParser.php +++ b/src/Diff/DiffParser.php @@ -8,16 +8,19 @@ final class DiffParser { private const string NO_FINAL_LINE_MARKER = '\ No newline at end of file'; - public function parse(string $diff): Diff + public function parse(string $diff): DiffChangeset { /** @var array> $changedLinesByFile */ $changedLinesByFile = []; + /** @var array> $deletionAnchorsByFile */ + $deletionAnchorsByFile = []; $currentFile = null; $deleted = false; $newLineNumber = 0; $oldLinesRemaining = 0; $newLinesRemaining = 0; $inHunk = false; + $previousLineWasDeletion = false; foreach (explode("\n", $diff) as $line) { if (str_starts_with($line, 'diff --git ')) { @@ -25,6 +28,7 @@ public function parse(string $diff): Diff $deleted = false; $newLineNumber = 0; $inHunk = false; + $previousLineWasDeletion = false; continue; } @@ -43,6 +47,7 @@ public function parse(string $diff): Diff $inHunk = false; if ($currentFile !== null && !isset($changedLinesByFile[$currentFile])) { $changedLinesByFile[$currentFile] = []; + $deletionAnchorsByFile[$currentFile] = []; } continue; } @@ -58,6 +63,7 @@ public function parse(string $diff): Diff $newLineNumber = (int) $m[2]; $newLinesRemaining = isset($m[3]) ? (int) $m[3] : 1; $inHunk = true; + $previousLineWasDeletion = false; } continue; } @@ -70,26 +76,38 @@ public function parse(string $diff): Diff $changedLinesByFile[$currentFile][] = $newLineNumber; $newLineNumber++; $newLinesRemaining--; + $previousLineWasDeletion = false; } elseif (str_starts_with($line, '-')) { + if (!$previousLineWasDeletion) { + $deletionAnchorsByFile[$currentFile][] = max(1, $newLineNumber); + } + $oldLinesRemaining--; + $previousLineWasDeletion = true; } elseif (str_starts_with($line, ' ')) { // Context line — present in both old and new file. $newLineNumber++; $oldLinesRemaining--; $newLinesRemaining--; + $previousLineWasDeletion = false; } if ($oldLinesRemaining === 0 && $newLinesRemaining === 0) { $inHunk = false; + $previousLineWasDeletion = false; } } $fileChanges = []; foreach ($changedLinesByFile as $filePath => $lineNumbers) { - $fileChanges[] = new FileChange($filePath, $lineNumbers); + $fileChanges[] = new FileChange( + $filePath, + $lineNumbers, + $deletionAnchorsByFile[$filePath], + ); } - return new Diff($fileChanges); + return new DiffChangeset($fileChanges); } } diff --git a/src/Diff/DiffProviderInterface.php b/src/Diff/DiffProviderInterface.php new file mode 100644 index 0000000..418a7b4 --- /dev/null +++ b/src/Diff/DiffProviderInterface.php @@ -0,0 +1,10 @@ + $addedLineNumbers */ + /** + * @param list $addedLineNumbers + * @param list $deletionAnchors + */ public function __construct( public string $filePath, public array $addedLineNumbers, + public array $deletionAnchors = [], ) { } } diff --git a/src/Diff/GitDiffProvider.php b/src/Diff/GitDiffProvider.php new file mode 100644 index 0000000..fe4e6b3 --- /dev/null +++ b/src/Diff/GitDiffProvider.php @@ -0,0 +1,99 @@ +runOrThrow( + ['git', 'rev-parse', '--show-toplevel'], + $workingDirectory, + 'Could not find Git repository.', + )); + + $baseReference = $this->resolveBaseReference($repositoryRoot); + + $error = sprintf('Unclear where HEAD branched from %s.', $baseReference); + + $mergeBase = $this->runOrThrow( + ['git', 'merge-base', 'HEAD', $baseReference], + $repositoryRoot, + $error, + ); + + return $this->runOrThrow( + ['git', 'diff', '--no-ext-diff', '--no-color', trim($mergeBase), '--'], + $repositoryRoot, + 'Could not read diff.', + ); + } + + /** @throws \RuntimeException if no default branch reference exists. */ + private function resolveBaseReference(string $repositoryRoot): string + { + $candidates = []; + + foreach (['upstream', 'origin'] as $remote) { + $result = $this->processRunner->run( + ['git', 'symbolic-ref', '--quiet', sprintf('refs/remotes/%s/HEAD', $remote)], + $repositoryRoot, + ); + + if ($result->exitCode === 0) { + $candidates[] = trim($result->stdout); + } + + $candidates[] = sprintf('refs/remotes/%s/main', $remote); + $candidates[] = sprintf('refs/remotes/%s/master', $remote); + } + + $candidates[] = 'refs/heads/main'; + $candidates[] = 'refs/heads/master'; + + foreach (array_unique($candidates) as $candidate) { + $result = $this->processRunner->run( + ['git', 'rev-parse', '--verify', '--quiet', $candidate . '^{commit}'], + $repositoryRoot, + ); + + if ($result->exitCode === 0) { + return $candidate; + } + } + + throw new \RuntimeException( + 'Could not determine the upstream default branch for the contribution diff.', + ); + } + + /** + * @param list $command + * @throws \RuntimeException if the command fails. + */ + private function runOrThrow(array $command, string $workingDirectory, string $error): string + { + $result = $this->processRunner->run($command, $workingDirectory); + + if ($result->exitCode === 0) { + return $result->stdout; + } + + $detail = trim($result->stderr); + + throw new \RuntimeException( + $detail !== '' ? "$error $detail" : $error, + ); + } +} diff --git a/src/Fix/Fix.php b/src/Fix/Fix.php new file mode 100644 index 0000000..031b262 --- /dev/null +++ b/src/Fix/Fix.php @@ -0,0 +1,18 @@ + $fixes + */ + public function apply(File $file, array $fixes): FixResult + { + if ($fixes === []) { + return new FixResult($file); + } + + $plans = []; + foreach ($fixes as $index => $fix) { + $plans[] = [ + 'index' => $index, + 'plan' => $fix instanceof FixPlan ? $fix : new FixPlan($fix), + ]; + } + + usort( + /** @var list $plans */ + $plans, + /** + * @param array{index: int, plan: FixPlan} $a + * @param array{index: int, plan: FixPlan} $b + */ + static function (array $a, array $b): int { + $offsetComparison = $a['plan']->firstOffset() <=> $b['plan']->firstOffset(); + + return $offsetComparison !== 0 + ? $offsetComparison + : $a['index'] <=> $b['index']; + } + ); + + /** @var list $acceptedFixes */ + $acceptedFixes = []; + $acceptedPlans = 0; + $skipped = 0; + + $content = $file->content; + $length = strlen($content); + + foreach ($plans as ['plan' => $plan]) { + if (!$this->canApply($file, $length, $plan, $acceptedFixes)) { + $skipped++; + continue; + } + + if (!$this->changesContent($content, $plan)) { + $skipped++; + continue; + } + + foreach ($plan->fixes as $fix) { + $this->insertFix($acceptedFixes, $fix); + } + + $acceptedPlans++; + } + + $fixedContent = ''; + $sourceOffset = 0; + + foreach ($acceptedFixes as $fix) { + $fixedContent .= substr($content, $sourceOffset, $fix->beginOffset - $sourceOffset); + $fixedContent .= $fix->replacement; + $sourceOffset = $fix->untilOffset; + } + + $content = $fixedContent . substr($content, $sourceOffset); + + return new FixResult( + file: $file->withContent($content), + applied: $acceptedPlans, + skipped: $skipped, + appliedFixes: $acceptedFixes, + ); + } + + /** + * @param list $acceptedFixes + */ + private function canApply(File $file, int $contentLength, FixPlan $plan, array $acceptedFixes): bool + { + $content = $file->content; + $first = $plan->fixes[0]; + $previous = null; + + foreach ($plan->fixes as $fix) { + if ( + $fix->filePath !== $file->path + || $fix->filePath !== $first->filePath + || $fix->sniffCode !== $first->sniffCode + || $fix->beginOffset < 0 + || $fix->untilOffset < $fix->beginOffset + || $fix->untilOffset > $contentLength + || ($previous !== null && self::overlaps($previous, $fix)) + || $this->conflictsWithAcceptedFix($fix, $acceptedFixes) + ) { + return false; + } + + $currentContent = substr($content, $fix->beginOffset, $fix->untilOffset - $fix->beginOffset); + + if ($fix->expectedContent !== null && $currentContent !== $fix->expectedContent) { + return false; + } + + $previous = $fix; + } + + return true; + } + + private function changesContent(string $content, FixPlan $plan): bool + { + foreach ($plan->fixes as $fix) { + $currentContent = substr($content, $fix->beginOffset, $fix->untilOffset - $fix->beginOffset); + + if ($currentContent !== $fix->replacement) { + return true; + } + } + + return false; + } + + /** + * @param list $acceptedFixes + */ + private function conflictsWithAcceptedFix(Fix $fix, array $acceptedFixes): bool + { + $index = $this->insertionIndex($acceptedFixes, $fix); + + return ($index > 0 && self::overlaps($acceptedFixes[$index - 1], $fix)) + || ($index < count($acceptedFixes) && self::overlaps($acceptedFixes[$index], $fix)); + } + + /** + * @param list $fixes + */ + private function insertFix(array &$fixes, Fix $fix): void + { + $lastIndex = count($fixes) - 1; + if ($lastIndex < 0 || self::compare($fixes[$lastIndex], $fix) < 0) { + $fixes[] = $fix; + return; + } + + array_splice($fixes, $this->insertionIndex($fixes, $fix), 0, [$fix]); + } + + /** + * @param list $fixes + */ + private function insertionIndex(array $fixes, Fix $fix): int + { + $low = 0; + $high = count($fixes); + + while ($low < $high) { + $middle = intdiv($low + $high, 2); + if (self::compare($fixes[$middle], $fix) < 0) { + $low = $middle + 1; + } else { + $high = $middle; + } + } + + return $low; + } + + private static function compare(Fix $a, Fix $b): int + { + return [ + $a->beginOffset, + $a->untilOffset, + ] <=> [ + $b->beginOffset, + $b->untilOffset, + ]; + } + + private static function overlaps(Fix $a, Fix $b): bool + { + $aIsInsertion = $a->beginOffset === $a->untilOffset; + $bIsInsertion = $b->beginOffset === $b->untilOffset; + + if ($aIsInsertion && $bIsInsertion) { + return $a->beginOffset === $b->beginOffset; + } + + if ($aIsInsertion) { + return $a->beginOffset > $b->beginOffset && $a->beginOffset < $b->untilOffset; + } + + if ($bIsInsertion) { + return $b->beginOffset > $a->beginOffset && $b->beginOffset < $a->untilOffset; + } + + return $a->beginOffset < $b->untilOffset && $b->beginOffset < $a->untilOffset; + } +} diff --git a/src/Fix/FixPlan.php b/src/Fix/FixPlan.php new file mode 100644 index 0000000..d3d2f63 --- /dev/null +++ b/src/Fix/FixPlan.php @@ -0,0 +1,34 @@ + */ + public array $fixes; + + public function __construct(Fix $first, Fix ...$additionalFixes) + { + $fixes = [$first, ...$additionalFixes]; + + usort( + $fixes, + static fn(Fix $a, Fix $b): int => [ + $a->beginOffset, + $a->untilOffset, + ] <=> [ + $b->beginOffset, + $b->untilOffset, + ], + ); + + $this->fixes = $fixes; + } + + public function firstOffset(): int + { + return $this->fixes[0]->beginOffset; + } +} diff --git a/src/Fix/FixResult.php b/src/Fix/FixResult.php new file mode 100644 index 0000000..e4d4b00 --- /dev/null +++ b/src/Fix/FixResult.php @@ -0,0 +1,19 @@ + $appliedFixes */ + public function __construct( + public File $file, + public int $applied = 0, + public int $skipped = 0, + public array $appliedFixes = [], + ) { + } +} diff --git a/src/Fix/Fixer/AttributeOrderFixer.php b/src/Fix/Fixer/AttributeOrderFixer.php new file mode 100644 index 0000000..c49ad3e --- /dev/null +++ b/src/Fix/Fixer/AttributeOrderFixer.php @@ -0,0 +1,80 @@ +'; + private const string OPENING_TAG_PATTERN = '/^<([a-zA-Z0-9:_-]+)\b([^<>]*?)>$/'; + private const string ATTRIBUTE_TOKEN_PATTERN = '/\s+([a-zA-Z0-9:_-]+)\s*=\s*(?:"[^"]*"|\'[^\']*\')/'; + + /** @throws FixerException */ + public function process(Violation $violation): Fix + { + if ($violation->content === null) { + throw FixerException::cannotFixMissingContent(); + } + + if (!preg_match(self::OPENING_TAG_PATTERN, $violation->content, $matches)) { + throw FixerException::cannotFixInvalidContent($violation); + } + + $fixedAttributeString = $this->fixedAttributeString($matches[2]); + + if ($fixedAttributeString === null) { + throw FixerException::cannotFixInvalidContent($violation); + } + + return new Fix( + $violation->filePath, + $violation->beginOffset, + $violation->untilOffset, + sprintf(self::OPENING_TAG_FORMAT, $matches[1], $fixedAttributeString), + $violation->sniffCode, + ); + } + + private function fixedAttributeString(string $attrString): ?string + { + if (!str_contains($attrString, 'xml:id') || !str_contains($attrString, 'xmlns')) { + return null; + } + + preg_match_all(self::ATTRIBUTE_TOKEN_PATTERN, $attrString, $matches, PREG_OFFSET_CAPTURE); + + $xmlIdToken = null; + $firstXmlnsStart = null; + + foreach ($matches[0] as $i => [$token, $start]) { + $start = (int) $start; + $name = $matches[1][$i][0]; + + if ($name === 'xml:id') { + $xmlIdToken = [ + 'text' => $token, + 'start' => $start, + 'end' => $start + strlen($token), + ]; + } + + if (($name === 'xmlns' || str_starts_with($name, 'xmlns:')) && $firstXmlnsStart === null) { + $firstXmlnsStart = $start; + } + } + + if ($xmlIdToken === null || $firstXmlnsStart === null || $xmlIdToken['start'] < $firstXmlnsStart) { + return null; + } + + return substr($attrString, 0, $firstXmlnsStart) + . $xmlIdToken['text'] + . substr($attrString, $firstXmlnsStart, $xmlIdToken['start'] - $firstXmlnsStart) + . substr($attrString, $xmlIdToken['end']); + } +} diff --git a/src/Fix/Fixer/ExceptionNameFixer.php b/src/Fix/Fixer/ExceptionNameFixer.php new file mode 100644 index 0000000..0949246 --- /dev/null +++ b/src/Fix/Fixer/ExceptionNameFixer.php @@ -0,0 +1,54 @@ +]*)>([^<]*)<\/classname>$/'; + + /** @throws FixerException */ + public function process(Violation $violation): FixPlan + { + if ($violation->content === null) { + throw FixerException::cannotFixMissingContent(); + } + + if (!preg_match(self::CLASSNAME_PATTERN, $violation->content, $matches)) { + throw FixerException::cannotFixInvalidContent($violation); + } + + $text = trim($matches[2]); + + if ($text === '' || !ExceptionNameSniff::looksLikeException($text)) { + throw FixerException::cannotFixInvalidContent($violation); + } + + if (count($violation->affectedRanges) !== 2) { + throw FixerException::cannotFixInvalidContent($violation); + } + + $fixes = []; + foreach ($violation->affectedRanges as $range) { + $fixes[] = new Fix( + $violation->filePath, + $range->beginOffset, + $range->untilOffset, + self::TARGET_ELEMENT, + $violation->sniffCode, + self::SOURCE_ELEMENT, + ); + } + + return new FixPlan(...$fixes); + } +} diff --git a/src/Fix/Fixer/Fixer.php b/src/Fix/Fixer/Fixer.php new file mode 100644 index 0000000..a488c7f --- /dev/null +++ b/src/Fix/Fixer/Fixer.php @@ -0,0 +1,16 @@ +content === null) { + throw FixerException::cannotFixMissingContent(); + } + + if ( + !preg_match(self::INDENTATION_PATTERN, $violation->content) + || !str_contains($violation->content, ' ') + || !str_contains($violation->content, "\t") + ) { + throw FixerException::cannotFixInvalidContent($violation); + } + + return new Fix( + filePath: $violation->filePath, + beginOffset: $violation->beginOffset, + untilOffset: $violation->untilOffset, + replacement: str_replace("\t", ' ', $violation->content), + sniffCode: $violation->sniffCode, + expectedContent: $violation->content, + ); + } +} diff --git a/src/Fix/Fixer/SimparaFixer.php b/src/Fix/Fixer/SimparaFixer.php new file mode 100644 index 0000000..b815a26 --- /dev/null +++ b/src/Fix/Fixer/SimparaFixer.php @@ -0,0 +1,47 @@ +]*)>(.*)<\/para>$/s'; + + /** @throws FixerException */ + public function process(Violation $violation): FixPlan + { + if ($violation->content === null) { + throw FixerException::cannotFixMissingContent(); + } + + if (!preg_match(self::PARA_PATTERN, $violation->content, $matches)) { + throw FixerException::cannotFixInvalidContent($violation); + } + + if (count($violation->affectedRanges) !== 2) { + throw FixerException::cannotFixInvalidContent($violation); + } + + $fixes = []; + foreach ($violation->affectedRanges as $range) { + $fixes[] = new Fix( + $violation->filePath, + $range->beginOffset, + $range->untilOffset, + self::TARGET_ELEMENT, + $violation->sniffCode, + self::SOURCE_ELEMENT, + ); + } + + return new FixPlan(...$fixes); + } +} diff --git a/src/Fix/Fixer/TrailingWhitespaceFixer.php b/src/Fix/Fixer/TrailingWhitespaceFixer.php new file mode 100644 index 0000000..092e534 --- /dev/null +++ b/src/Fix/Fixer/TrailingWhitespaceFixer.php @@ -0,0 +1,35 @@ +content === null) { + throw FixerException::cannotFixMissingContent(); + } + + if (!preg_match(self::WHITESPACE_PATTERN, $violation->content)) { + throw FixerException::cannotFixInvalidContent($violation); + } + + return new Fix( + filePath: $violation->filePath, + beginOffset: $violation->beginOffset, + untilOffset: $violation->untilOffset, + replacement: '', + sniffCode: $violation->sniffCode, + expectedContent: $violation->content, + ); + } +} diff --git a/src/Fix/Fixer/WhitespaceFixer.php b/src/Fix/Fixer/WhitespaceFixer.php new file mode 100644 index 0000000..f630150 --- /dev/null +++ b/src/Fix/Fixer/WhitespaceFixer.php @@ -0,0 +1,43 @@ +content === null) { + throw FixerException::cannotFixMissingContent(); + } + + $fixed = rtrim($violation->content, " \t"); + + if (preg_match('/^[ \t]+/', $fixed, $matches)) { + $fixedIndent = str_replace("\t", ' ', $matches[0]); + $fixed = $fixedIndent . substr($fixed, strlen($matches[0])); + } + + if ($fixed === $violation->content) { + throw FixerException::cannotFixInvalidContent($violation); + } + + return new Fix( + $violation->filePath, + $violation->beginOffset, + $violation->untilOffset, + $fixed, + $violation->sniffCode, + ); + } +} diff --git a/src/Fix/FixerException.php b/src/Fix/FixerException.php new file mode 100644 index 0000000..afad17b --- /dev/null +++ b/src/Fix/FixerException.php @@ -0,0 +1,49 @@ +sniffCode, + $violation->filePath, + $violation->line, + )); + } + + public static function cannotReadFixedContent(): self + { + return new self('Cannot read fixed content when no fix application was attempted.'); + } + + public static function invalidFixedXml(string $filePath): self + { + return new self( + sprintf('Fixers produced invalid XML for %s; no changes were written.', $filePath), + ); + } + + public static function didNotConverge(string $filePath): self + { + return new self( + sprintf('Fixers did not converge for %s; no changes were written.', $filePath), + ); + } +} diff --git a/src/Path/DiffPathLoader.php b/src/Path/DiffPathLoader.php new file mode 100644 index 0000000..82aa1d9 --- /dev/null +++ b/src/Path/DiffPathLoader.php @@ -0,0 +1,103 @@ + $projectRoots + */ + public function __construct( + private DiffChangeset $diff, + private string $workingDirectory, + private string $basePath, + private array $projectRoots, + private PathMatcher $matcher, + ) { + } + + public function load(): DiffChangeset + { + $changes = []; + + foreach ($this->diff->fileChanges as $fileChange) { + foreach ($this->candidates($fileChange->filePath) as $candidate) { + if ( + is_file($candidate) + && str_ends_with(strtolower($candidate), '.xml') + && $this->matcher->isIncluded($candidate) + ) { + $changes[$candidate] = new FileChange( + $candidate, + $fileChange->addedLineNumbers, + $fileChange->deletionAnchors, + ); + break; + } + } + } + + ksort($changes); + + return new DiffChangeset(array_values($changes)); + } + + /** @return list */ + private function candidates(string $path): array + { + $path = str_replace('\\', '/', $path); + + if ($this->isAbsolute($path)) { + return [$path]; + } + + $candidates = [ + $this->workingDirectory . '/' . $path, + $this->basePath . '/' . $path, + ]; + + foreach ($this->projectRoots as $root => $directory) { + $candidates[] = $root . '/' . $path; + + $prefix = trim(str_replace('\\', '/', $directory), '/') . '/'; + if ($prefix !== '/' && str_starts_with($path, $prefix)) { + $candidates[] = $root . '/' . substr($path, strlen($prefix)); + } + } + + return array_map($this->normalize(...), $candidates) + |> array_unique(...) + |> array_values(...); + } + + private function isAbsolute(string $path): bool + { + return str_starts_with($path, '/') || preg_match('#^[a-zA-Z]:/#', $path) === 1; + } + + private function normalize(string $path): string + { + $prefix = str_starts_with($path, '/') ? '/' : ''; + $segments = []; + + foreach (explode('/', str_replace('\\', '/', $path)) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + + if ($segment === '..' && $segments !== [] && end($segments) !== '..') { + array_pop($segments); + continue; + } + + $segments[] = $segment; + } + + return $prefix . implode('/', $segments); + } +} diff --git a/src/Path/EntityResolver.php b/src/Path/EntityResolver.php index 64d2200..aafb27b 100644 --- a/src/Path/EntityResolver.php +++ b/src/Path/EntityResolver.php @@ -8,6 +8,12 @@ final class EntityResolver { private string $extension; + /** @var array|null */ + private ?array $resolvedEntities = null; + + /** @var array|null */ + private ?array $resolvedPaths = null; + /** * @param array $projectRoots * @param list $entityPaths @@ -26,15 +32,41 @@ public function __construct( */ public function resolve(): array { + $this->resolveAll(); + + return $this->resolvedEntities ?? []; + } + + /** + * @return array + * @throws \UnexpectedValueException if the directory cannot be read. + */ + public function paths(): array + { + $this->resolveAll(); + + return $this->resolvedPaths ?? []; + } + + /** @throws \UnexpectedValueException if the directory cannot be read. */ + private function resolveAll(): void + { + if ($this->resolvedEntities !== null && $this->resolvedPaths !== null) { + return; + } + $entities = []; + $paths = []; foreach ($this->entityPaths as $path) { foreach ($this->getEntityFiles($path) as $file) { - $entities += $this->resolveFile($file); + $visited = []; + $entities += $this->resolveFile($file, $visited, $paths); } } - return $entities; + $this->resolvedEntities = $entities; + $this->resolvedPaths = $paths; } /** @@ -86,13 +118,11 @@ private function scanDirectory(string $directory): array /** * @param array $visited + * @param array $paths * @return array */ - private function resolveFile( - string $filePath, - array &$visited = [], - ?string $originEntity = null - ): array { + private function resolveFile(string $filePath, array &$visited, array &$paths, ?string $originEntity = null): array + { if (isset($visited[$filePath]) || !is_readable($filePath)) { return []; } @@ -105,7 +135,7 @@ private function resolveFile( return []; // @codeCoverageIgnore } - $entities = $this->extractEntities($content, $filePath, $visited); + $entities = $this->extractEntities($content, $filePath, $visited, $paths); if ($originEntity !== null) { $entities[$originEntity] = $this->normalize($content); @@ -116,27 +146,22 @@ private function resolveFile( /** * @param array $visited + * @param array $paths * @return array */ - private function extractEntities( - string $content, - string $filePath, - array &$visited - ): array { - return - $this->extractDtdEntities($content, $filePath, $visited) + private function extractEntities(string $content, string $filePath, array &$visited, array &$paths): array + { + return $this->extractDtdEntities($content, $filePath, $visited, $paths) + $this->extractXmlEntities($content); } /** * @param array $visited + * @param array $paths * @return array */ - private function extractDtdEntities( - string $content, - string $filePath, - array &$visited - ): array { + private function extractDtdEntities(string $content, string $filePath, array &$visited, array &$paths): array + { $result = []; if (!str_contains($content, 'resolvePath($filePath, $value); - $result += $this->resolveFile($resolvedPath, $visited, $name); + + if (is_readable($resolvedPath)) { + $paths[$name] ??= $resolvedPath; + } + + $result += $this->resolveFile($resolvedPath, $visited, $paths, $name); continue; } diff --git a/src/Process/NativeProcessRunner.php b/src/Process/NativeProcessRunner.php new file mode 100644 index 0000000..dce353c --- /dev/null +++ b/src/Process/NativeProcessRunner.php @@ -0,0 +1,38 @@ + $command + * @throws \RuntimeException if the process cannot be started. + */ + public function run(array $command, string $workingDirectory): ProcessResult; +} diff --git a/src/RelativePath.php b/src/RelativePath.php new file mode 100644 index 0000000..1f0689b --- /dev/null +++ b/src/RelativePath.php @@ -0,0 +1,23 @@ + */ @@ -19,6 +22,14 @@ public function addViolation(Violation $violation): void $this->violations[] = $violation; } + /** @param list $violations */ + public function addViolations(array $violations): void + { + foreach ($violations as $violation) { + $this->addViolation($violation); + } + } + /** @return list */ public function getViolations(): array { diff --git a/src/Report/Report.php b/src/Report/Report.php index 37ccbe5..77e7335 100644 --- a/src/Report/Report.php +++ b/src/Report/Report.php @@ -4,6 +4,8 @@ namespace DocbookCS\Report; +use DocbookCS\Violation\Violation; + final class Report { /** @var array */ diff --git a/src/Report/Reporter/CheckstyleReporter.php b/src/Report/Reporter/CheckstyleReporter.php index 6076f2c..024d9f9 100644 --- a/src/Report/Reporter/CheckstyleReporter.php +++ b/src/Report/Reporter/CheckstyleReporter.php @@ -4,6 +4,7 @@ namespace DocbookCS\Report\Reporter; +use DocbookCS\RelativePath; use DocbookCS\Report\Report; final class CheckstyleReporter implements ReporterInterface @@ -28,7 +29,7 @@ public function generate(Report $report): string } $fileNode = $dom->createElement('file'); - $fileNode->setAttribute('name', $fileReport->filePath); + $fileNode->setAttribute('name', RelativePath::fromWorkingDirectory($fileReport->filePath)); foreach ($fileReport->getViolations() as $violation) { $errorNode = $dom->createElement('error'); diff --git a/src/Report/Reporter/ConsoleReporter.php b/src/Report/Reporter/ConsoleReporter.php index 2599bce..b6967c6 100644 --- a/src/Report/Reporter/ConsoleReporter.php +++ b/src/Report/Reporter/ConsoleReporter.php @@ -4,8 +4,9 @@ namespace DocbookCS\Report\Reporter; +use DocbookCS\RelativePath; use DocbookCS\Report\Report; -use DocbookCS\Report\Severity; +use DocbookCS\Violation\Severity; final class ConsoleReporter implements ReporterInterface { @@ -27,9 +28,11 @@ public function generate(Report $report): string continue; } + $filePath = RelativePath::fromWorkingDirectory($fileReport->filePath); + $output .= PHP_EOL; - $output .= $this->bold('FILE: ' . $fileReport->filePath) . PHP_EOL; - $output .= str_repeat('-', min(80, 6 + strlen($fileReport->filePath))) . PHP_EOL; + $output .= $this->bold('FILE: ' . $filePath) . PHP_EOL; + $output .= str_repeat('-', min(80, 6 + strlen($filePath))) . PHP_EOL; foreach ($fileReport->getViolations() as $violation) { $output .= sprintf( @@ -41,7 +44,7 @@ public function generate(Report $report): string ) . PHP_EOL; } - $output .= str_repeat('-', min(80, 6 + strlen($fileReport->filePath))) . PHP_EOL; + $output .= str_repeat('-', min(80, 6 + strlen($filePath))) . PHP_EOL; } $output .= PHP_EOL; diff --git a/src/Report/Reporter/JsonReporter.php b/src/Report/Reporter/JsonReporter.php index 0e52b0f..0eb3e0f 100644 --- a/src/Report/Reporter/JsonReporter.php +++ b/src/Report/Reporter/JsonReporter.php @@ -4,6 +4,7 @@ namespace DocbookCS\Report\Reporter; +use DocbookCS\RelativePath; use DocbookCS\Report\Report; final class JsonReporter implements ReporterInterface @@ -38,7 +39,7 @@ public function generate(Report $report): string ]; } - $data['files'][$fileReport->filePath] = [ + $data['files'][RelativePath::fromWorkingDirectory($fileReport->filePath)] = [ 'violations' => count($violations), 'messages' => $violations, ]; diff --git a/src/Report/Violation.php b/src/Report/Violation.php deleted file mode 100644 index 24df707..0000000 --- a/src/Report/Violation.php +++ /dev/null @@ -1,17 +0,0 @@ -||<\?[\s\S]*?\?>|' . self::ENTITY_PATTERN . '/', function (array $matches) use (&$changed, $markXmlExpansions): string { - if ( - str_starts_with($matches[0], '', + ' ']]>', + ' '?>', + ]; + /** @var array */ protected array $properties = []; + public function __construct( + public RunMode $mode = RunMode::Sniff, + ) { + } + public function setProperty(string $name, string $value): void { $this->properties[$name] = $value; @@ -28,20 +42,146 @@ protected function isSourceBacked(\DOMNode $node): bool return !EntityExpansionMarker::contains($node); } - /** @throws \LogicException if an invalid severity level is configured */ + /** + * The offsets point at the opening "<" and closing "<" in the source. + * + * @return array{SourceRange, SourceRange} + * @throws \OutOfBoundsException if a tag offset lies outside the source + */ + protected function elementNameRanges( + File $file, + int $openingTagOffset, + int $closingTagOffset, + string $elementName, + ): array { + $openingNameOffset = $openingTagOffset + 1; + $closingNameOffset = $closingTagOffset + 2; + $elementNameLength = strlen($elementName); + + return [ + new SourceRange( + $file->lineNumberAtOffset($openingNameOffset), + $openingNameOffset, + $openingNameOffset + $elementNameLength, + ), + new SourceRange( + $file->lineNumberAtOffset($closingNameOffset), + $closingNameOffset, + $closingNameOffset + $elementNameLength, + ), + ]; + } + + /** + * @param list $affectedRanges + * @throws \LogicException if an invalid severity level is configured + */ protected function createViolation( string $filePath, int $line, + int $beginOffset, + int $untilOffset, string $message, + ?string $content = null, Severity $severity = Severity::ERROR, + array $affectedRanges = [], ): Violation { return new Violation( - sniffCode: $this->getCode(), + sniffCode: static::getCode(), filePath: $filePath, line: $line, + beginOffset: $beginOffset, + untilOffset: $untilOffset, message: $message, + content: $content, severity: Severity::tryFrom($this->getProperty('severity', $severity->value)) ?: throw new \LogicException('Invalid severity level configured for ExceptionNameSniff.'), + affectedRanges: $affectedRanges, ); } + + protected function maskNonElementMarkup(string $source): string + { + $masked = $source; + $offset = 0; + + while (false !== $start = strpos($source, '<', $offset)) { + $endOffset = $this->nonElementMarkupEndOffset($source, $start); + + if ($endOffset === null) { + $offset = $start + 1; + continue; + } + + for ($i = $start; $i < $endOffset; $i++) { + $masked[$i] = ' '; + } + + $offset = $endOffset; + } + + return $masked; + } + + private function nonElementMarkupEndOffset(string $source, int $start): ?int + { + foreach (self::NON_ELEMENT_DELIMITERS as $opening => $closing) { + if (substr_compare($source, $opening, $start, strlen($opening)) === 0) { + return $this->offsetAfterDelimiter($source, $closing, $start); + } + } + + if (substr_compare($source, 'declarationEndOffset($source, $start); + } + + return null; + } + + private function offsetAfterDelimiter(string $source, string $delimiter, int $offset): int + { + $end = strpos($source, $delimiter, $offset); + + return $end === false ? strlen($source) : $end + strlen($delimiter); + } + + private function declarationEndOffset(string $source, int $offset): int + { + $length = strlen($source); + $quote = null; + $bracketDepth = 0; + + for ($i = $offset; $i < $length; $i++) { + $character = $source[$i]; + + if ($quote !== null) { + if ($character === $quote) { + $quote = null; + } + + continue; + } + + if ($character === '"' || $character === "'") { + $quote = $character; + continue; + } + + if ($character === '[') { + $bracketDepth++; + continue; + } + + if ($character === ']') { + $bracketDepth--; + continue; + } + + if ($character === '>' && $bracketDepth === 0) { + return $i + 1; + } + } + + return $length; + } } diff --git a/src/Sniff/AttributeOrderSniff.php b/src/Sniff/AttributeOrderSniff.php index b4d03eb..80050bf 100644 --- a/src/Sniff/AttributeOrderSniff.php +++ b/src/Sniff/AttributeOrderSniff.php @@ -4,6 +4,9 @@ namespace DocbookCS\Sniff; +use DocbookCS\Fix\Fixer\AttributeOrderFixer; +use DocbookCS\Source\File; + /** * Ensures that when an element has both xml:id and xmlns (or xmlns:*) * attributes, xml:id appears first. @@ -11,20 +14,36 @@ * This is a stylistic convention in the PHP documentation project: * identity attributes should precede namespace declarations. */ -final class AttributeOrderSniff extends AbstractSniff +final class AttributeOrderSniff extends AbstractSniff implements Fixable { - public function getCode(): string + private const string OPENING_TAG_PATTERN = '/<([a-zA-Z0-9:_-]+)\b([^<>]*?)>/'; + private const string ATTRIBUTE_NAME_PATTERN = '/([a-zA-Z0-9:_-]+)\s*=/'; + + public static function getCode(): string { return 'DocbookCS.AttributeOrder'; } - /** @throws \LogicException if an invalid severity level is configured */ - public function process(\DOMDocument $document, string $content, string $filePath): array + public static function fixerClassName(): string + { + return AttributeOrderFixer::class; + } + + /** + * @throws \LogicException if an invalid severity level is configured + * @throws \OutOfBoundsException if a matched tag offset lies outside the source + */ + public function process(\DOMDocument $document, File $file): array { $violations = []; // Match ONLY opening tags (skip closing, comments, xml decl) - preg_match_all('/<([a-zA-Z0-9:_-]+)\b([^<>]*?)>/s', $content, $matches, PREG_OFFSET_CAPTURE); + preg_match_all( + self::OPENING_TAG_PATTERN, + $this->maskNonElementMarkup($file->content), + $matches, + PREG_OFFSET_CAPTURE, + ); foreach ($matches[0] as $i => [$fullMatch, $offset]) { $tagName = $matches[1][$i][0]; @@ -38,12 +57,17 @@ public function process(\DOMDocument $document, string $content, string $filePat continue; } + $beginOffset = (int) $offset; + $this->checkAttributes( $tagName, $attrString, - $filePath, - $this->lineFromOffset($content, (int)$offset), - $violations + $file->path, + $file->lineNumberAtOffset($beginOffset), + $beginOffset, + $beginOffset + strlen($fullMatch), + $violations, + $fullMatch, ); } @@ -51,7 +75,8 @@ public function process(\DOMDocument $document, string $content, string $filePat } /** - * @param list<\DocbookCS\Report\Violation> &$violations + * @param list<\DocbookCS\Violation\Violation> &$violations + * * @throws \LogicException if an invalid severity level is configured */ private function checkAttributes( @@ -59,9 +84,12 @@ private function checkAttributes( string $attrString, string $filePath, int $line, - array &$violations + int $beginOffset, + int $untilOffset, + array &$violations, + string $content, ): void { - preg_match_all('/([a-zA-Z0-9:_-]+)\s*=/', $attrString, $matches); + preg_match_all(self::ATTRIBUTE_NAME_PATTERN, $attrString, $matches); $attributes = $matches[1]; $xmlIdPos = null; @@ -80,20 +108,17 @@ private function checkAttributes( } } - if ($xmlIdPos !== null && $xmlnsPos !== PHP_INT_MAX && $xmlIdPos > $xmlnsPos) { - $violations[] = $this->createViolation( - $filePath, - $line, - sprintf( - 'Element <%s>: xml:id should appear before xmlns attributes.', - $tagName, - ), - ); + if ($xmlIdPos === null || $xmlnsPos === PHP_INT_MAX || $xmlIdPos <= $xmlnsPos) { + return; } - } - private function lineFromOffset(string $content, int $offset): int - { - return substr_count($content, "\n", 0, $offset) + 1; + $violations[] = $this->createViolation( + $filePath, + $line, + $beginOffset, + $untilOffset, + sprintf('Element <%s>: xml:id should appear before xmlns attributes.', $tagName), + $content, + ); } } diff --git a/src/Sniff/ExceptionNameSniff.php b/src/Sniff/ExceptionNameSniff.php index acbfbda..f6c9fef 100644 --- a/src/Sniff/ExceptionNameSniff.php +++ b/src/Sniff/ExceptionNameSniff.php @@ -4,6 +4,9 @@ namespace DocbookCS\Sniff; +use DocbookCS\Fix\Fixer\ExceptionNameFixer; +use DocbookCS\Source\File; + /** * Detects exception/error class names wrapped in that * should use instead. @@ -12,10 +15,13 @@ * exception." When a element's text content matches a * known exception/error pattern, this sniff flags it. */ -final class ExceptionNameSniff extends AbstractSniff +final class ExceptionNameSniff extends AbstractSniff implements Fixable { + private const string ELEMENT_NAME = 'classname'; + /** * Default suffixes that indicate the class is an exception or error. + * @var list */ private const array DEFAULT_SUFFIXES = [ 'Exception', @@ -23,18 +29,33 @@ final class ExceptionNameSniff extends AbstractSniff 'Throwable', ]; - public function getCode(): string + private const string CLASSNAME_PATTERN = '/]*>([^<]*)<\/classname>/'; + + public static function getCode(): string { return 'DocbookCS.ExceptionName'; } - /** @throws \LogicException if an invalid severity level is configured */ - public function process(\DOMDocument $document, string $content, string $filePath): array + public static function fixerClassName(): string + { + return ExceptionNameFixer::class; + } + + /** + * @throws \LogicException + * @throws \OutOfBoundsException if a matched tag offset lies outside the source + */ + public function process(\DOMDocument $document, File $file): array { $violations = []; - $suffixes = $this->getSuffixes(); + $sourceMatchIndex = 0; $classnames = $document->getElementsByTagName('classname'); + if ($classnames->length === 0) { + return []; + } + + $sourceMatches = $this->sourceMatches($file); /** @var \DOMElement $node */ foreach ($classnames as $node) { @@ -43,6 +64,8 @@ public function process(\DOMDocument $document, string $content, string $filePat } $text = trim($node->textContent); + $match = $sourceMatches[$sourceMatchIndex] ?? null; + $sourceMatchIndex++; if ($text === '') { continue; @@ -52,38 +75,74 @@ public function process(\DOMDocument $document, string $content, string $filePat continue; } - if ($this->looksLikeException($text, $suffixes)) { - $violations[] = $this->createViolation( - $filePath, - $node->getLineNo(), - sprintf( - '"%s" is wrapped in but should use .', - $text, - ), - ); + if (!self::looksLikeException($text)) { + continue; + } + + if ($match === null || $match['text'] !== $text) { + throw new \LogicException('Could not map classname violation to source content.'); } + + $affectedRanges = $this->elementNameRanges( + $file, + $match['beginOffset'], + $match['closingOffset'], + self::ELEMENT_NAME, + ); + + $violations[] = $this->createViolation( + $file->path, + $affectedRanges[0]->line, + $match['beginOffset'], + $match['untilOffset'], + sprintf('"%s" is wrapped in but should use .', $text), + $match['content'], + affectedRanges: $affectedRanges, + ); } return $violations; } - /** - * @param list $suffixes - */ - private function looksLikeException(string $text, array $suffixes): bool + public static function looksLikeException(string $text): bool { $parts = explode('\\', $text); $baseName = end($parts); - return array_any( - $suffixes, - static fn($suffix) => str_ends_with($baseName, $suffix) - ); + return array_any(self::DEFAULT_SUFFIXES, static fn(string $suffix): bool => str_ends_with($baseName, $suffix)); } - /** @return list */ - private function getSuffixes(): array + /** + * @return list + */ + private function sourceMatches(File $file): array { - return self::DEFAULT_SUFFIXES; + preg_match_all( + self::CLASSNAME_PATTERN, + $this->maskNonElementMarkup($file->content), + $matches, + PREG_OFFSET_CAPTURE, + ); + + $sourceMatches = []; + foreach ($matches[0] as $i => [$fullMatch, $offset]) { + $offset = (int) $offset; + $closingOffset = $offset + (int) strrpos($fullMatch, ''); + $sourceMatches[] = [ + 'beginOffset' => $offset, + 'untilOffset' => $offset + strlen($fullMatch), + 'content' => $fullMatch, + 'text' => trim($matches[1][$i][0]), + 'closingOffset' => $closingOffset, + ]; + } + + return $sourceMatches; } } diff --git a/src/Sniff/Fixable.php b/src/Sniff/Fixable.php new file mode 100644 index 0000000..e2a4018 --- /dev/null +++ b/src/Sniff/Fixable.php @@ -0,0 +1,13 @@ + */ + public static function fixerClassName(): string; +} diff --git a/src/Sniff/MixedIndentationSniff.php b/src/Sniff/MixedIndentationSniff.php new file mode 100644 index 0000000..ee01c63 --- /dev/null +++ b/src/Sniff/MixedIndentationSniff.php @@ -0,0 +1,52 @@ +lines() as $line) { + if (!preg_match(self::INDENTATION_PATTERN, $line->content, $matches)) { + continue; + } + + $indentation = $matches[0]; + if (!str_contains($indentation, ' ') || !str_contains($indentation, "\t")) { + continue; + } + + $violations[] = $this->createViolation( + $file->path, + $line->number, + $line->beginOffset, + $line->beginOffset + strlen($indentation), + self::MESSAGE, + $indentation, + ); + } + + return $violations; + } +} diff --git a/src/Sniff/SimparaSniff.php b/src/Sniff/SimparaSniff.php index 667da93..978ea32 100644 --- a/src/Sniff/SimparaSniff.php +++ b/src/Sniff/SimparaSniff.php @@ -4,8 +4,15 @@ namespace DocbookCS\Sniff; -final class SimparaSniff extends AbstractSniff +use DocbookCS\Fix\Fixer\SimparaFixer; +use DocbookCS\Source\File; + +final class SimparaSniff extends AbstractSniff implements Fixable { + private const string ELEMENT_NAME = 'para'; + private const string MESSAGE = ' contains only inline content and should be .'; + private const string PARA_TAG_PATTERN = '/<\/?para\b[^>]*>/'; + private const array SIMPARA_ALLOWED = [ 'abbrev', 'acronym', @@ -96,18 +103,32 @@ final class SimparaSniff extends AbstractSniff 'xref', ]; - public function getCode(): string + public static function getCode(): string { return 'DocbookCS.Simpara'; } - /** @throws \LogicException if an invalid severity level is configured */ - public function process(\DOMDocument $document, string $content, string $filePath): array + public static function fixerClassName(): string + { + return SimparaFixer::class; + } + + /** + * @throws \LogicException if an invalid severity level is configured + * @throws \OutOfBoundsException if a matched tag offset lies outside the source + */ + public function process(\DOMDocument $document, File $file): array { $violations = []; - $allowed = $this->getAllowedElements(); + $sourceMatchIndex = 0; $paras = $document->getElementsByTagName('para'); + if ($paras->length === 0) { + return []; + } + + $sourceMatches = $this->sourceMatches($file); + $allowed = $this->getAllowedElements(); /** @var \DOMElement $para */ foreach ($paras as $para) { @@ -115,6 +136,17 @@ public function process(\DOMDocument $document, string $content, string $filePat continue; } + $match = $sourceMatches[$sourceMatchIndex] ?? null; + $sourceMatchIndex++; + + if ($match === null) { + throw new \LogicException('Could not map simpara violation to source content.'); + } + + if ($match['selfClosing']) { + continue; + } + $parent = $para->parentNode; if ( $parent instanceof \DOMElement @@ -123,13 +155,31 @@ public function process(\DOMDocument $document, string $content, string $filePat continue; } - if ($this->isSimple($para, $allowed)) { - $violations[] = $this->createViolation( - $filePath, - $para->getLineNo(), - ' contains only inline content and should be .', - ); + if (!$this->isSimple($para, $allowed)) { + continue; } + + $closingOffset = $match['closingOffset']; + if ($closingOffset === null) { + throw new \LogicException('Could not map simpara violation to source content.'); + } + + $affectedRanges = $this->elementNameRanges( + $file, + $match['beginOffset'], + $closingOffset, + self::ELEMENT_NAME, + ); + + $violations[] = $this->createViolation( + $file->path, + $affectedRanges[0]->line, + $match['beginOffset'], + $match['untilOffset'], + self::MESSAGE, + $match['content'], + affectedRanges: $affectedRanges, + ); } return $violations; @@ -141,12 +191,14 @@ public function process(\DOMDocument $document, string $content, string $filePat private function isSimple(\DOMElement $node, array $allowed): bool { foreach ($node->childNodes as $child) { - if ($child instanceof \DOMElement) { - $name = strtolower($child->localName ?: ''); + if (!$child instanceof \DOMElement) { + continue; + } + + $name = strtolower($child->localName ?: ''); - if (!in_array($name, $allowed, true)) { - return false; - } + if (!in_array($name, $allowed, true)) { + return false; } } @@ -165,6 +217,68 @@ private function getAllowedElements(): array $additional = array_map('trim', explode(',', $extra)); $additional = array_filter($additional, static fn(string $s): bool => $s !== ''); - return array_values(array_unique(array_merge(self::SIMPARA_ALLOWED, $additional))); + return array_merge(self::SIMPARA_ALLOWED, $additional) + |> array_unique(...) + |> array_values(...); + } + + /** + * @return list + */ + private function sourceMatches(File $file): array + { + preg_match_all( + self::PARA_TAG_PATTERN, + $this->maskNonElementMarkup($file->content), + $matches, + PREG_OFFSET_CAPTURE, + ); + + /** @var list $stack */ + $stack = []; + $sourceMatches = []; + + foreach ($matches[0] as [$tag, $offset]) { + $offset = (int) $offset; + + if (str_ends_with(rtrim($tag), '/>')) { + $sourceMatches[] = [ + 'beginOffset' => $offset, + 'untilOffset' => $offset + strlen($tag), + 'content' => $tag, + 'selfClosing' => true, + 'closingOffset' => null, + ]; + continue; + } + + if (!str_starts_with($tag, ' $opening, + 'untilOffset' => $untilOffset, + 'content' => substr($file->content, $opening, $untilOffset - $opening), + 'selfClosing' => false, + 'closingOffset' => $offset, + ]; + } + + usort($sourceMatches, static fn(array $a, array $b): int => $a['beginOffset'] <=> $b['beginOffset']); + + return $sourceMatches; } } diff --git a/src/Sniff/SniffInterface.php b/src/Sniff/SniffInterface.php index 4cc1513..b138f99 100644 --- a/src/Sniff/SniffInterface.php +++ b/src/Sniff/SniffInterface.php @@ -4,25 +4,30 @@ namespace DocbookCS\Sniff; -use DocbookCS\Report\Violation; +use DocbookCS\Runner\RunMode; +use DocbookCS\Source\File; /** - * A sniff receives a DOMDocument (already loaded) and the file path, - * then returns zero or more Violation value objects. + * A sniff receives a DOMDocument (already loaded) and its source file, + * then returns zero or more findings for reports and optional fixes. */ interface SniffInterface { + public RunMode $mode { get; } + + public function __construct(RunMode $mode); + /** * Unique, human-readable code for this sniff (e.g. "DocbookCS.MySniff"). */ - public function getCode(): string; + public static function getCode(): string; /** * Apply the sniff to the given document. * - * @return list + * @return list<\DocbookCS\Violation\Violation> */ - public function process(\DOMDocument $document, string $content, string $filePath): array; + public function process(\DOMDocument $document, File $file): array; /** * Accept a key/value property from the configuration. diff --git a/src/Sniff/TrailingWhitespaceSniff.php b/src/Sniff/TrailingWhitespaceSniff.php new file mode 100644 index 0000000..c9e4798 --- /dev/null +++ b/src/Sniff/TrailingWhitespaceSniff.php @@ -0,0 +1,50 @@ +lines() as $line) { + if (!preg_match(self::TRAILING_WHITESPACE_PATTERN, $line->content, $matches, PREG_OFFSET_CAPTURE)) { + continue; + } + + [$whitespace, $relativeOffset] = $matches[0]; + $beginOffset = $line->beginOffset + (int) $relativeOffset; + + $violations[] = $this->createViolation( + $file->path, + $line->number, + $beginOffset, + $beginOffset + strlen($whitespace), + self::MESSAGE, + $whitespace, + ); + } + + return $violations; + } +} diff --git a/src/Sniff/WhitespaceSniff.php b/src/Sniff/WhitespaceSniff.php index 6f72852..7ad8447 100644 --- a/src/Sniff/WhitespaceSniff.php +++ b/src/Sniff/WhitespaceSniff.php @@ -4,40 +4,64 @@ namespace DocbookCS\Sniff; +use DocbookCS\Fix\Fixer\WhitespaceFixer; +use DocbookCS\Source\File; + /** - * Detects whitespace and indentation issues in DocBook source files. - * - * The following violations are detected: - * - Trailing whitespace at the end of a line - * - Spaces used before tabs in indentation - * - Mixed use of tabs and spaces within indentation + * Backward-compatible aggregate of the focused whitespace rules. + * New configurations should use TrailingWhitespaceSniff and MixedIndentationSniff. */ -final class WhitespaceSniff extends AbstractSniff +final class WhitespaceSniff extends AbstractSniff implements Fixable { - public function getCode(): string + private const string LINE_ENDING_PATTERN = '/(\r\n|\n|\r)/'; + private const string WHITESPACE_PATTERN = '/([ \t]+$)|^(\t* +\t+|\t+ +\t*)|^( +)\t/'; + + public static function getCode(): string { return 'DocbookCS.Whitespace'; } - /** @throws \LogicException if an invalid severity level is configured */ - public function process(\DOMDocument $document, string $content, string $filePath): array + public static function fixerClassName(): string { - $lines = explode(PHP_EOL, $content); - $pattern = '/([ \t]+$)|^(\t* +\t+|\t+ +\t*)|^( +)\t/'; + return WhitespaceFixer::class; + } + /** @throws \LogicException if an invalid severity level is configured */ + public function process(\DOMDocument $document, File $file): array + { $violations = []; - foreach ($lines as $lineNumber => $line) { - $lineNo = $lineNumber + 1; + $offset = 0; + $line = 1; + + $lines = preg_split(self::LINE_ENDING_PATTERN, $file->content, -1, PREG_SPLIT_DELIM_CAPTURE); + if ($lines === false) { + throw new \LogicException('Could not split source content into lines.'); // @codeCoverageIgnore + } - if (preg_match($pattern, $line, $matches)) { + for ($i = 0; $i < count($lines); $i += 2) { + $lineContent = $lines[$i]; + $lineContentLength = strlen($lineContent); + $lineEnding = $lines[$i + 1] ?? ''; + + if (preg_match(self::WHITESPACE_PATTERN, $lineContent, $matches)) { $message = match (true) { !empty($matches[1]) => 'Trailing whitespace detected.', !empty($matches[2]) || !empty($matches[3]) => 'Mixed tabs and spaces in indentation.', default => 'Inconsistent indentation.', // @codeCoverageIgnore }; - $violations[] = $this->createViolation($filePath, $lineNo, $message); + $violations[] = $this->createViolation( + $file->path, + $line, + $offset, + $offset + $lineContentLength, + $message, + $lineContent, + ); } + + $offset += $lineContentLength + strlen($lineEnding); + $line++; } return $violations; diff --git a/src/Source/File.php b/src/Source/File.php new file mode 100644 index 0000000..02f8be5 --- /dev/null +++ b/src/Source/File.php @@ -0,0 +1,135 @@ +|null */ + private ?array $lineBeginOffsets = null; + + public function __construct( + public readonly string $path, + public readonly string $content, + ) { + } + + /** @return \Generator */ + public function lines(): \Generator + { + $sourceLength = strlen($this->content); + $lineBeginOffset = 0; + $lineNumber = 1; + + while (true) { + $line = $this->createLineAtOffset($lineNumber, $lineBeginOffset); + yield $line; + + if ($line->offsetAfterContent() === $sourceLength) { + return; + } + + $lineBeginOffset = $line->offsetAfterLine(); + $lineNumber++; + } + } + + /** @throws \OutOfBoundsException if the offset lies outside the source */ + public function lineNumberAtOffset(int $offset): int + { + return $this->lineIndexAtOffset($offset, $this->lineBeginOffsets()) + 1; + } + + public function withContent(string $content): self + { + return $content === $this->content + ? $this + : new self($this->path, $content); + } + + /** @return non-empty-list */ + private function lineBeginOffsets(): array + { + if ($this->lineBeginOffsets !== null) { + return $this->lineBeginOffsets; + } + + $lineBeginOffsets = [0]; + $sourceLength = strlen($this->content); + $lineBeginOffset = 0; + + while ($lineBeginOffset < $sourceLength) { + $lineLength = strcspn($this->content, "\r\n", $lineBeginOffset); + $lineEndingOffset = $lineBeginOffset + $lineLength; + + if ($lineEndingOffset === $sourceLength) { + break; + } + + $lineBeginOffset = $lineEndingOffset + ( + $this->content[$lineEndingOffset] === "\r" + && ($this->content[$lineEndingOffset + 1] ?? null) === "\n" + ? 2 + : 1 + ); + $lineBeginOffsets[] = $lineBeginOffset; + } + + return $this->lineBeginOffsets = $lineBeginOffsets; + } + + /** + * @param non-empty-list $lineBeginOffsets + * @throws \OutOfBoundsException if the offset lies outside the source + */ + private function lineIndexAtOffset(int $offset, array $lineBeginOffsets): int + { + $sourceLength = strlen($this->content); + if ($offset < 0 || $offset > $sourceLength) { + throw new \OutOfBoundsException( + sprintf('Source offset %d is outside the valid range 0..%d.', $offset, $sourceLength), + ); + } + + $low = 0; + $high = count($lineBeginOffsets) - 1; + + while ($low < $high) { + $middle = intdiv($low + $high + 1, 2); + + if ($lineBeginOffsets[$middle] <= $offset) { + $low = $middle; + } else { + $high = $middle - 1; + } + } + + return $low; + } + + private function createLineAtOffset(int $lineNumber, int $lineBeginOffset): Line + { + $sourceLength = strlen($this->content); + $lineLength = strcspn($this->content, "\r\n", $lineBeginOffset); + $offsetAfterContent = $lineBeginOffset + $lineLength; + + return new Line( + number: $lineNumber, + content: substr($this->content, $lineBeginOffset, $lineLength), + lineEnding: $offsetAfterContent < $sourceLength + ? $this->lineEndingAt($offsetAfterContent) + : '', + beginOffset: $lineBeginOffset, + ); + } + + private function lineEndingAt(int $offset): string + { + if ($this->content[$offset] === "\r" && ($this->content[$offset + 1] ?? null) === "\n") { + return "\r\n"; + } + + return $this->content[$offset]; + } +} diff --git a/src/Source/Line.php b/src/Source/Line.php new file mode 100644 index 0000000..a8ad010 --- /dev/null +++ b/src/Source/Line.php @@ -0,0 +1,26 @@ +beginOffset + strlen($this->content); + } + + public function offsetAfterLine(): int + { + return $this->offsetAfterContent() + strlen($this->lineEnding); + } +} diff --git a/src/Report/Severity.php b/src/Violation/Severity.php similarity index 81% rename from src/Report/Severity.php rename to src/Violation/Severity.php index 77aae7a..e68382b 100644 --- a/src/Report/Severity.php +++ b/src/Violation/Severity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace DocbookCS\Report; +namespace DocbookCS\Violation; enum Severity: string { diff --git a/src/Violation/SourceRange.php b/src/Violation/SourceRange.php new file mode 100644 index 0000000..8206d9d --- /dev/null +++ b/src/Violation/SourceRange.php @@ -0,0 +1,15 @@ + */ + public array $affectedRanges; + + /** + * @param list $affectedRanges + */ + public function __construct( + public string $sniffCode, + public string $filePath, + public int $line, + public int $beginOffset, + public int $untilOffset, + public string $message, + public ?string $content = null, + public Severity $severity = Severity::WARNING, + array $affectedRanges = [], + ) { + $this->affectedRanges = $affectedRanges !== [] + ? array_values($affectedRanges) + : [new SourceRange($line, $beginOffset, $untilOffset)]; + } +} diff --git a/tests/Feature/ApplicationInputTest.php b/tests/Feature/ApplicationInputTest.php new file mode 100644 index 0000000..55a5513 --- /dev/null +++ b/tests/Feature/ApplicationInputTest.php @@ -0,0 +1,99 @@ +stdout = $stdout; + $this->stderr = $stderr; + } + + #[Test] + public function itRejectsAValuelessDiffOption(): void + { + $app = new Application( + ['docbook-cs', '--config=' . self::VALID_CONFIG, '--diff'], + $this->stdout, + $this->stderr, + ); + + self::assertSame(2, $app->run()); + self::assertStringContainsString('Unknown option: --diff', $this->readStream($this->stderr)); + } + + #[Test] + public function itDetectsAPipedDiffWithoutAFlag(): void + { + $app = new Application( + ['docbook-cs', '--config=' . self::VALID_CONFIG], + $this->stdout, + $this->stderr, + stdin: '', + ); + + self::assertSame(0, $app->run()); + self::assertSame('', $this->readStream($this->stderr)); + } + + #[Test] + public function itRejectsPathsCombinedWithAPipedDiff(): void + { + $app = new Application( + ['docbook-cs', '--config=' . self::VALID_CONFIG, self::SCAN_FILE], + $this->stdout, + $this->stderr, + stdin: '', + ); + + self::assertSame(2, $app->run()); + self::assertStringContainsString('Paths cannot be combined with diff input', $this->readStream($this->stderr)); + } + + #[Test] + public function itIncludesFixAndWideOptionsInHelp(): void + { + $app = new Application(['docbook-cs', '--help'], $this->stdout, $this->stderr); + + $app->run(); + + $output = $this->readStream($this->stdout); + + self::assertStringContainsString('--fix', $output); + self::assertStringContainsString('--wide', $output); + } + + /** @param resource $stream */ + private function readStream(mixed $stream): string + { + rewind($stream); + + return stream_get_contents($stream) ?: ''; + } +} diff --git a/tests/Unit/ApplicationTest.php b/tests/Feature/ApplicationTest.php similarity index 77% rename from tests/Unit/ApplicationTest.php rename to tests/Feature/ApplicationTest.php index 275288a..d60ff78 100644 --- a/tests/Unit/ApplicationTest.php +++ b/tests/Feature/ApplicationTest.php @@ -2,19 +2,23 @@ declare(strict_types=1); -namespace DocbookCS\Tests\Unit; +namespace DocbookCS\Tests\Feature; use DocbookCS\Application; use DocbookCS\Config\ConfigData; -use DocbookCS\Diff\Diff; +use DocbookCS\Diff\DiffChangeset; use DocbookCS\Diff\DiffParser; use DocbookCS\Diff\FileChange; +use DocbookCS\Diff\GitDiffProvider; use DocbookCS\Config\ConfigParser; use DocbookCS\Config\ConfigParserException; use DocbookCS\Config\SniffEntry; +use DocbookCS\Path\DiffPathLoader; use DocbookCS\Path\EntityResolver; use DocbookCS\Path\PathLoader; use DocbookCS\Path\PathMatcher; +use DocbookCS\Process\NativeProcessRunner; +use DocbookCS\Process\ProcessResult; use DocbookCS\Progress\NullProgress; use DocbookCS\Report\FileReport; use DocbookCS\Report\Report; @@ -22,9 +26,17 @@ use DocbookCS\Report\Reporter\ConsoleReporter; use DocbookCS\Report\Reporter\JsonReporter; use DocbookCS\Runner\EntityPreprocessor; -use DocbookCS\Runner\SniffRunner; +use DocbookCS\Runner\RunMode; +use DocbookCS\Runner\RunCoordinator; +use DocbookCS\Runner\RunPlan; +use DocbookCS\Runner\RunPlanner; +use DocbookCS\Runner\RunScopeResolver; +use DocbookCS\Runner\SourceScope; +use DocbookCS\Runner\ViolationScopeFilter; use DocbookCS\Runner\XmlFileProcessor; +use DocbookCS\Runner\XmlProcessingResult; use DocbookCS\Sniff\ExceptionNameSniff; +use DocbookCS\Source\File; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -47,11 +59,24 @@ CoversClass(PathLoader::class), CoversClass(PathMatcher::class), CoversClass(Report::class), + CoversClass(RunCoordinator::class), + CoversClass(RunMode::class), + CoversClass(RunPlan::class), + CoversClass(RunPlanner::class), CoversClass(SniffEntry::class), - CoversClass(SniffRunner::class), CoversClass(XmlFileProcessor::class), - UsesClass(Diff::class), + // + UsesClass(DiffChangeset::class), + UsesClass(DiffPathLoader::class), + UsesClass(File::class), UsesClass(FileChange::class), + UsesClass(GitDiffProvider::class), + UsesClass(NativeProcessRunner::class), + UsesClass(ProcessResult::class), + UsesClass(RunScopeResolver::class), + UsesClass(SourceScope::class), + UsesClass(ViolationScopeFilter::class), + UsesClass(XmlProcessingResult::class), ] final class ApplicationTest extends TestCase { @@ -249,7 +274,7 @@ public function itResolvesRelativeOverridePathsAgainstCwd(): void public function itCatchesRuntimeErrorFromRunner(): void { $app = new Application( - ['docbook-cs', '--config=' . self::INVALID_SNIFF_CONFIG], + ['docbook-cs', '--config=' . self::INVALID_SNIFF_CONFIG, self::SCAN_FILE], $this->stdout, $this->stderr, ); @@ -288,114 +313,6 @@ public function itPassesThroughAbsoluteOverridePaths(): void self::assertNotSame(2, $exitCode); } - #[Test] - public function itSupportsDiffFromFile(): void - { - $diffFile = tempnam(sys_get_temp_dir(), 'docbookcs_test_'); - self::assertIsString($diffFile); - - // Diff that references no XML files the config would normally scan. - file_put_contents($diffFile, <<<'DIFF' -diff --git a/nonexistent.xml b/nonexistent.xml ---- a/nonexistent.xml -+++ b/nonexistent.xml -@@ -1,1 +1,2 @@ - line1 -+line2 -DIFF); - - try { - $app = new Application( - ['docbook-cs', '--config=' . self::VALID_CONFIG, "--diff={$diffFile}"], - $this->stdout, - $this->stderr, - ); - - $exitCode = $app->run(); - - // No matching files → no violations → exit 0. - self::assertSame(0, $exitCode); - self::assertSame('', $this->readStream($this->stderr)); - } finally { - unlink($diffFile); - } - } - - #[Test] - public function itSupportsDiffFromStdin(): void - { - $stdin = fopen('php://memory', 'rb+'); - self::assertIsResource($stdin); - - fwrite($stdin, <<<'DIFF' -diff --git a/nonexistent.xml b/nonexistent.xml ---- a/nonexistent.xml -+++ b/nonexistent.xml -@@ -1,1 +1,2 @@ - line1 -+line2 -DIFF); - rewind($stdin); - - $app = new Application( - ['docbook-cs', '--config=' . self::VALID_CONFIG, '--diff'], - $this->stdout, - $this->stderr, - $stdin, - ); - - $exitCode = $app->run(); - - self::assertSame(0, $exitCode); - self::assertSame('', $this->readStream($this->stderr)); - } - - #[Test] - public function itSupportsDiffFromStdinWithExplicitDash(): void - { - $stdin = fopen('php://memory', 'rb+'); - self::assertIsResource($stdin); - - fwrite($stdin, ''); - rewind($stdin); - - $app = new Application( - ['docbook-cs', '--config=' . self::VALID_CONFIG, '--diff=-'], - $this->stdout, - $this->stderr, - $stdin, - ); - - $exitCode = $app->run(); - - self::assertSame(0, $exitCode); - } - - #[Test] - public function itReturnsErrorWhenDiffFileCannotBeRead(): void - { - $app = new Application( - ['docbook-cs', '--config=' . self::VALID_CONFIG, '--diff=/nonexistent/path.patch'], - $this->stdout, - $this->stderr, - ); - - $exitCode = $app->run(); - - self::assertSame(2, $exitCode); - self::assertStringContainsString('Error reading diff', $this->readStream($this->stderr)); - } - - #[Test] - public function itIncludesDiffOptionInHelp(): void - { - $app = new Application(['docbook-cs', '--help'], $this->stdout, $this->stderr); - - $app->run(); - - self::assertStringContainsString('--diff', $this->readStream($this->stdout)); - } - #[Test] public function itSuppressesProgressWhenQuietFlagIsSet(): void { diff --git a/tests/Integration/Diff/GitDiffProviderTest.php b/tests/Integration/Diff/GitDiffProviderTest.php new file mode 100644 index 0000000..9964bb8 --- /dev/null +++ b/tests/Integration/Diff/GitDiffProviderTest.php @@ -0,0 +1,113 @@ +processRunner = new NativeProcessRunner(); + + $tmpDir = sys_get_temp_dir() . '/docbook-cs-git-diff-' . bin2hex(random_bytes(6)); + mkdir($tmpDir); + $this->repository = $tmpDir; + } + + protected function tearDown(): void + { + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->repository, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + if (!$file instanceof \SplFileInfo) { + throw new \UnexpectedValueException('Unexpected directory entry.'); + } + + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + + rmdir($this->repository); + } + + #[Test] + public function itDiffsTheWorkingTreeFromTheUpstreamBranchPoint(): void + { + $this->git('init', '--quiet', '--initial-branch=main'); + $this->configureAuthor(); + + file_put_contents($this->repository . '/base.xml', "base\n"); + $this->git('add', 'base.xml'); + $this->git('commit', '--quiet', '-m', 'Base'); + + $base = $this->git('rev-parse', 'HEAD'); + $this->git('update-ref', 'refs/remotes/upstream/main', $base); + $this->git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main'); + $this->git('switch', '--quiet', '-c', 'contribution'); + $this->git('branch', '--delete', '--force', 'main'); + + file_put_contents($this->repository . '/committed.xml', "committed\n"); + $this->git('add', 'committed.xml'); + $this->git('commit', '--quiet', '-m', 'Contribution'); + file_put_contents($this->repository . '/base.xml', "working tree\n"); + + $diff = new GitDiffProvider($this->processRunner)->for($this->repository); + + self::assertStringContainsString('diff --git a/base.xml b/base.xml', $diff); + self::assertStringContainsString('+working tree', $diff); + self::assertStringContainsString('diff --git a/committed.xml b/committed.xml', $diff); + self::assertStringContainsString('+committed', $diff); + } + + #[Test] + public function itFailsClearlyWhenNoUpstreamDefaultBranchCanBeFound(): void + { + $this->git('init', '--quiet', '--initial-branch=contribution'); + $this->configureAuthor(); + + file_put_contents($this->repository . '/base.xml', "base\n"); + $this->git('add', 'base.xml'); + $this->git('commit', '--quiet', '-m', 'Contribution'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageIsOrContains('Could not determine the upstream default branch'); + + new GitDiffProvider($this->processRunner)->for($this->repository); + } + + private function configureAuthor(): void + { + $this->git('config', 'user.name', 'DocbookCS Tests'); + $this->git('config', 'user.email', 'docbook-cs@example.invalid'); + } + + private function git(string ...$arguments): string + { + $command = array_values(['git', ...$arguments]); + $result = $this->processRunner->run($command, $this->repository); + + self::assertSame(0, $result->exitCode, $result->stderr); + + return trim($result->stdout); + } +} diff --git a/tests/Integration/Fix/AttributeOrderFixerTest.php b/tests/Integration/Fix/AttributeOrderFixerTest.php new file mode 100644 index 0000000..19d22c4 --- /dev/null +++ b/tests/Integration/Fix/AttributeOrderFixerTest.php @@ -0,0 +1,95 @@ +'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new AttributeOrderSniff(RunMode::Fix)->process($document, $source); + + $beginOffset = (int)strpos($content, 'content); + self::assertSame($beginOffset, $violations[0]->beginOffset); + self::assertSame($beginOffset + strlen($sourceContent), $violations[0]->untilOffset); + self::assertSame(1, $violations[0]->line); + + $fix = new AttributeOrderFixer()->process($violations[0]); + + $result = new FixApplier()->apply($source, [ $fix ]); + + self::assertSame('', $result->file->content); + self::assertSame(1, $result->applied); + } + + #[Test] + public function itPreservesTagShapedTextInsideComments(): void + { + $content = '' + . ''; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new AttributeOrderSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(1, $violations); + + $fix = new AttributeOrderFixer()->process($violations[0]); + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame( + '' + . '', + $result->file->content, + ); + self::assertSame(1, $result->applied); + } + + private function createDocument(string $xml): \DOMDocument + { + $document = new \DOMDocument(); + $document->loadXML($xml); + + return $document; + } +} diff --git a/tests/Integration/Fix/ExceptionNameFixerTest.php b/tests/Integration/Fix/ExceptionNameFixerTest.php new file mode 100644 index 0000000..9744a35 --- /dev/null +++ b/tests/Integration/Fix/ExceptionNameFixerTest.php @@ -0,0 +1,151 @@ +RuntimeException'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new ExceptionNameSniff(RunMode::Fix)->process($document, $source); + + $beginOffset = (int) strpos($content, ''); + $sourceContent = 'RuntimeException'; + + self::assertCount(1, $violations); + self::assertSame($sourceContent, $violations[0]->content); + self::assertSame($beginOffset, $violations[0]->beginOffset); + self::assertSame($beginOffset + strlen($sourceContent), $violations[0]->untilOffset); + self::assertSame(1, $violations[0]->line); + + $fix = new ExceptionNameFixer()->process($violations[0]); + + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame('RuntimeException', $result->file->content); + self::assertSame(1, $result->applied); + } + + #[Test] + public function itPreservesClassnameAttributes(): void + { + $content = 'RuntimeException'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new ExceptionNameSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(1, $violations); + self::assertSame( + 'RuntimeException', + $violations[0]->content, + ); + + $fix = new ExceptionNameFixer()->process($violations[0]); + + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame( + 'RuntimeException', + $result->file->content, + ); + self::assertSame(1, $result->applied); + } + + #[Test] + public function itKeepsSourceContentAlignedAfterRegularClassnames(): void + { + $content = 'RegularClassRuntimeException'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new ExceptionNameSniff(RunMode::Fix)->process($document, $source); + + $sourceContent = 'RuntimeException'; + $beginOffset = (int) strpos($content, $sourceContent); + + self::assertCount(1, $violations); + self::assertSame($sourceContent, $violations[0]->content); + self::assertSame($beginOffset, $violations[0]->beginOffset); + self::assertSame($beginOffset + strlen($sourceContent), $violations[0]->untilOffset); + + $fix = new ExceptionNameFixer()->process($violations[0]); + + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame( + 'RegularClassRuntimeException', + $result->file->content, + ); + self::assertSame(1, $result->applied); + } + + #[Test] + public function itPreservesTagShapedTextInsideComments(): void + { + $content = '' + . 'RuntimeException'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new ExceptionNameSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(1, $violations); + + $fix = new ExceptionNameFixer()->process($violations[0]); + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame( + '' + . 'RuntimeException', + $result->file->content, + ); + self::assertSame(1, $result->applied); + } + + private function createDocument(string $xml): \DOMDocument + { + $document = new \DOMDocument(); + $document->loadXML($xml); + + return $document; + } +} diff --git a/tests/Integration/Fix/SimparaFixerTest.php b/tests/Integration/Fix/SimparaFixerTest.php new file mode 100644 index 0000000..23de962 --- /dev/null +++ b/tests/Integration/Fix/SimparaFixerTest.php @@ -0,0 +1,132 @@ +Text inline'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new SimparaSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(1, $violations); + self::assertSame('Text inline', $violations[0]->content); + + $fix = new SimparaFixer()->process($violations[0]); + + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame('Text inline', $result->file->content); + self::assertSame(1, $result->applied); + } + + #[Test] + public function itPreservesParaAttributes(): void + { + $content = 'Text'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new SimparaSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(1, $violations); + self::assertSame('Text', $violations[0]->content); + + $fix = new SimparaFixer()->process($violations[0]); + + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame('Text', $result->file->content); + self::assertSame(1, $result->applied); + } + + #[Test] + public function itCanFixNestedInnerParas(): void + { + $content = 'TextInner'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new SimparaSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(1, $violations); + self::assertSame('Inner', $violations[0]->content); + + $fix = new SimparaFixer()->process($violations[0]); + + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame( + 'TextInner', + $result->file->content, + ); + self::assertSame(1, $result->applied); + } + + #[Test] + public function itPreservesTagShapedTextInsideComments(): void + { + $content = 'Source'; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new SimparaSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(1, $violations); + + $fix = new SimparaFixer()->process($violations[0]); + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame( + 'Source', + $result->file->content, + ); + self::assertSame(1, $result->applied); + } + + private function createDocument(string $xml): \DOMDocument + { + $document = new \DOMDocument(); + $document->loadXML($xml); + + return $document; + } +} diff --git a/tests/Integration/Fix/WhitespaceConcernFixersTest.php b/tests/Integration/Fix/WhitespaceConcernFixersTest.php new file mode 100644 index 0000000..247be06 --- /dev/null +++ b/tests/Integration/Fix/WhitespaceConcernFixersTest.php @@ -0,0 +1,73 @@ + \n \t \n"; + $document = new \DOMDocument(); + $document->loadXML($content); + $source = new File('file.xml', $content); + + $trailingViolations = new TrailingWhitespaceSniff(RunMode::Fix)->process($document, $source); + $indentationViolations = new MixedIndentationSniff(RunMode::Fix)->process($document, $source); + + self::assertCount(2, $trailingViolations); + self::assertCount(1, $indentationViolations); + + $fixes = []; + $trailingFixer = new TrailingWhitespaceFixer(); + foreach ($trailingViolations as $violation) { + $fixes[] = $trailingFixer->process($violation); + } + + $indentationFixer = new MixedIndentationFixer(); + foreach ($indentationViolations as $violation) { + $fixes[] = $indentationFixer->process($violation); + } + + $result = new FixApplier()->apply($source, $fixes); + + self::assertSame("\n \n", $result->file->content); + self::assertSame(3, $result->applied); + self::assertSame(0, $result->skipped); + } +} diff --git a/tests/Integration/Fix/WhitespaceFixerTest.php b/tests/Integration/Fix/WhitespaceFixerTest.php new file mode 100644 index 0000000..39c914f --- /dev/null +++ b/tests/Integration/Fix/WhitespaceFixerTest.php @@ -0,0 +1,75 @@ + \n \t\n"; + $document = $this->createDocument($content); + $source = new File('file.xml', $content); + + $violations = new WhitespaceSniff(RunMode::Fix)->process($document, $source); + + $secondLineOffset = (int) strpos($content, " \t"); + + self::assertCount(2, $violations); + self::assertSame(' ', $violations[0]->content); + self::assertSame(0, $violations[0]->beginOffset); + self::assertSame(strlen(' '), $violations[0]->untilOffset); + self::assertSame(" \t", $violations[1]->content); + self::assertSame($secondLineOffset, $violations[1]->beginOffset); + self::assertSame($secondLineOffset + strlen(" \t"), $violations[1]->untilOffset); + + $fixes = []; + $fixer = new WhitespaceFixer(); + foreach ($violations as $violation) { + $fixes[] = $fixer->process($violation); + } + + $result = new FixApplier()->apply($source, $fixes); + + self::assertSame("\n \n", $result->file->content); + self::assertSame(2, $result->applied); + } + + private function createDocument(string $xml): \DOMDocument + { + $document = new \DOMDocument(); + $document->loadXML($xml); + + return $document; + } +} diff --git a/tests/Integration/Path/DiffPathLoaderTest.php b/tests/Integration/Path/DiffPathLoaderTest.php new file mode 100644 index 0000000..0bf530d --- /dev/null +++ b/tests/Integration/Path/DiffPathLoaderTest.php @@ -0,0 +1,80 @@ +directory = sys_get_temp_dir() . '/docbook-cs-diff-path-' . bin2hex(random_bytes(6)); + mkdir($this->directory); + } + + protected function tearDown(): void + { + @unlink($this->directory . '/chapter.xml'); + @unlink($this->directory . '/excluded.xml'); + @rmdir($this->directory); + } + + #[Test] + public function itLoadsChangedXmlFilesWithoutScanningConfiguredPaths(): void + { + $file = $this->directory . '/chapter.xml'; + file_put_contents($file, ''); + + $loader = new DiffPathLoader( + new DiffChangeset([new FileChange('docs/chapter.xml', [1])]), + workingDirectory: dirname($this->directory), + basePath: $this->directory, + projectRoots: [$this->directory => 'docs'], + matcher: new PathMatcher($this->directory, []), + ); + + $change = $loader->load()->changeFor($file); + + self::assertNotNull($change); + self::assertSame([1], $change->addedLineNumbers); + } + + #[Test] + public function itIgnoresMissingNonXmlAndExcludedFiles(): void + { + $excluded = $this->directory . '/excluded.xml'; + file_put_contents($excluded, ''); + + $loader = new DiffPathLoader( + new DiffChangeset([ + new FileChange('excluded.xml', [1]), + new FileChange('notes.txt', [1]), + new FileChange('missing.xml', [1]), + ]), + workingDirectory: $this->directory, + basePath: $this->directory, + projectRoots: [], + matcher: new PathMatcher($this->directory, ['excluded.xml']), + ); + + self::assertSame([], $loader->load()->fileChanges); + } +} diff --git a/tests/Integration/Process/NativeProcessRunnerTest.php b/tests/Integration/Process/NativeProcessRunnerTest.php new file mode 100644 index 0000000..1318897 --- /dev/null +++ b/tests/Integration/Process/NativeProcessRunnerTest.php @@ -0,0 +1,36 @@ +run( + [ + PHP_BINARY, + '-r', + 'fwrite(STDOUT, "output"); fwrite(STDERR, "error"); exit(7);', + ], + getcwd() ?: '.', + ); + + self::assertSame(7, $result->exitCode); + self::assertSame('output', $result->stdout); + self::assertSame('error', $result->stderr); + } +} diff --git a/tests/Integration/Runner/FixConvergenceTest.php b/tests/Integration/Runner/FixConvergenceTest.php new file mode 100644 index 0000000..f7699eb --- /dev/null +++ b/tests/Integration/Runner/FixConvergenceTest.php @@ -0,0 +1,375 @@ +ARuntimeException'; + $filePath = $this->temporaryFile($source); + + try { + $processor = new XmlFileProcessor([ + new SimparaSniff(RunMode::Fix), + new ExceptionNameSniff(RunMode::Fix), + ]); + + $report = $this->processFile($processor, $filePath); + + self::assertSame( + 'ARuntimeException', + file_get_contents($filePath), + ); + self::assertFalse($report->hasViolations()); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function itReportsRemainingViolationsAtTheirFinalLines(): void + { + $source = ''; + $filePath = $this->temporaryFile($source); + + try { + $lineBreakSniff = new class (RunMode::Fix) extends AbstractSniff implements Fixable { + private const string ELEMENT = ''; + + public static function getCode(): string + { + return 'Test.LineBreak'; + } + + public static function fixerClassName(): string + { + return LineBreakFixer::class; + } + + public function process(\DOMDocument $document, File $file): array + { + $offset = strpos($file->content, self::ELEMENT); + if ($offset === false) { + return []; + } + + return [$this->createViolation( + $file->path, + substr_count($file->content, "\n", 0, $offset) + 1, + $offset, + $offset + strlen(self::ELEMENT), + 'Replace the line-break marker.', + self::ELEMENT, + )]; + } + }; + $badElementSniff = new class (RunMode::Fix) extends AbstractSniff { + public static function getCode(): string + { + return 'Test.BadElement'; + } + + public function process(\DOMDocument $document, File $file): array + { + $element = $document->getElementsByTagName('bad')->item(0); + if (!$element instanceof \DOMElement) { + return []; + } + + $offset = strpos($file->content, ''); + if ($offset === false) { + return []; + } + + return [$this->createViolation( + $file->path, + $element->getLineNo(), + $offset, + $offset + strlen(''), + 'Bad element.', + '', + )]; + } + }; + $processor = new XmlFileProcessor([ + $lineBreakSniff, + $badElementSniff, + ]); + + $report = $this->processFile($processor, $filePath); + + self::assertSame("\n", file_get_contents($filePath)); + self::assertSame(1, $report->getViolationCount()); + self::assertSame(2, $report->getViolations()[0]->line); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function itKeepsChangedLineScopeAlignedAfterFixes(): void + { + $source = "\n\n"; + $filePath = $this->temporaryFile($source); + + try { + $lineBreakSniff = new class (RunMode::Fix) extends AbstractSniff implements Fixable { + public static function getCode(): string + { + return 'Test.ScopedLineBreak'; + } + + public static function fixerClassName(): string + { + return LineBreakFixer::class; + } + + public function process(\DOMDocument $document, File $file): array + { + $element = ''; + $offset = strpos($file->content, $element); + if ($offset === false) { + return []; + } + + return [$this->createViolation( + $file->path, + 2, + $offset, + $offset + strlen($element), + 'Replace the line-break marker.', + $element, + )]; + } + }; + $badElementSniff = new class (RunMode::Fix) extends AbstractSniff { + public static function getCode(): string + { + return 'Test.ScopedBadElement'; + } + + public function process(\DOMDocument $document, File $file): array + { + $element = $document->getElementsByTagName('bad')->item(0); + $offset = strpos($file->content, ''); + + if (!$element instanceof \DOMElement || $offset === false) { + return []; + } + + return [$this->createViolation( + $file->path, + $element->getLineNo(), + $offset, + $offset + strlen(''), + 'Bad element.', + '', + )]; + } + }; + $processor = new XmlFileProcessor([$lineBreakSniff, $badElementSniff]); + + $report = $this->processFile( + $processor, + $filePath, + new FileChange($filePath, [2]), + ); + + self::assertSame("\n\n\n", file_get_contents($filePath)); + self::assertSame(1, $report->getViolationCount()); + self::assertSame(3, $report->getViolations()[0]->line); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function itDoesNotPersistFixesThatCycle(): void + { + $source = ''; + $filePath = $this->temporaryFile($source); + + try { + $toggleElementSniff = new class (RunMode::Fix) extends AbstractSniff implements Fixable { + public static function getCode(): string + { + return 'Test.ToggleElement'; + } + + public static function fixerClassName(): string + { + return ToggleElementFixer::class; + } + + public function process(\DOMDocument $document, File $file): array + { + $element = str_contains($file->content, '') ? '' : ''; + $offset = strpos($file->content, $element); + if ($offset === false) { + return []; + } + + return [$this->createViolation( + $file->path, + 1, + $offset, + $offset + strlen($element), + 'Toggle the element.', + $element, + )]; + } + }; + $processor = new XmlFileProcessor([ + $toggleElementSniff, + ]); + + try { + $this->processFile($processor, $filePath); + self::fail('Expected the cycling fixer to fail.'); + } catch (FixerException $exception) { + self::assertStringContainsString('did not converge', $exception->getMessage()); + } + + self::assertSame($source, file_get_contents($filePath)); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function itDoesNotPersistFixesThatProduceInvalidXml(): void + { + $source = ''; + $filePath = $this->temporaryFile($source); + + try { + $invalidXmlSniff = new class (RunMode::Fix) extends AbstractSniff implements Fixable { + public static function getCode(): string + { + return 'Test.InvalidXml'; + } + + public static function fixerClassName(): string + { + return AttributeOrderFixer::class; + } + + public function process(\DOMDocument $document, File $file): array + { + $offset = strpos($file->content, ''); + if ($offset === false) { + return []; + } + + return [$this->createViolation( + $file->path, + 1, + $offset, + $offset + strlen(''), + 'Produce invalid XML.', + '', + )]; + } + }; + $processor = new XmlFileProcessor([$invalidXmlSniff]); + + try { + $this->processFile($processor, $filePath); + self::fail('Expected the invalid fixer result to fail.'); + } catch (FixerException $exception) { + self::assertStringContainsString('produced invalid XML', $exception->getMessage()); + } + + self::assertSame($source, file_get_contents($filePath)); + } finally { + @unlink($filePath); + } + } + + private function processFile(XmlFileProcessor $processor, string $path, ?FileChange $fileChange = null): FileReport + { + $content = file_get_contents($path); + self::assertIsString($content); + + $result = $processor->process(new File($path, $content), $fileChange); + if ($result->isModified()) { + file_put_contents($path, $result->fixedContent()); + } + + return $result->fileReport; + } + + private function temporaryFile(string $content): string + { + $filePath = tempnam(sys_get_temp_dir(), 'docbook-cs-'); + self::assertIsString($filePath); + file_put_contents($filePath, $content); + + return $filePath; + } +} diff --git a/tests/Integration/Runner/RunCoordinatorFileFailureTest.php b/tests/Integration/Runner/RunCoordinatorFileFailureTest.php new file mode 100644 index 0000000..a3578b1 --- /dev/null +++ b/tests/Integration/Runner/RunCoordinatorFileFailureTest.php @@ -0,0 +1,107 @@ +'); + + $progress = new class ($xmlFilePath) implements ProgressInterface { + public function __construct(private string $filePath) + { + } + + public function start(int $totalFiles): void + { + @unlink($this->filePath); + } + + public function advance(int $current, string $filePath, int $violations): void + { + } + + public function finish(): void + { + } + }; + + $config = new ConfigData( + projectRoots: [], + sniffs: [], + includePaths: [$xmlFilePath], + excludePatterns: [], + entityPaths: [], + basePath: dirname($xmlFilePath), + ); + + $report = new RunCoordinator($progress)->run($this->planPaths($config)); + + self::assertTrue($report->hasViolations()); + self::assertSame('DocbookCS.Internal', $report->getAllViolations()[0]->sniffCode); + self::assertStringContainsString('Could not read file', $report->getAllViolations()[0]->message); + } + + #[Test] + public function itKeepsUnreadableFileErrorsInDiffRuns(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'docbook-cs-'); + self::assertIsString($filePath); + $xmlFilePath = $filePath . '.xml'; + rename($filePath, $xmlFilePath); + file_put_contents($xmlFilePath, ''); + + $progress = $this->createMock(ProgressInterface::class); + $progress->expects($this->once())->method('start')->willReturnCallback( + static function () use ($xmlFilePath): void { + @unlink($xmlFilePath); + }, + ); + $progress->expects($this->once())->method('advance'); + $progress->expects($this->once())->method('finish'); + $config = new ConfigData( + projectRoots: [], + sniffs: [], + includePaths: [$xmlFilePath], + excludePatterns: [], + entityPaths: [], + basePath: dirname($xmlFilePath), + ); + $diff = new DiffChangeset([new FileChange($xmlFilePath, [42])]); + + $report = new RunCoordinator($progress)->run($this->planDiff($config, $diff)); + + self::assertTrue($report->hasViolations()); + self::assertSame('DocbookCS.Internal', $report->getAllViolations()[0]->sniffCode); + } + + private function planPaths(ConfigData $config): RunPlan + { + return new RunPlanner($config)->planPaths($config->getIncludePaths()); + } + + private function planDiff(ConfigData $config, DiffChangeset $diff): RunPlan + { + return new RunPlanner($config)->planDiff($diff); + } +} diff --git a/tests/Integration/Runner/RunScopeResolverTest.php b/tests/Integration/Runner/RunScopeResolverTest.php new file mode 100644 index 0000000..dbf2f28 --- /dev/null +++ b/tests/Integration/Runner/RunScopeResolverTest.php @@ -0,0 +1,158 @@ +directory = sys_get_temp_dir() . '/docbook-cs-scope-' . bin2hex(random_bytes(6)); + mkdir($this->directory); + + $this->sourceFile = $this->directory . '/source.xml'; + $this->targetFile = $this->directory . '/target.xml'; + $this->entityFile = $this->directory . '/bridge.ent'; + + file_put_contents($this->sourceFile, '&bridge;'); + file_put_contents($this->targetFile, ''); + file_put_contents($this->entityFile, '⌖'); + } + + protected function tearDown(): void + { + @unlink($this->sourceFile); + @unlink($this->targetFile); + @unlink($this->entityFile); + @rmdir($this->directory); + } + + #[Test] + public function narrowScopeKeepsOnlySelectedFilesAndDiffLines(): void + { + $resolver = $this->resolver(); + + $targets = $resolver->resolveDiff( + new DiffChangeset([new FileChange('source.xml', [2, 3])]), + ); + + self::assertSame([2, 3], $targets[$this->sourceFile]?->addedLineNumbers); + self::assertCount(1, $targets); + } + + #[Test] + public function wideScopeWidensSelectedFilesAndFollowsReferencedTargets(): void + { + $resolver = $this->resolver(wide: true); + + $targets = $resolver->resolveDiff( + new DiffChangeset([new FileChange('source.xml', [2, 3])]), + ); + + self::assertNull($targets[$this->sourceFile]); + self::assertNull($targets[$this->targetFile]); + self::assertCount(2, $targets); + } + + #[Test] + public function expandedScopeHonorsTargetExclusions(): void + { + $resolver = new RunScopeResolver( + $this->config(['target.xml']), + [ + 'bridge' => $this->entityFile, + 'target' => $this->targetFile, + ], + wide: true, + ); + + $targets = $resolver->resolvePaths([$this->sourceFile]); + + self::assertSame([$this->sourceFile => null], $targets); + } + + #[Test] + public function pathScopeResolvesRelativePathsAgainstTheWorkingDirectory(): void + { + $path = 'tests/fixtures/sniff_runner/default/file_a.xml'; + $absolutePath = (getcwd() ?: '.') . '/' . $path; + + $targets = $this->resolver()->resolvePaths([$path]); + + self::assertSame([$absolutePath => null], $targets); + } + + #[Test] + public function widePathScopeDoesNotDuplicateLexicallyEquivalentTargets(): void + { + $resolver = new RunScopeResolver( + $this->config(), + [ + 'bridge' => $this->entityFile, + 'target' => $this->directory . '/./target.xml', + ], + wide: true, + ); + + self::assertSame( + [ + $this->sourceFile => null, + $this->targetFile => null, + ], + $resolver->resolvePaths([$this->directory . '/.']), + ); + } + + private function resolver(bool $wide = false): RunScopeResolver + { + return new RunScopeResolver( + $this->config(), + [ + 'bridge' => $this->entityFile, + 'target' => $this->targetFile, + ], + $wide, + ); + } + + /** @param list $excludePatterns */ + private function config(array $excludePatterns = []): ConfigData + { + return new ConfigData( + projectRoots: [], + sniffs: [], + includePaths: [$this->sourceFile], + excludePatterns: $excludePatterns, + entityPaths: [], + basePath: $this->directory, + ); + } +} diff --git a/tests/Integration/Runner/RunScopeTest.php b/tests/Integration/Runner/RunScopeTest.php new file mode 100644 index 0000000..a34f4e5 --- /dev/null +++ b/tests/Integration/Runner/RunScopeTest.php @@ -0,0 +1,227 @@ +directory = sys_get_temp_dir() . '/docbook-cs-run-scope-' . bin2hex(random_bytes(6)); + mkdir($this->directory); + + $this->sourceFile = $this->directory . '/source.xml'; + $this->targetFile = $this->directory . '/target.xml'; + $this->entityFile = $this->directory . '/entities.ent'; + + file_put_contents($this->sourceFile, ''); + file_put_contents($this->targetFile, ''); + file_put_contents($this->entityFile, ''); + } + + protected function tearDown(): void + { + @unlink($this->sourceFile); + @unlink($this->targetFile); + @unlink($this->entityFile); + @rmdir($this->directory); + } + + #[Test] + public function itExpandsReferencedTargetsOnlyWhenWideScopeIsRequested(): void + { + $config = $this->config(); + + self::assertSame(1, $this->executePaths($config, [$this->sourceFile])->getFilesScanned()); + self::assertSame( + 2, + $this->executePaths( + $config, + [$this->sourceFile], + wide: true, + )->getFilesScanned(), + ); + } + + #[Test] + public function itFixesExpandedXmlInItsTargetFileOnly(): void + { + file_put_contents($this->targetFile, 'Text'); + $config = $this->config([ + new SniffEntry(SimparaSniff::class), + ]); + + $report = $this->executePaths( + $config, + [$this->sourceFile], + mode: RunMode::Fix, + wide: true, + ); + + self::assertSame('', file_get_contents($this->sourceFile)); + self::assertSame('Text', file_get_contents($this->targetFile)); + self::assertFalse($report->hasViolations()); + } + + #[Test] + public function aDiffProvidesItsOwnFilesWithoutConfiguredIncludePaths(): void + { + $config = new ConfigData( + projectRoots: [], + sniffs: [], + includePaths: [], + excludePatterns: [], + entityPaths: [], + basePath: $this->directory, + ); + + $report = $this->executeDiff( + $config, + new DiffChangeset([ + new FileChange($this->sourceFile, [1]), + ]), + ); + + self::assertSame(1, $report->getFilesScanned()); + } + + #[Test] + public function aDiffPathUsingAProjectDirectoryKeepsItsSourceRanges(): void + { + $config = new ConfigData( + projectRoots: [$this->directory => 'docs'], + sniffs: [], + includePaths: [], + excludePatterns: [], + entityPaths: [], + basePath: $this->directory, + ); + + $report = $this->executeDiff( + $config, + new DiffChangeset([ + new FileChange('docs/source.xml', [1]), + ]), + ); + + self::assertSame(1, $report->getFilesScanned()); + } + + /** @param list $sniffs */ + private function config(array $sniffs = []): ConfigData + { + return new ConfigData( + projectRoots: [], + sniffs: $sniffs, + includePaths: [$this->sourceFile], + excludePatterns: [], + entityPaths: [$this->entityFile], + basePath: $this->directory, + ); + } + + /** @param list $paths */ + private function executePaths( + ConfigData $config, + array $paths, + RunMode $mode = RunMode::Sniff, + bool $wide = false, + ): Report { + return new RunCoordinator()->run( + new RunPlanner($config, $mode, $wide)->planPaths($paths), + ); + } + + private function executeDiff( + ConfigData $config, + DiffChangeset $diff, + RunMode $mode = RunMode::Sniff, + bool $wide = false, + ): Report { + return new RunCoordinator()->run( + new RunPlanner($config, $mode, $wide)->planDiff($diff), + ); + } +} diff --git a/tests/Integration/Runner/SniffRunnerTest.php b/tests/Integration/Runner/SniffRunnerTest.php new file mode 100644 index 0000000..852691d --- /dev/null +++ b/tests/Integration/Runner/SniffRunnerTest.php @@ -0,0 +1,436 @@ + $sniffs */ + private function createConfig(array $sniffs = []): ConfigData + { + return new ConfigData( + [], + $sniffs, + [self::FIXTURE_DIR], + [], + [], + self::FIXTURE_DIR, + ); + } + + #[Test] + public function itProcessesFilesWithoutViolations(): void + { + $config = $this->createConfig(); + + $runner = new RunCoordinator(); + $report = $runner->run($this->planPaths($config)); + + self::assertSame(2, $report->getFilesScanned()); + self::assertFalse($report->hasViolations()); + self::assertCount(0, $report->getFileReports()); + } + + #[Test] + public function itUsesOverridePathsWhenProvided(): void + { + $config = $this->createConfig(); + + $runner = new RunCoordinator(); + $report = $runner->run($this->planPaths( + $config, + [self::FIXTURE_DIR . '/../override'], + )); + + self::assertSame(1, $report->getFilesScanned()); + } + + #[Test] + public function itCallsProgressMethods(): void + { + $progress = $this->createMock(ProgressInterface::class); + + $progress->expects($this->once()) + ->method('start') + ->with(2); + + $progress->expects($this->exactly(2)) + ->method('advance'); + + $progress->expects($this->once()) + ->method('finish'); + + $config = $this->createConfig(); + + $runner = new RunCoordinator($progress); + $runner->run($this->planPaths($config)); + } + + #[Test] + public function itAddsFileReportsForFilesWithViolations(): void + { + $sniff = new class (RunMode::Sniff) implements SniffInterface { + public function __construct(public RunMode $mode) + { + } + + public static function getCode(): string + { + return 'Test.ViolatingSniff'; + } + + public function process(\DOMDocument $document, File $file): array + { + return [ + new Violation( + sniffCode: 'Test.ViolatingSniff', + filePath: $file->path, + line: 1, + beginOffset: 0, + untilOffset: 0, + message: 'Test violation message', + severity: Severity::WARNING, + ), + ]; + } + + public function setProperty(string $name, string $value): void + { + } + }; + + $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); + + $runner = new RunCoordinator(); + $report = $runner->run($this->planPaths($config)); + + self::assertSame(2, $report->getFilesScanned()); + self::assertCount(2, $report->getFileReports()); + self::assertTrue($report->hasViolations()); + } + + #[Test] + public function itStoresAbsolutePathsInFileReports(): void + { + $sniff = new class (RunMode::Sniff) implements SniffInterface { + public function __construct(public RunMode $mode) + { + } + + public static function getCode(): string + { + return 'Test.ViolatingSniff'; + } + + public function process(\DOMDocument $document, File $file): array + { + return [ + new Violation( + sniffCode: 'Test.ViolatingSniff', + filePath: $file->path, + line: 1, + beginOffset: 0, + untilOffset: 0, + message: 'Test violation', + severity: Severity::WARNING, + ), + ]; + } + + public function setProperty(string $name, string $value): void + { + } + }; + + $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); + + $runner = new RunCoordinator(); + $report = $runner->run($this->planPaths($config)); + + foreach ($report->getFileReports() as $fileReport) { + self::assertTrue( + str_starts_with($fileReport->filePath, '/'), + 'Expected absolute path, got: ' . $fileReport->filePath, + ); + } + } + + #[Test] + public function itPassesPropertiesToSniffs(): void + { + $sniffClass = new class (RunMode::Sniff) implements SniffInterface { + public static string $captured = ''; + public static RunMode $capturedMode = RunMode::Sniff; + + public function __construct(public RunMode $mode) + { + self::$capturedMode = $mode; + } + + public function setProperty(string $name, string $value): void + { + self::$captured = $value; + } + + public static function getCode(): string + { + return 'Test.ConfigurableSniff'; + } + + public function process(\DOMDocument $document, File $file): array + { + return []; + } + }; + + $config = $this->createConfig(sniffs: [new SniffEntry($sniffClass::class, ['someProp' => 'someValue'])]); + + $runner = new RunCoordinator(); + $runner->run($this->planPaths($config, mode: RunMode::Fix)); + + self::assertSame('someValue', $sniffClass::$captured); + self::assertSame(RunMode::Fix, $sniffClass::$capturedMode); + } + + #[Test] + public function itThrowsWhenSniffClassDoesNotExist(): void + { + $config = $this->createConfig(sniffs: [new SniffEntry('NonExistent\\FakeSniff')]); + + $runner = new RunCoordinator(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageIsOrContains('does not exist'); + + $runner->run($this->planPaths($config)); + } + + #[Test] + public function itThrowsWhenClassDoesNotImplementSniffInterface(): void + { + $config = $this->createConfig(sniffs: [new SniffEntry(\stdClass::class)]); + + $runner = new RunCoordinator(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageIsOrContains('does not implement'); + + $runner->run($this->planPaths($config)); + } + + #[Test] + public function itFiltersFilesToOnlyThoseInTheDiff(): void + { + $config = $this->createConfig(); + $runner = new RunCoordinator(); + + $diff = new DiffChangeset([new FileChange(self::FIXTURE_DIR . '/file_a.xml', [1])]); + $report = $runner->run($this->planDiff($config, $diff)); + + self::assertSame(1, $report->getFilesScanned()); + } + + #[Test] + public function itScansNoFilesWhenDiffContainsNoMatchingPaths(): void + { + $config = $this->createConfig(); + $runner = new RunCoordinator(); + + $diff = new DiffChangeset([new FileChange('completely/different/file.xml', [1, 2, 3])]); + $report = $runner->run($this->planDiff($config, $diff)); + + self::assertSame(0, $report->getFilesScanned()); + } + + #[Test] + public function itMatchesWhenDiffPathEqualsDiscoveredPath(): void + { + $config = $this->createConfig(); + $runner = new RunCoordinator(); + + $discoveredPath = self::FIXTURE_DIR . '/file_a.xml'; + + $diff = new DiffChangeset([new FileChange($discoveredPath, [1])]); + $report = $runner->run($this->planDiff($config, $diff)); + + self::assertSame(1, $report->getFilesScanned()); + } + + #[Test] + public function itScansAllFilesWhenNoDiffIsGiven(): void + { + $config = $this->createConfig(); + $runner = new RunCoordinator(); + + $report = $runner->run($this->planPaths($config)); + + self::assertSame(2, $report->getFilesScanned()); + } + + #[Test] + public function itScansLexicallyEquivalentWideTargetsOnlyOnce(): void + { + $directory = sys_get_temp_dir() . '/docbook-cs-scan-' . bin2hex(random_bytes(6)); + mkdir($directory); + + $sourceFile = $directory . '/source.xml'; + $targetFile = $directory . '/target.xml'; + $entityFile = $directory . '/bridge.ent'; + + file_put_contents($sourceFile, '&bridge;'); + file_put_contents($targetFile, ''); + file_put_contents($entityFile, '⌖'); + + try { + $config = new ConfigData([], [], [], [], [], $directory); + $resolver = new RunScopeResolver( + $config, + [ + 'bridge' => $entityFile, + 'target' => $directory . '/./target.xml', + ], + wide: true, + ); + $plan = new RunPlan( + mode: RunMode::Sniff, + sniffs: [], + targets: $resolver->resolvePaths([$directory . '/.']), + entities: [ + 'bridge' => '⌖', + 'target' => '', + ], + ); + + $report = new RunCoordinator()->run($plan); + + self::assertSame(2, $report->getFilesScanned()); + } finally { + @unlink($sourceFile); + @unlink($targetFile); + @unlink($entityFile); + @rmdir($directory); + } + } + + #[Test] + public function itReportsNoViolationsForFilesInDiffWithoutAddedLines(): void + { + $sniff = new class (RunMode::Sniff) implements SniffInterface { + public function __construct(public RunMode $mode) + { + } + + public static function getCode(): string + { + return 'Test.ViolatingSniff'; + } + + public function process(\DOMDocument $document, File $file): array + { + return [ + new Violation( + sniffCode: 'Test.ViolatingSniff', + filePath: $file->path, + line: 1, + beginOffset: 0, + untilOffset: 0, + message: 'Test violation', + severity: Severity::WARNING, + ), + ]; + } + + public function setProperty(string $name, string $value): void + { + } + }; + + $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); + $runner = new RunCoordinator(); + + $diff = new DiffChangeset([new FileChange(self::FIXTURE_DIR . '/file_a.xml', [])]); + $report = $runner->run($this->planDiff($config, $diff)); + + self::assertSame(1, $report->getFilesScanned()); + self::assertFalse($report->hasViolations()); + } + + /** @param list|null $paths */ + private function planPaths(ConfigData $config, ?array $paths = null, RunMode $mode = RunMode::Sniff): RunPlan + { + return new RunPlanner($config, $mode)->planPaths($paths ?? $config->getIncludePaths()); + } + + private function planDiff(ConfigData $config, DiffChangeset $diff, RunMode $mode = RunMode::Sniff): RunPlan + { + return new RunPlanner($config, $mode)->planDiff($diff); + } +} diff --git a/tests/Integration/Runner/SourceRangeScopeTest.php b/tests/Integration/Runner/SourceRangeScopeTest.php new file mode 100644 index 0000000..c637115 --- /dev/null +++ b/tests/Integration/Runner/SourceRangeScopeTest.php @@ -0,0 +1,126 @@ + + +Text + + +XML; + $expected = <<<'XML' + + +Text + + +XML; + $filePath = tempnam(sys_get_temp_dir(), 'docbook-cs-'); + self::assertIsString($filePath); + file_put_contents($filePath, $source); + + try { + $processor = new XmlFileProcessor([ + new SimparaSniff(RunMode::Fix), + ]); + + $result = $processor->process( + new File($filePath, $source), + new FileChange($filePath, [3]), + ); + file_put_contents($filePath, $result->fixedContent()); + $report = $result->fileReport; + + self::assertSame($expected, file_get_contents($filePath)); + self::assertFalse($report->hasViolations()); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function itFixesAViolationCausedByADeletedLine(): void + { + $source = <<<'XML' + + +Text + + +XML; + $expected = <<<'XML' + + +Text + + +XML; + $processor = new XmlFileProcessor([ + new SimparaSniff(RunMode::Fix), + ]); + + $result = $processor->process( + new File('file.xml', $source), + new FileChange('file.xml', [], deletionAnchors: [3]), + ); + + self::assertSame($expected, $result->fixedContent()); + self::assertFalse($result->fileReport->hasViolations()); + } +} diff --git a/tests/Integration/Runner/XmlFileProcessorPipelineTest.php b/tests/Integration/Runner/XmlFileProcessorPipelineTest.php new file mode 100644 index 0000000..5a8b9f6 --- /dev/null +++ b/tests/Integration/Runner/XmlFileProcessorPipelineTest.php @@ -0,0 +1,155 @@ +'); + + $report = $this->process( + $this->processor([new AttributeOrderSniff()]), + '', + $filePath, + ); + + self::assertCount(1, $report->getViolations()); + self::assertSame($filePath, $report->getViolations()[0]->filePath); + self::assertSame($filePath, $report->filePath); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function itAppliesFixesToTheOriginalSourceWhenEntitiesExpandBeforeTheViolation(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'docbook-cs-'); + self::assertIsString($filePath); + $source = '&prefix;'; + + try { + file_put_contents($filePath, $source); + + $processor = $this->processor( + [new AttributeOrderSniff(RunMode::Fix)], + new EntityPreprocessor([ + 'prefix' => 'expanded-content-before-tag', + ]), + ); + + $this->processFile($processor, $filePath); + + self::assertSame( + '&prefix;', + file_get_contents($filePath), + ); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function itHandlesEntitiesWithoutParseErrors(): void + { + $xml = $this->xml( + ' + + &link.superglobals; &php.ini; & + ' + ); + + $processor = $this->processor([], new EntityPreprocessor([ + 'link.superglobals' => '', + 'php.ini' => '', + ])); + + $report = $this->process($processor, $xml); + + self::assertCount( + 0, + array_filter( + $report->getViolations(), + fn($v) => $v->sniffCode === 'DocbookCS.Internal' + ) + ); + } + + #[Test] + public function itUsesCustomPreprocessor(): void + { + $processor = $this->processor([], new EntityPreprocessor([ + 'custom.entity' => '[X]', + ])); + + $xml = $this->xml('&custom.entity;'); + + $report = $this->process($processor, $xml); + + self::assertCount( + 0, + array_filter( + $report->getViolations(), + fn($v) => $v->sniffCode === 'DocbookCS.Internal' + ) + ); + } + + private function process(XmlFileProcessor $processor, string $content, string $path = 'input.xml'): FileReport + { + return $processor->process(new File($path, $content))->fileReport; + } + + private function processFile(XmlFileProcessor $processor, string $path): FileReport + { + $content = file_get_contents($path); + self::assertIsString($content); + + $result = $processor->process(new File($path, $content)); + if ($result->isModified()) { + file_put_contents($path, $result->fixedContent()); + } + + return $result->fileReport; + } + + private function xml(string $body): string + { + return << +$body +XML; + } + + /** @param list $sniffs */ + private function processor(array $sniffs = [], ?EntityPreprocessor $pre = null): XmlFileProcessor + { + return new XmlFileProcessor( + $sniffs, + $pre ?? new EntityPreprocessor([]) // always pass array + ); + } +} diff --git a/tests/Integration/Sniff/EntityExpandedSniffTest.php b/tests/Integration/Sniff/EntityExpandedSniffTest.php index a6b1925..886a3f8 100644 --- a/tests/Integration/Sniff/EntityExpandedSniffTest.php +++ b/tests/Integration/Sniff/EntityExpandedSniffTest.php @@ -4,14 +4,18 @@ namespace DocbookCS\Tests\Integration\Sniff; -use DocbookCS\Report\Violation; use DocbookCS\Runner\EntityExpansionMarker; use DocbookCS\Runner\EntityPreprocessor; use DocbookCS\Sniff\AbstractSniff; use DocbookCS\Sniff\ExceptionNameSniff; use DocbookCS\Sniff\SimparaSniff; +use DocbookCS\Source\File; +use DocbookCS\Source\Line; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ @@ -20,35 +24,36 @@ CoversClass(EntityPreprocessor::class), CoversClass(ExceptionNameSniff::class), CoversClass(SimparaSniff::class), - CoversClass(Violation::class), + // + UsesClass(File::class), + UsesClass(Line::class), + UsesClass(SourceRange::class), + UsesClass(Violation::class), ] final class EntityExpandedSniffTest extends TestCase { #[Test] - public function simparaIgnoresExpandedElements(): void + public function simparaIgnoresExpandedAndSelfClosingElements(): void { - $source = 'Source&expanded;'; + $source = 'Source&expanded;'; $document = $this->processedDocument($source, 'Expanded'); - $violations = new SimparaSniff()->process($document, $source, 'file.xml'); + $violations = new SimparaSniff()->process($document, new File('file.xml', $source)); self::assertCount(1, $violations); - self::assertSame(1, $violations[0]->line); + self::assertSame('Source', $violations[0]->content); } #[Test] public function exceptionNameIgnoresExpandedElements(): void { $source = 'RuntimeException&expanded;'; - $document = $this->processedDocument( - $source, - 'ExpandedException', - ); + $document = $this->processedDocument($source, 'ExpandedException'); - $violations = new ExceptionNameSniff()->process($document, $source, 'file.xml'); + $violations = new ExceptionNameSniff()->process($document, new File('file.xml', $source)); self::assertCount(1, $violations); - self::assertSame(1, $violations[0]->line); + self::assertSame('RuntimeException', $violations[0]->content); } private function processedDocument(string $source, string $expanded): \DOMDocument diff --git a/tests/Support/Fix/LineBreakFixer.php b/tests/Support/Fix/LineBreakFixer.php new file mode 100644 index 0000000..5bf2ecb --- /dev/null +++ b/tests/Support/Fix/LineBreakFixer.php @@ -0,0 +1,29 @@ +content !== '') { + throw FixerException::cannotFixInvalidContent($violation); + } + + return new Fix( + filePath: $violation->filePath, + beginOffset: $violation->beginOffset, + untilOffset: $violation->untilOffset, + replacement: "\n", + sniffCode: $violation->sniffCode, + expectedContent: $violation->content, + ); + } +} diff --git a/tests/Support/Fix/ToggleElementFixer.php b/tests/Support/Fix/ToggleElementFixer.php new file mode 100644 index 0000000..36fcad0 --- /dev/null +++ b/tests/Support/Fix/ToggleElementFixer.php @@ -0,0 +1,35 @@ +content === null) { + throw FixerException::cannotFixMissingContent(); + } + + $replacement = match ($violation->content) { + '' => '', + '' => '', + default => throw FixerException::cannotFixInvalidContent($violation), + }; + + return new Fix( + filePath: $violation->filePath, + beginOffset: $violation->beginOffset, + untilOffset: $violation->untilOffset, + replacement: $replacement, + sniffCode: $violation->sniffCode, + expectedContent: $violation->content, + ); + } +} diff --git a/tests/Unit/Config/ConfigParserTest.php b/tests/Unit/Config/ConfigParserTest.php index ca490d2..1201e20 100644 --- a/tests/Unit/Config/ConfigParserTest.php +++ b/tests/Unit/Config/ConfigParserTest.php @@ -67,10 +67,10 @@ public function itParsesAFullConfigFromString(): void // Sniffs self::assertCount(2, $config->getSniffs()); - self::assertSame('App\Sniffs\ParaSniff', $config->getSniffs()[0]->getClassName()); - self::assertSame([], $config->getSniffs()[0]->getProperties()); + self::assertSame('App\Sniffs\ParaSniff', $config->getSniffs()[0]->className); + self::assertSame([], $config->getSniffs()[0]->properties); - self::assertSame('App\Sniffs\IdSniff', $config->getSniffs()[1]->getClassName()); + self::assertSame('App\Sniffs\IdSniff', $config->getSniffs()[1]->className); self::assertSame('^[a-z]+$', $config->getSniffs()[1]->getProperty('pattern')); // Include paths (resolved relative to basePath) diff --git a/tests/Unit/Diff/DiffParserTest.php b/tests/Unit/Diff/DiffParserTest.php index ad3e5d2..c1f3040 100644 --- a/tests/Unit/Diff/DiffParserTest.php +++ b/tests/Unit/Diff/DiffParserTest.php @@ -4,7 +4,7 @@ namespace DocbookCS\Tests\Unit\Diff; -use DocbookCS\Diff\Diff; +use DocbookCS\Diff\DiffChangeset; use DocbookCS\Diff\DiffParser; use DocbookCS\Diff\FileChange; use PHPUnit\Framework\Attributes\CoversClass; @@ -14,7 +14,8 @@ #[ CoversClass(DiffParser::class), - UsesClass(Diff::class), + // + UsesClass(DiffChangeset::class), UsesClass(FileChange::class), ] final class DiffParserTest extends TestCase @@ -29,7 +30,7 @@ protected function setUp(): void #[Test] public function itReturnsEmptyArrayForEmptyDiff(): void { - self::assertSame([], $this->lineNumbersByFile($this->parser->parse(''))); + self::assertSame([], $this->parser->parse('')->fileChanges); } #[Test] @@ -46,10 +47,10 @@ public function itParsesAddedLineNumbers(): void line3 DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $change = $this->parser->parse($diff)->changeFor('reference/file.xml'); - self::assertArrayHasKey('reference/file.xml', $result); - self::assertSame([2], $result['reference/file.xml']); + self::assertNotNull($change); + self::assertSame([2], $change->addedLineNumbers); } #[Test] @@ -67,9 +68,9 @@ public function itParsesMultipleAddedLines(): void last line DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $result = $this->parser->parse($diff); - self::assertSame([6, 7], $result['doc/chapter.xml']); + self::assertSame([6, 7], $result->changeFor('doc/chapter.xml')?->addedLineNumbers); } #[Test] @@ -84,10 +85,10 @@ public function itStripsTheBPrefix(): void +added DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $result = $this->parser->parse($diff); - self::assertArrayHasKey('src/file.xml', $result); - self::assertArrayNotHasKey('b/src/file.xml', $result); + self::assertCount(1, $result->fileChanges); + self::assertSame('src/file.xml', $result->fileChanges[0]->filePath); } #[Test] @@ -104,7 +105,7 @@ public function itExcludesDeletedFiles(): void -line3 DIFF; - self::assertSame([], $this->lineNumbersByFile($this->parser->parse($diff))); + self::assertSame([], $this->parser->parse($diff)->fileChanges); } #[Test] @@ -121,10 +122,10 @@ public function itHandlesNewlyCreatedFiles(): void +line3 DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $change = $this->parser->parse($diff)->changeFor('new.xml'); - self::assertArrayHasKey('new.xml', $result); - self::assertSame([1, 2, 3], $result['new.xml']); + self::assertNotNull($change); + self::assertSame([1, 2, 3], $change->addedLineNumbers); } #[Test] @@ -147,12 +148,14 @@ public function itHandlesMultipleFilesInOneDiff(): void unchanged DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $result = $this->parser->parse($diff); + $firstChange = $result->changeFor('first.xml'); + $secondChange = $result->changeFor('second.xml'); - self::assertArrayHasKey('first.xml', $result); - self::assertArrayHasKey('second.xml', $result); - self::assertSame([2], $result['first.xml']); - self::assertSame([2], $result['second.xml']); + self::assertNotNull($firstChange); + self::assertNotNull($secondChange); + self::assertSame([2], $firstChange->addedLineNumbers); + self::assertSame([2], $secondChange->addedLineNumbers); } #[Test] @@ -169,11 +172,31 @@ public function itIgnoresRemovedLines(): void line3 DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $change = $this->parser->parse($diff)->changeFor('file.xml'); // No lines added, so the changed set is empty (not absent — the file is tracked). - self::assertArrayHasKey('file.xml', $result); - self::assertSame([], $result['file.xml']); + self::assertNotNull($change); + self::assertSame([], $change->addedLineNumbers); + } + + #[Test] + public function itAnchorsRemovedLinesInTheResultingFile(): void + { + $diff = <<<'DIFF' +diff --git a/file.xml b/file.xml +--- a/file.xml ++++ b/file.xml +@@ -1,4 +1,3 @@ + line1 +-removed line + line2 + line3 +DIFF; + + $change = $this->parser->parse($diff)->changeFor('file.xml'); + self::assertNotNull($change); + + self::assertSame([2], $change->deletionAnchors); } #[Test] @@ -190,9 +213,29 @@ public function itIgnoresTheMissingFinalNewlineMarker(): void +second DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $result = $this->parser->parse($diff); - self::assertSame([1, 2], $result['file.xml']); + self::assertSame([1, 2], $result->changeFor('file.xml')?->addedLineNumbers); + } + + #[Test] + public function itAnchorsReplacedLinesWhenTheMissingFinalNewlineMarkerIsPresent(): void + { + $diff = <<<'DIFF' +diff --git a/file.xml b/file.xml +--- a/file.xml ++++ b/file.xml +@@ -1 +1,2 @@ +-old +\ No newline at end of file ++new ++second +DIFF; + + $change = $this->parser->parse($diff)->changeFor('file.xml'); + self::assertNotNull($change); + + self::assertSame([1], $change->deletionAnchors); } #[Test] @@ -214,9 +257,9 @@ public function itTracksLineNumbersAcrossMultipleHunks(): void line12 DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); + $result = $this->parser->parse($diff); - self::assertSame([2, 12], $result['file.xml']); + self::assertSame([2, 12], $result->changeFor('file.xml')?->addedLineNumbers); } #[Test] @@ -230,21 +273,8 @@ public function itHandlesHunkWithNoContext(): void +only line DIFF; - $result = $this->lineNumbersByFile($this->parser->parse($diff)); - - self::assertSame([1], $result['file.xml']); - } - - // TODO: avoids test diff churn; remove when fixers merged - /** @return array> */ - private function lineNumbersByFile(Diff $diff): array - { - $lineNumbersByFile = []; - - foreach ($diff->fileChanges as $fileChange) { - $lineNumbersByFile[$fileChange->filePath] = $fileChange->addedLineNumbers; - } + $result = $this->parser->parse($diff); - return $lineNumbersByFile; + self::assertSame([1], $result->changeFor('file.xml')?->addedLineNumbers); } } diff --git a/tests/Unit/Fix/FixPlanTest.php b/tests/Unit/Fix/FixPlanTest.php new file mode 100644 index 0000000..490938c --- /dev/null +++ b/tests/Unit/Fix/FixPlanTest.php @@ -0,0 +1,110 @@ +x'; + $source = new File('file.xml', $content); + $plan = new FixPlan( + new Fix('file.xml', 1, 5, 'simpara', 'Sniff', 'para'), + new Fix('file.xml', 9, 13, 'simpara', 'Sniff', 'para'), + ); + + $result = new FixApplier()->apply($source, [$plan]); + + self::assertSame('x', $result->file->content); + self::assertSame(1, $result->applied); + self::assertSame(0, $result->skipped); + } + + #[Test] + public function itAllowsAnIndependentFixBetweenAPlanRanges(): void + { + $content = 'x'; + $source = new File('file.xml', $content); + $plan = new FixPlan( + new Fix('file.xml', 1, 5, 'simpara', 'ElementSniff', 'para'), + new Fix('file.xml', 9, 13, 'simpara', 'ElementSniff', 'para'), + ); + $textFix = new Fix('file.xml', 6, 7, 'y', 'TextSniff', 'x'); + + $result = new FixApplier()->apply($source, [$plan, $textFix]); + + self::assertSame('y', $result->file->content); + self::assertSame(2, $result->applied); + self::assertSame(0, $result->skipped); + } + + #[Test] + public function itSkipsAWholePlanWhenOneRangeIsStale(): void + { + $content = 'x'; + $source = new File('file.xml', $content); + $plan = new FixPlan( + new Fix('file.xml', 1, 5, 'simpara', 'Sniff', 'para'), + new Fix('file.xml', 9, 13, 'simpara', 'Sniff', 'para'), + ); + + $result = new FixApplier()->apply($source, [$plan]); + + self::assertSame($content, $result->file->content); + self::assertSame(0, $result->applied); + self::assertSame(1, $result->skipped); + } + + #[Test] + public function itSkipsAWholePlanWhenOneRangeConflicts(): void + { + $content = 'x'; + $source = new File('file.xml', $content); + $openingTagFix = new Fix('file.xml', 1, 5, 'other', 'FirstSniff', 'para'); + $plan = new FixPlan( + new Fix('file.xml', 1, 5, 'simpara', 'SecondSniff', 'para'), + new Fix('file.xml', 9, 13, 'simpara', 'SecondSniff', 'para'), + ); + + $result = new FixApplier()->apply($source, [$openingTagFix, $plan]); + + self::assertSame('x', $result->file->content); + self::assertSame(1, $result->applied); + self::assertSame(1, $result->skipped); + } + + #[Test] + public function itSkipsFixesForAnotherSource(): void + { + $content = 'x'; + $source = new File('file.xml', $content); + $fix = new Fix('other.xml', 1, 5, 'simpara', 'Sniff', 'para'); + + $result = new FixApplier()->apply($source, [$fix]); + + self::assertSame($content, $result->file->content); + self::assertSame(0, $result->applied); + self::assertSame(1, $result->skipped); + } +} diff --git a/tests/Unit/Path/EntityResolverPathsTest.php b/tests/Unit/Path/EntityResolverPathsTest.php new file mode 100644 index 0000000..d09d1ff --- /dev/null +++ b/tests/Unit/Path/EntityResolverPathsTest.php @@ -0,0 +1,38 @@ +paths(); + $entities = $resolver->resolve(); + + self::assertSame($fixtureRoot . '/included.ent', $paths['inc']); + self::assertSame('child-value', $entities['child']); + } + + #[Test] + public function itDoesNotExposeUnreadableEntityTargets(): void + { + $fixture = __DIR__ . '/../../fixtures/entity_tree/system/missing_target.ent'; + $resolver = new EntityResolver([], [$fixture]); + + self::assertArrayNotHasKey('missing', $resolver->paths()); + } +} diff --git a/tests/Unit/Report/ReportTest.php b/tests/Unit/Report/ReportTest.php index 86eca0c..bff8881 100644 --- a/tests/Unit/Report/ReportTest.php +++ b/tests/Unit/Report/ReportTest.php @@ -4,18 +4,24 @@ namespace DocbookCS\Tests\Unit\Report; +use DocbookCS\RelativePath; use DocbookCS\Report\FileReport; use DocbookCS\Report\Report; -use DocbookCS\Report\Severity; -use DocbookCS\Report\Violation; +use DocbookCS\Violation\Severity; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ CoversClass(FileReport::class), + CoversClass(RelativePath::class), CoversClass(Report::class), CoversClass(Violation::class), + // + UsesClass(SourceRange::class), ] final class ReportTest extends TestCase { @@ -26,7 +32,7 @@ private function createViolation( Severity $severity = Severity::ERROR, string $filePath = 'file.xml', ): Violation { - return new Violation($sniffCode, $filePath, $line, $message, $severity); + return new Violation($sniffCode, $filePath, $line, 0, 0, $message, severity: $severity); } #[Test] @@ -68,6 +74,16 @@ public function itAddsFileReport(): void self::assertSame($fileReport, $report->getFileReports()['src/chapter.xml']); } + #[Test] + public function itKeepsTheFileReportPathWhileRenderingItRelativeToWorkingDirectory(): void + { + $filePath = (getcwd() ?: '') . '/src/chapter.xml'; + $fileReport = new FileReport($filePath); + + self::assertSame($filePath, $fileReport->filePath); + self::assertSame('src/chapter.xml', RelativePath::fromWorkingDirectory($fileReport->filePath)); + } + #[Test] public function itKeysFileReportsByFilePath(): void { diff --git a/tests/Unit/Report/Reporter/CheckstyleReporterTest.php b/tests/Unit/Report/Reporter/CheckstyleReporterTest.php index 87cbfb1..4e72cb3 100644 --- a/tests/Unit/Report/Reporter/CheckstyleReporterTest.php +++ b/tests/Unit/Report/Reporter/CheckstyleReporterTest.php @@ -4,13 +4,16 @@ namespace DocbookCS\Tests\Unit\Report\Reporter; +use DocbookCS\RelativePath; use DocbookCS\Report\FileReport; use DocbookCS\Report\Report; use DocbookCS\Report\Reporter\CheckstyleReporter; -use DocbookCS\Report\Severity; -use DocbookCS\Report\Violation; +use DocbookCS\Violation\Severity; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ @@ -18,6 +21,9 @@ CoversClass(FileReport::class), CoversClass(Report::class), CoversClass(Violation::class), + // + UsesClass(RelativePath::class), + UsesClass(SourceRange::class), ] final class CheckstyleReporterTest extends TestCase { @@ -34,7 +40,7 @@ private function createViolation( string $sniffCode = 'DocbookCS.Test', Severity $severity = Severity::ERROR, ): Violation { - return new Violation($sniffCode, 'filepath.xml', $line, $message, $severity); + return new Violation($sniffCode, 'filepath.xml', $line, 0, 0, $message, severity: $severity); } private function parseOutput(string $xml): \DOMDocument @@ -113,6 +119,23 @@ public function itIncludesFileNodeWithNameAttribute(): void self::assertSame('src/broken.xml', $fileNodes->item(0)?->getAttribute('name')); } + #[Test] + public function itRendersAbsoluteFilePathRelativeToWorkingDirectory(): void + { + $fileReport = new FileReport((getcwd() ?: '') . '/src/broken.xml'); + $fileReport->addViolation($this->createViolation()); + + $report = new Report(); + $report->addFileReport($fileReport); + + $dom = $this->parseOutput($this->reporter->generate($report)); + + self::assertSame( + 'src/broken.xml', + $dom->getElementsByTagName('file')->item(0)?->getAttribute('name'), + ); + } + #[Test] public function itSetsLineAttribute(): void { diff --git a/tests/Unit/Report/Reporter/ConsoleReporterTest.php b/tests/Unit/Report/Reporter/ConsoleReporterTest.php index ad2ba58..4b29bf9 100644 --- a/tests/Unit/Report/Reporter/ConsoleReporterTest.php +++ b/tests/Unit/Report/Reporter/ConsoleReporterTest.php @@ -4,13 +4,16 @@ namespace DocbookCS\Tests\Unit\Report\Reporter; +use DocbookCS\RelativePath; use DocbookCS\Report\FileReport; use DocbookCS\Report\Report; use DocbookCS\Report\Reporter\ConsoleReporter; -use DocbookCS\Report\Severity; -use DocbookCS\Report\Violation; +use DocbookCS\Violation\Severity; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ @@ -18,6 +21,9 @@ CoversClass(FileReport::class), CoversClass(Report::class), CoversClass(Violation::class), + // + UsesClass(RelativePath::class), + UsesClass(SourceRange::class), ] final class ConsoleReporterTest extends TestCase { @@ -35,7 +41,7 @@ private function createViolation( Severity $severity = Severity::ERROR, string $filePath = 'filepath.xml', ): Violation { - return new Violation($sniffCode, $filePath, $line, $message, $severity); + return new Violation($sniffCode, $filePath, $line, 0, 0, $message, severity: $severity); } #[Test] @@ -90,6 +96,20 @@ public function itShowsFilePathInHeader(): void self::assertStringContainsString('FILE: src/broken.xml', $output); } + #[Test] + public function itRendersAbsoluteFilePathRelativeToWorkingDirectory(): void + { + $fileReport = new FileReport((getcwd() ?: '') . '/src/broken.xml'); + $fileReport->addViolation($this->createViolation()); + + $report = new Report(); + $report->addFileReport($fileReport); + + $output = $this->reporter->generate($report); + + self::assertStringContainsString('FILE: src/broken.xml', $output); + } + #[Test] public function itShowsDashSeparatorAfterFileHeader(): void { diff --git a/tests/Unit/Report/Reporter/JsonReporterTest.php b/tests/Unit/Report/Reporter/JsonReporterTest.php index 3cbbe72..dda9faf 100644 --- a/tests/Unit/Report/Reporter/JsonReporterTest.php +++ b/tests/Unit/Report/Reporter/JsonReporterTest.php @@ -4,13 +4,16 @@ namespace DocbookCS\Tests\Unit\Report\Reporter; +use DocbookCS\RelativePath; use DocbookCS\Report\FileReport; use DocbookCS\Report\Report; use DocbookCS\Report\Reporter\JsonReporter; -use DocbookCS\Report\Severity; -use DocbookCS\Report\Violation; +use DocbookCS\Violation\Severity; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ @@ -18,6 +21,9 @@ CoversClass(JsonReporter::class), CoversClass(Report::class), CoversClass(Violation::class), + // + UsesClass(RelativePath::class), + UsesClass(SourceRange::class), ] final class JsonReporterTest extends TestCase { @@ -34,7 +40,7 @@ private function createViolation( string $sniffCode = 'DocbookCS.Test', Severity $severity = Severity::ERROR, ): Violation { - return new Violation($sniffCode, 'filepath.xml', $line, $message, $severity); + return new Violation($sniffCode, 'filepath.xml', $line, 0, 0, $message, severity: $severity); } #[Test] @@ -312,6 +318,20 @@ public function itDoesNotEscapeSlashesInOutput(): void self::assertStringNotContainsString('path\/to\/file.xml', $output); } + #[Test] + public function itRendersAbsoluteFilePathRelativeToWorkingDirectory(): void + { + $fileReport = new FileReport((getcwd() ?: '') . '/path/to/file.xml'); + $fileReport->addViolation($this->createViolation()); + + $report = new Report(); + $report->addFileReport($fileReport); + + $data = $this->parseOutput($this->reporter->generate($report)); + + self::assertArrayHasKey('path/to/file.xml', $data['files']); + } + #[Test] public function itUsesPrettyPrintedJson(): void { diff --git a/tests/Unit/Runner/RunPlannerTest.php b/tests/Unit/Runner/RunPlannerTest.php new file mode 100644 index 0000000..8515f25 --- /dev/null +++ b/tests/Unit/Runner/RunPlannerTest.php @@ -0,0 +1,67 @@ +createMock(DiffProviderInterface::class); + $diffProvider + ->expects(self::once()) + ->method('for') + ->willReturn(<<<'DIFF' +diff --git a/nonexistent.xml b/nonexistent.xml +--- a/nonexistent.xml ++++ b/nonexistent.xml +@@ -1 +1 @@ +-old ++new +DIFF); + + $planner = new RunPlanner($config, diffProvider: $diffProvider); + + self::assertSame([], $planner->plan([], null)->targets); + } +} diff --git a/tests/Unit/Runner/SniffRunnerTest.php b/tests/Unit/Runner/SniffRunnerTest.php deleted file mode 100644 index 47b6d25..0000000 --- a/tests/Unit/Runner/SniffRunnerTest.php +++ /dev/null @@ -1,325 +0,0 @@ - $sniffs */ - private function createConfig(array $sniffs = []): ConfigData - { - return new ConfigData( - [], - $sniffs, - [self::FIXTURE_DIR], - [], - [], - self::FIXTURE_DIR, - ); - } - - #[Test] - public function itProcessesFilesWithoutViolations(): void - { - $config = $this->createConfig(); - - $runner = new SniffRunner(); - $report = $runner->run($config); - - self::assertSame(2, $report->getFilesScanned()); - self::assertFalse($report->hasViolations()); - self::assertCount(0, $report->getFileReports()); - } - - #[Test] - public function itUsesOverridePathsWhenProvided(): void - { - $config = $this->createConfig(); - - $runner = new SniffRunner(); - $report = $runner->run($config, [self::FIXTURE_DIR . '/../override']); - - self::assertSame(1, $report->getFilesScanned()); - } - - #[Test] - public function itCallsProgressMethods(): void - { - $progress = $this->createMock(ProgressInterface::class); - - $progress->expects($this->once()) - ->method('start') - ->with(2); - - $progress->expects($this->exactly(2)) - ->method('advance'); - - $progress->expects($this->once()) - ->method('finish'); - - $config = $this->createConfig(); - - $runner = new SniffRunner($progress); - $runner->run($config); - } - - #[Test] - public function itAddsFileReportsForFilesWithViolations(): void - { - $sniff = new class implements SniffInterface { - public function getCode(): string - { - return 'Test.ViolatingSniff'; - } - - public function process(\DOMDocument $document, string $content, string $filePath): array - { - return [ - new Violation( - sniffCode: 'Test.ViolatingSniff', - filePath: $filePath, - line: 1, - message: 'Test violation message', - severity: Severity::WARNING, - ), - ]; - } - - public function setProperty(string $name, string $value): void - { - } - }; - - $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); - - $runner = new SniffRunner(); - $report = $runner->run($config); - - self::assertSame(2, $report->getFilesScanned()); - self::assertCount(2, $report->getFileReports()); - self::assertTrue($report->hasViolations()); - } - - #[Test] - public function itStoresRelativePathsInFileReports(): void - { - $sniff = new class implements SniffInterface { - public function getCode(): string - { - return 'Test.ViolatingSniff'; - } - - public function process(\DOMDocument $document, string $content, string $filePath): array - { - return [ - new Violation( - sniffCode: 'Test.ViolatingSniff', - filePath: $filePath, - line: 1, - message: 'Test violation', - severity: Severity::WARNING, - ), - ]; - } - - public function setProperty(string $name, string $value): void - { - } - }; - - $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); - - $runner = new SniffRunner(); - $report = $runner->run($config); - - foreach ($report->getFileReports() as $fileReport) { - self::assertFalse( - str_starts_with($fileReport->filePath, '/'), - 'Expected relative path, got: ' . $fileReport->filePath, - ); - } - } - - #[Test] - public function itPassesPropertiesToSniffs(): void - { - $sniffClass = new class implements SniffInterface { - public static string $captured = ''; - - public function setProperty(string $name, string $value): void - { - self::$captured = $value; - } - - public function getCode(): string - { - return 'Test.ConfigurableSniff'; - } - - public function process(\DOMDocument $document, string $content, string $filePath): array - { - return []; - } - }; - - $config = $this->createConfig(sniffs: [new SniffEntry($sniffClass::class, ['someProp' => 'someValue'])]); - - $runner = new SniffRunner(); - $runner->run($config); - - self::assertSame('someValue', $sniffClass::$captured); - } - - #[Test] - public function itThrowsWhenSniffClassDoesNotExist(): void - { - $config = $this->createConfig(sniffs: [new SniffEntry('NonExistent\\FakeSniff')]); - - $runner = new SniffRunner(); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('does not exist'); - - $runner->run($config); - } - - #[Test] - public function itThrowsWhenClassDoesNotImplementSniffInterface(): void - { - $config = $this->createConfig(sniffs: [new SniffEntry(\stdClass::class)]); - - $runner = new SniffRunner(); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('does not implement'); - - $runner->run($config); - } - - #[Test] - public function itFiltersFilesToOnlyThoseInTheDiff(): void - { - $config = $this->createConfig(); - $runner = new SniffRunner(); - - $diff = new Diff([new FileChange('sniff_runner/default/file_a.xml', [1])]); - $report = $runner->run($config, null, $diff); - - self::assertSame(1, $report->getFilesScanned()); - } - - #[Test] - public function itScansNoFilesWhenDiffContainsNoMatchingPaths(): void - { - $config = $this->createConfig(); - $runner = new SniffRunner(); - - $diff = new Diff([new FileChange('completely/different/file.xml', [1, 2, 3])]); - $report = $runner->run($config, null, $diff); - - self::assertSame(0, $report->getFilesScanned()); - } - - #[Test] - public function itMatchesWhenDiffPathEqualsDiscoveredPath(): void - { - $config = $this->createConfig(); - $runner = new SniffRunner(); - - $discoveredPath = self::FIXTURE_DIR . '/file_a.xml'; - - $diff = new Diff([new FileChange($discoveredPath, [1])]); - $report = $runner->run($config, null, $diff); - - self::assertSame(1, $report->getFilesScanned()); - } - - #[Test] - public function itScansAllFilesWhenNoDiffIsGiven(): void - { - $config = $this->createConfig(); - $runner = new SniffRunner(); - - $report = $runner->run($config); - - self::assertSame(2, $report->getFilesScanned()); - } - - #[Test] - public function itReportsNoViolationsForFilesInDiffWithoutAddedLines(): void - { - $sniff = new class implements SniffInterface { - public function getCode(): string - { - return 'Test.ViolatingSniff'; - } - - public function process(\DOMDocument $document, string $content, string $filePath): array - { - return [ - new Violation( - sniffCode: 'Test.ViolatingSniff', - filePath: $filePath, - line: 1, - message: 'Test violation', - severity: Severity::WARNING, - ), - ]; - } - - public function setProperty(string $name, string $value): void - { - } - }; - - $config = $this->createConfig(sniffs: [new SniffEntry($sniff::class)]); - $runner = new SniffRunner(); - - $diff = new Diff([new FileChange('sniff_runner/default/file_a.xml', [])]); - $report = $runner->run($config, null, $diff); - - self::assertSame(1, $report->getFilesScanned()); - self::assertFalse($report->hasViolations()); - } -} diff --git a/tests/Unit/Runner/SourceScopeTest.php b/tests/Unit/Runner/SourceScopeTest.php new file mode 100644 index 0000000..27f5328 --- /dev/null +++ b/tests/Unit/Runner/SourceScopeTest.php @@ -0,0 +1,140 @@ +isWholeFile()); + self::assertSame([2], $scope->lineNumbers($file)); + self::assertTrue($scope->includes($this->violation(4, 7, 2))); + self::assertFalse($scope->includes($this->violation(8, 13, 3))); + } + + #[Test] + public function wholeFileScopeIncludesEveryLine(): void + { + $file = new File('file.xml', "one\ntwo\nthree"); + $scope = SourceScope::wholeFile(); + + self::assertTrue($scope->isWholeFile()); + self::assertSame([1, 2, 3], $scope->lineNumbers($file)); + } + + #[Test] + public function anEmptyChangedLineSetSelectsNoLines(): void + { + $file = new File('file.xml', "one\ntwo\nthree"); + $scope = SourceScope::fromFileChange($file, new FileChange('file.xml', [])); + + self::assertSame([], $scope->lineNumbers($file)); + } + + #[Test] + public function itKeepsScopeAlignedAfterAnInsertionBeforeIt(): void + { + $file = new File('file.xml', "one\ntwo\nthree"); + $scope = SourceScope::fromFileChange($file, new FileChange('file.xml', [2])); + $scope = $scope->after([ + new Fix('file.xml', 0, 0, "x\n", 'Test'), + ]); + $file = $file->withContent("x\none\ntwo\nthree"); + + self::assertSame([3], $scope->lineNumbers($file)); + self::assertFalse($scope->includes($this->violation(4, 5, 2))); + self::assertTrue($scope->includes($this->violation(6, 9, 3))); + self::assertFalse($scope->includes($this->violation(10, 15, 4))); + } + + #[Test] + public function itIncludesContentInsertedIntoAnEmptySelectedLine(): void + { + $file = new File('file.xml', "root\n"); + $scope = SourceScope::fromFileChange($file, new FileChange('file.xml', [2])); + $scope = $scope->after([ + new Fix('file.xml', 5, 5, 'value', 'Test'), + ]); + + self::assertTrue($scope->includes($this->violation(5, 10, 2))); + } + + #[Test] + public function itSortsFixesBeforeMappingScopeOffsets(): void + { + $file = new File('file.xml', "one\ntwo\nthree"); + $scope = SourceScope::fromFileChange($file, new FileChange('file.xml', [2])); + $scope = $scope->after([ + new Fix('file.xml', 13, 13, "\nfour", 'Test'), + new Fix('file.xml', 0, 0, "zero\n", 'Test'), + ]); + $file = $file->withContent("zero\none\ntwo\nthree\nfour"); + + self::assertSame([3], $scope->lineNumbers($file)); + } + + #[Test] + public function itIncludesAViolationContainingADeletionLocation(): void + { + $file = new File('file.xml', "\n\nText\n\n"); + $scope = SourceScope::fromFileChange( + $file, + new FileChange('file.xml', [], deletionAnchors: [3]), + ); + + self::assertTrue($scope->includes($this->violation(7, 27, 2))); + self::assertFalse($scope->includes($this->violation(0, 6, 1))); + } + + #[Test] + public function itAnchorsADeletionAtTheEndOfTheFile(): void + { + $file = new File('file.xml', "\n"); + $scope = SourceScope::fromFileChange( + $file, + new FileChange('file.xml', [], deletionAnchors: [2]), + ); + + self::assertTrue($scope->includes($this->violation(0, strlen($file->content) + 1, 1))); + } + + private function violation(int $beginOffset, int $untilOffset, int $line): Violation + { + return new Violation( + sniffCode: 'Test', + filePath: 'file.xml', + line: $line, + beginOffset: $beginOffset, + untilOffset: $untilOffset, + message: 'Test violation.', + ); + } +} diff --git a/tests/Unit/Runner/ViolationScopeFilterTest.php b/tests/Unit/Runner/ViolationScopeFilterTest.php new file mode 100644 index 0000000..5a42559 --- /dev/null +++ b/tests/Unit/Runner/ViolationScopeFilterTest.php @@ -0,0 +1,51 @@ +'); + $document = new \DOMDocument(); + self::assertTrue($document->loadXML($file->content)); + $violations = [new Violation( + sniffCode: 'Test.Stub', + filePath: $file->path, + line: 1, + beginOffset: 0, + untilOffset: 0, + message: 'violation at line 1', + )]; + + $result = new ViolationScopeFilter()->filter( + $violations, + $document, + $file, + SourceScope::wholeFile(), + ); + + self::assertSame($violations, $result); + } +} diff --git a/tests/Unit/Runner/XmlFileProcessorTest.php b/tests/Unit/Runner/XmlFileProcessorTest.php index 901835a..d609060 100644 --- a/tests/Unit/Runner/XmlFileProcessorTest.php +++ b/tests/Unit/Runner/XmlFileProcessorTest.php @@ -4,15 +4,27 @@ namespace DocbookCS\Tests\Unit\Runner; +use DocbookCS\Diff\FileChange; +use DocbookCS\Fix\Fixer\AttributeOrderFixer; +use DocbookCS\Fix\FixerException; use DocbookCS\Report\FileReport; use DocbookCS\Report\Report; -use DocbookCS\Report\Severity; -use DocbookCS\Report\Violation; use DocbookCS\Runner\EntityPreprocessor; +use DocbookCS\Runner\RunMode; +use DocbookCS\Runner\SourceScope; +use DocbookCS\Runner\ViolationScopeFilter; use DocbookCS\Runner\XmlFileProcessor; +use DocbookCS\Runner\XmlProcessingResult; +use DocbookCS\Sniff\Fixable; use DocbookCS\Sniff\SniffInterface; +use DocbookCS\Source\File; +use DocbookCS\Source\Line; +use DocbookCS\Violation\Severity; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ @@ -20,36 +32,49 @@ CoversClass(FileReport::class), CoversClass(Report::class), CoversClass(Violation::class), + CoversClass(ViolationScopeFilter::class), CoversClass(XmlFileProcessor::class), + // + UsesClass(AttributeOrderFixer::class), + UsesClass(File::class), + UsesClass(FileChange::class), + UsesClass(FixerException::class), + UsesClass(Line::class), + UsesClass(RunMode::class), + UsesClass(SourceRange::class), + UsesClass(SourceScope::class), + UsesClass(XmlProcessingResult::class), ] final class XmlFileProcessorTest extends TestCase { #[Test] public function itReportsParseErrors(): void { - $report = $this->processor()->processString('', 'bad.xml'); + $report = $this->process($this->processor(), '', 'bad.xml'); $this->assertInternalError($report, 'XML parse error'); } #[Test] - public function itReportsMissingFiles(): void + public function itReportsParseErrorsOutsideChangedSourceRanges(): void { - $report = $this->processor()->processFile('/nonexistent/path/file.xml'); + $report = $this->process( + $this->processor(), + '', + 'bad.xml', + new FileChange('bad.xml', [99]), + ); - $this->assertInternalError($report, 'Could not read file'); + $this->assertInternalError($report, 'XML parse error'); } #[Test] - public function itPrefersReportPathOverFilePath(): void + public function itStoresTheProvidedFilePathInFileReports(): void { - $report = $this->processor()->processFile( - '/nonexistent/path/file.xml', - [], - 'relative/file.xml' - ); + $filePath = (getcwd() ?: '') . '/nonexistent/path/file.xml'; + $report = $this->process($this->processor(), '', $filePath); - self::assertSame('relative/file.xml', $report->filePath); + self::assertSame($filePath, $report->filePath); } #[Test] @@ -57,63 +82,17 @@ public function itAcceptsValidXmlWithoutViolations(): void { $xml = $this->xml('ok'); - $report = $this->processor()->processString($xml); + $report = $this->process($this->processor(), $xml); self::assertFalse($report->hasViolations()); } - #[Test] - public function itHandlesEntitiesWithoutParseErrors(): void - { - $xml = $this->xml( - ' - - &link.superglobals; &php.ini; & - ' - ); - - $processor = $this->processor([], new EntityPreprocessor([ - 'link.superglobals' => '', - 'php.ini' => '', - ])); - - $report = $processor->processString($xml); - - self::assertCount( - 0, - array_filter( - $report->getViolations(), - fn($v) => $v->sniffCode === 'DocbookCS.Internal' - ) - ); - } - - #[Test] - public function itUsesCustomPreprocessor(): void - { - $processor = $this->processor([], new EntityPreprocessor([ - 'custom.entity' => '[X]', - ])); - - $xml = $this->xml('&custom.entity;'); - - $report = $processor->processString($xml); - - self::assertCount( - 0, - array_filter( - $report->getViolations(), - fn($v) => $v->sniffCode === 'DocbookCS.Internal' - ) - ); - } - #[Test] public function itReturnsZeroViolationsWithoutSniffs(): void { $xml = $this->xml('Hello'); - $report = $this->processor()->processString($xml); + $report = $this->process($this->processor(), $xml); self::assertSame(0, $report->getViolationCount()); } @@ -131,7 +110,7 @@ public function itReturnsAllViolationsWithoutDiffFiltering(): void ' ); - $report = $this->processor([$sniff])->processString($xml); + $report = $this->process($this->processor([$sniff]), $xml); self::assertSame(2, $report->getViolationCount()); } @@ -149,7 +128,12 @@ public function itFiltersViolationsByChangedLines(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'f.xml', [3]); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'f.xml', + new FileChange('f.xml', [3]), + ); self::assertSame(1, $report->getViolationCount()); self::assertSame(3, $report->getViolations()[0]->line); @@ -170,7 +154,12 @@ public function itExpandsElementSpanForNestedChanges(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'x.xml', [6]); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'x.xml', + new FileChange('x.xml', [6]), + ); self::assertSame(1, $report->getViolationCount()); } @@ -186,7 +175,12 @@ public function itDropsViolationsWhoseLineHasNoElement(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'x.xml', [3]); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'x.xml', + new FileChange('x.xml', [3]), + ); self::assertSame(0, $report->getViolationCount()); } @@ -204,7 +198,12 @@ public function itMatchesChangesInElementOwnTextContent(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'x.xml', [4]); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'x.xml', + new FileChange('x.xml', [4]), + ); self::assertSame(1, $report->getViolationCount()); } @@ -223,7 +222,12 @@ public function itBoundsChildSpanByNextSibling(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'x.xml', [4]); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'x.xml', + new FileChange('x.xml', [4]), + ); self::assertSame(1, $report->getViolationCount()); } @@ -245,7 +249,12 @@ public function itIgnoresChangesInNonDirectDescendants(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'x.xml', [6]); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'x.xml', + new FileChange('x.xml', [6]), + ); self::assertSame(0, $report->getViolationCount()); } @@ -262,20 +271,16 @@ public function itIgnoresChangesOutsideElementSpan(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'x.xml', [7]); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'x.xml', + new FileChange('x.xml', [7]), + ); self::assertSame(0, $report->getViolationCount()); } - #[Test] - public function itKeepsInternalErrorsEvenWithDiffFiltering(): void - { - $report = $this->processor()->processFile('/nonexistent/path/file.xml', [42]); - - self::assertTrue($report->hasViolations()); - self::assertSame('DocbookCS.Internal', $report->getViolations()[0]->sniffCode); - } - #[Test] public function itReportsNoViolationsInDiffModeWhenNoLinesWereAdded(): void { @@ -289,32 +294,124 @@ public function itReportsNoViolationsInDiffModeWhenNoLinesWereAdded(): void ' ); - $report = $this->processor([$sniff])->processString($xml, 'f.xml', []); + $report = $this->process( + $this->processor([$sniff]), + $xml, + 'f.xml', + new FileChange('f.xml', []), + ); self::assertSame(0, $report->getViolationCount()); } + #[Test] + public function itDoesNotFixViolationsFromNonFixableSniffs(): void + { + $sniff = new class (RunMode::Sniff) implements SniffInterface { + public function __construct(public RunMode $mode) + { + } + + public static function getCode(): string + { + return 'Test.NonFixable'; + } + + public function process(\DOMDocument $document, File $file): array + { + return [ + new Violation( + sniffCode: self::getCode(), + filePath: $file->path, + line: 2, + beginOffset: 0, + untilOffset: 7, + message: 'Reported only.', + content: '', + severity: Severity::ERROR, + ), + ]; + } + + public function setProperty(string $name, string $value): void + { + } + }; + + $report = $this->process($this->processor([$sniff]), $this->xml('')); + + self::assertSame(1, $report->getViolationCount()); + } + + #[Test] + public function itThrowsWhenFixableSniffReportsViolationWithoutContentInFixMode(): void + { + $sniff = new class (RunMode::Fix) implements Fixable { + public function __construct(public RunMode $mode) + { + } + + public static function getCode(): string + { + return 'Test.BrokenFixable'; + } + + public static function fixerClassName(): string + { + return AttributeOrderFixer::class; + } + + public function process(\DOMDocument $document, File $file): array + { + return [ + new Violation( + sniffCode: self::getCode(), + filePath: $file->path, + line: 1, + beginOffset: 0, + untilOffset: 7, + message: 'Missing source content.', + severity: Severity::ERROR, + ), + ]; + } + + public function setProperty(string $name, string $value): void + { + } + }; + + $this->expectException(FixerException::class); + $this->expectExceptionMessageIsOrContains('Violations cannot be content-less when passed to a fixer.'); + + $this->process($this->processor([$sniff]), $this->xml('')); + } + /** @param list $lines */ private function sniff(array $lines): SniffInterface { - return new class ($lines) implements SniffInterface { - /** @param list $lines */ - public function __construct(private readonly array $lines) + $sniff = new class (RunMode::Sniff) implements SniffInterface { + /** @var list */ + public array $lines = []; + + public function __construct(public RunMode $mode) { } - public function getCode(): string + public static function getCode(): string { return 'Test.Stub'; } - public function process(\DOMDocument $document, string $content, string $filePath): array + public function process(\DOMDocument $document, File $file): array { return array_map( fn(int $line) => new Violation( - sniffCode: $this->getCode(), - filePath: $filePath, + sniffCode: self::getCode(), + filePath: $file->path, line: $line, + beginOffset: 0, + untilOffset: 0, message: "violation at line {$line}", severity: Severity::WARNING ), @@ -326,6 +423,19 @@ public function setProperty(string $name, string $value): void { } }; + + $sniff->lines = $lines; + + return $sniff; + } + + private function process( + XmlFileProcessor $processor, + string $content, + string $path = 'input.xml', + ?FileChange $fileChange = null, + ): FileReport { + return $processor->process(new File($path, $content), $fileChange)->fileReport; } /** @param list $sniffs */ diff --git a/tests/Unit/Runner/XmlProcessingResultTest.php b/tests/Unit/Runner/XmlProcessingResultTest.php new file mode 100644 index 0000000..e06f8e0 --- /dev/null +++ b/tests/Unit/Runner/XmlProcessingResultTest.php @@ -0,0 +1,73 @@ +'), + new File('input.xml', ''), + ); + + self::assertFalse($result->isModified()); + } + + #[Test] + public function itHasPendingFixesForModifiedContent(): void + { + $result = new XmlProcessingResult( + new FileReport('input.xml'), + new File('input.xml', ''), + new File('input.xml', ''), + ); + + self::assertTrue($result->isModified()); + } + + #[Test] + public function itThrowsWhenReadingFixedContentWithoutFixApplication(): void + { + $result = new XmlProcessingResult( + new FileReport('input.xml'), + new File('input.xml', ''), + new File('input.xml', ''), + ); + + $this->expectException(FixerException::class); + $this->expectExceptionMessageIsOrContains('Cannot read fixed content when no fix application was attempted.'); + + $result->fixedContent(); + } + + #[Test] + public function itReturnsFixedContentWhenFixApplicationExists(): void + { + $result = new XmlProcessingResult( + new FileReport('input.xml'), + new File('input.xml', ''), + new File('input.xml', ''), + ); + + self::assertSame('', $result->fixedContent()); + } +} diff --git a/tests/Unit/Sniff/AbstractSniffTest.php b/tests/Unit/Sniff/AbstractSniffTest.php index 8c9c5a4..0f8e834 100644 --- a/tests/Unit/Sniff/AbstractSniffTest.php +++ b/tests/Unit/Sniff/AbstractSniffTest.php @@ -5,6 +5,7 @@ namespace DocbookCS\Tests\Unit\Sniff; use DocbookCS\Sniff\AbstractSniff; +use DocbookCS\Source\File; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -17,12 +18,12 @@ final class AbstractSniffTest extends TestCase private function createSniff(): AbstractSniff { return new class extends AbstractSniff { - public function getCode(): string + public static function getCode(): string { return 'test.sniff'; } - public function process(\DOMDocument $document, string $content, string $filePath): array + public function process(\DOMDocument $document, File $file): array { return []; } diff --git a/tests/Unit/Sniff/AffectedRangesTest.php b/tests/Unit/Sniff/AffectedRangesTest.php new file mode 100644 index 0000000..c3c1bef --- /dev/null +++ b/tests/Unit/Sniff/AffectedRangesTest.php @@ -0,0 +1,71 @@ +\nText\n"; + $violation = new SimparaSniff()->process( + $this->document($content), + new File('file.xml', $content), + )[0]; + + self::assertSame('Text', $violation->content); + self::assertSame((int) strpos($content, ''), $violation->beginOffset); + self::assertEquals([ + new SourceRange(2, 8, 12), + new SourceRange(2, 19, 23), + ], $violation->affectedRanges); + } + + #[Test] + public function exceptionNameIdentifiesElementNamesOnDifferentLines(): void + { + $content = "\nRuntimeException\n\n"; + $violation = new ExceptionNameSniff()->process( + $this->document($content), + new File('file.xml', $content), + )[0]; + + self::assertSame("RuntimeException\n", $violation->content); + self::assertEquals([ + new SourceRange(2, 8, 17), + new SourceRange(3, 37, 46), + ], $violation->affectedRanges); + } + + private function document(string $content): \DOMDocument + { + $document = new \DOMDocument(); + $document->loadXML($content); + + return $document; + } +} diff --git a/tests/Unit/Sniff/AttributeOrderSniffTest.php b/tests/Unit/Sniff/AttributeOrderSniffTest.php index da2e993..173fe44 100644 --- a/tests/Unit/Sniff/AttributeOrderSniffTest.php +++ b/tests/Unit/Sniff/AttributeOrderSniffTest.php @@ -4,15 +4,23 @@ namespace DocbookCS\Tests\Unit\Sniff; -use DocbookCS\Report\Violation; use DocbookCS\Sniff\AttributeOrderSniff; +use DocbookCS\Source\File; +use DocbookCS\Source\Line; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ CoversClass(AttributeOrderSniff::class), CoversClass(Violation::class), + // + UsesClass(File::class), + UsesClass(Line::class), + UsesClass(SourceRange::class), ] final class AttributeOrderSniffTest extends TestCase { @@ -34,7 +42,7 @@ public function itIgnoresElementsWithoutRelevantAttributes(): void self::assertSame( [], - $sniff->process($doc, $content, 'test.xml') + $sniff->process($doc, new File('test.xml', $content)) ); } @@ -48,7 +56,7 @@ public function itDoesNotFlagWhenXmlIdComesFirst(): void self::assertSame( [], - $sniff->process($doc, $content, 'test.xml') + $sniff->process($doc, new File('test.xml', $content)) ); } @@ -60,7 +68,7 @@ public function itFlagsWhenXmlIdComesAfterXmlns(): void $content = ''; $doc = $this->createDocument(''); - $violations = $sniff->process($doc, $content, 'file.xml'); + $violations = $sniff->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertStringContainsString( @@ -79,7 +87,7 @@ public function itHandlesXmlnsPrefixedAttributes(): void self::assertCount( 1, - $sniff->process($doc, $content, 'file.xml') + $sniff->process($doc, new File('file.xml', $content)) ); } @@ -97,7 +105,7 @@ public function itHandlesMultipleElements(): void $doc = $this->createDocument(''); - $violations = $sniff->process($doc, $content, 'file.xml'); + $violations = $sniff->process($doc, new File('file.xml', $content)); self::assertCount(2, $violations); } @@ -113,7 +121,7 @@ public function itReportsCorrectLineNumber(): void $doc = $this->createDocument(''); - $violations = $sniff->process($doc, $content, 'file.xml'); + $violations = $sniff->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertSame(2, $violations[0]->line); @@ -128,7 +136,37 @@ public function itReturnsEmptyWhenContentIsEmpty(): void self::assertSame( [], - $sniff->process($doc, '', 'file.xml') + $sniff->process($doc, new File('file.xml', '')) ); } + + #[Test] + public function itAddsSourceContent(): void + { + $sniff = new AttributeOrderSniff(); + + $content = ''; + $doc = $this->createDocument(''); + + $violations = $sniff->process($doc, new File('file.xml', $content)); + + self::assertCount(1, $violations); + self::assertSame('', $violations[0]->content); + self::assertSame(0, $violations[0]->beginOffset); + self::assertSame(38, $violations[0]->untilOffset); + self::assertSame(1, $violations[0]->line); + } + + #[Test] + public function itIgnoresElementsInsideComments(): void + { + $content = '' + . ''; + $doc = $this->createDocument($content); + + $violations = new AttributeOrderSniff()->process($doc, new File('file.xml', $content)); + + self::assertCount(1, $violations); + self::assertSame('', $violations[0]->content); + } } diff --git a/tests/Unit/Sniff/ExceptionNameSniffTest.php b/tests/Unit/Sniff/ExceptionNameSniffTest.php index 5e749c6..ab3abf4 100644 --- a/tests/Unit/Sniff/ExceptionNameSniffTest.php +++ b/tests/Unit/Sniff/ExceptionNameSniffTest.php @@ -4,9 +4,12 @@ namespace DocbookCS\Tests\Unit\Sniff; -use DocbookCS\Report\Violation; use DocbookCS\Runner\EntityExpansionMarker; use DocbookCS\Sniff\ExceptionNameSniff; +use DocbookCS\Source\File; +use DocbookCS\Source\Line; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -17,6 +20,9 @@ CoversClass(Violation::class), // UsesClass(EntityExpansionMarker::class), + UsesClass(File::class), + UsesClass(Line::class), + UsesClass(SourceRange::class), ] final class ExceptionNameSniffTest extends TestCase { @@ -33,7 +39,7 @@ public function itReturnsEmptyWhenNoClassnameNodes(): void { $content = ''; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'test.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('test.xml', $content)); self::assertSame([], $violations); } @@ -43,7 +49,7 @@ public function itIgnoresEmptyClassname(): void { $content = ' '; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'test.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('test.xml', $content)); self::assertSame([], $violations); } @@ -53,7 +59,7 @@ public function itDoesNotFlagRegularClassnames(): void { $content = 'MyService'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'test.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('test.xml', $content)); self::assertSame([], $violations); } @@ -63,7 +69,7 @@ public function itFlagsExceptionSuffix(): void { $content = 'RuntimeException'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertStringContainsString( @@ -77,7 +83,7 @@ public function itFlagsErrorSuffix(): void { $content = 'TypeError'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertStringContainsString( @@ -91,7 +97,7 @@ public function itFlagsThrowableSuffix(): void { $content = 'CustomThrowable'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertStringContainsString( @@ -105,7 +111,7 @@ public function itHandlesNamespacedClassnames(): void { $content = 'Foo\Bar\BazException'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertStringContainsString( @@ -119,7 +125,7 @@ public function itOnlyChecksBaseNameInNamespace(): void { $content = 'Exception\ButNotActually'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); self::assertSame([], $violations); } @@ -135,7 +141,7 @@ public function itHandlesMultipleClassnames(): void '; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); self::assertCount(2, $violations); } @@ -145,7 +151,7 @@ public function itDoesNotFlagClassnameInsideOoclass(): void { $content = 'RuntimeException'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); self::assertSame([], $violations); } @@ -155,9 +161,57 @@ public function itIncludesFilePathInViolation(): void { $content = 'RuntimeException'; $doc = $this->createDocument($content); - $violations = new ExceptionNameSniff()->process($doc, $content, 'my-file.xml'); + $violations = new ExceptionNameSniff()->process($doc, new File('my-file.xml', $content)); self::assertCount(1, $violations); self::assertSame('my-file.xml', $violations[0]->filePath); } + + #[Test] + public function itAddsSourceContent(): void + { + $content = 'RuntimeException'; + $doc = $this->createDocument($content); + + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); + + $beginOffset = (int) strpos($content, ''); + $sourceContent = 'RuntimeException'; + + self::assertCount(1, $violations); + self::assertSame($sourceContent, $violations[0]->content); + self::assertSame($beginOffset, $violations[0]->beginOffset); + self::assertSame($beginOffset + strlen($sourceContent), $violations[0]->untilOffset); + self::assertSame(1, $violations[0]->line); + } + + #[Test] + public function itKeepsSourceContentAlignedAfterRegularClassnames(): void + { + $content = 'RegularClassRuntimeException'; + $doc = $this->createDocument($content); + + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); + + $sourceContent = 'RuntimeException'; + $beginOffset = (int) strpos($content, $sourceContent); + + self::assertCount(1, $violations); + self::assertSame($sourceContent, $violations[0]->content); + self::assertSame($beginOffset, $violations[0]->beginOffset); + self::assertSame($beginOffset + strlen($sourceContent), $violations[0]->untilOffset); + } + + #[Test] + public function itIgnoresClassnamesInsideCommentsWhenMappingSource(): void + { + $content = '' + . 'RuntimeException'; + $doc = $this->createDocument($content); + + $violations = new ExceptionNameSniff()->process($doc, new File('file.xml', $content)); + + self::assertCount(1, $violations); + self::assertSame('RuntimeException', $violations[0]->content); + } } diff --git a/tests/Unit/Sniff/SimparaSniffTest.php b/tests/Unit/Sniff/SimparaSniffTest.php index 036d374..5542d00 100644 --- a/tests/Unit/Sniff/SimparaSniffTest.php +++ b/tests/Unit/Sniff/SimparaSniffTest.php @@ -4,9 +4,12 @@ namespace DocbookCS\Tests\Unit\Sniff; -use DocbookCS\Report\Violation; use DocbookCS\Runner\EntityExpansionMarker; use DocbookCS\Sniff\SimparaSniff; +use DocbookCS\Source\File; +use DocbookCS\Source\Line; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -17,6 +20,9 @@ CoversClass(Violation::class), // UsesClass(EntityExpansionMarker::class), + UsesClass(File::class), + UsesClass(Line::class), + UsesClass(SourceRange::class), ] final class SimparaSniffTest extends TestCase { @@ -31,29 +37,36 @@ private function createDocument(string $xml): \DOMDocument #[Test] public function itFlagsPlainTextPara(): void { - $doc = $this->createDocument('Text'); + $doc = $this->createDocument($content = + 'Text' + ); + + $violations = new SimparaSniff()->process($doc, new File('file.xml', $content)); - self::assertCount(1, new SimparaSniff()->process($doc, '', 'file.xml')); + self::assertCount(1, $violations); + self::assertSame('Text', $violations[0]->content); + self::assertSame((int) strpos($content, ''), $violations[0]->beginOffset); + self::assertSame((int) strpos($content, '') + strlen(''), $violations[0]->untilOffset); } #[Test] public function itFlagsAllowedInlineElements(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = 'Text inline' ); - self::assertCount(1, new SimparaSniff()->process($doc, '', 'file.xml')); + self::assertCount(1, new SimparaSniff()->process($doc, new File('file.xml', $content))); } #[Test] public function itDoesNotFlagUnknownElement(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = '' ); - self::assertSame([], new SimparaSniff()->process($doc, '', 'file.xml')); + self::assertSame([], new SimparaSniff()->process($doc, new File('file.xml', $content))); } #[Test] @@ -61,7 +74,7 @@ public function itHandlesMultipleParas(): void { $sniff = new SimparaSniff(); - $doc = $this->createDocument( + $doc = $this->createDocument($content = ' Inline @@ -69,7 +82,7 @@ public function itHandlesMultipleParas(): void ' ); - $violations = $sniff->process($doc, '', 'file.xml'); + $violations = $sniff->process($doc, new File('file.xml', $content)); self::assertCount(2, $violations); } @@ -80,41 +93,62 @@ public function itSupportsAdditionalInlineElements(): void $sniff = new SimparaSniff(); $sniff->setProperty('additionalInlineElements', 'custom'); - $doc = $this->createDocument( + $doc = $this->createDocument($content = '' ); - self::assertCount(1, $sniff->process($doc, '', 'file.xml')); + self::assertCount(1, $sniff->process($doc, new File('file.xml', $content))); } #[Test] public function itDoesNotFlagWhenCustomElementNotAllowed(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = '' ); - self::assertSame([], new SimparaSniff()->process($doc, '', 'file.xml')); + self::assertSame([], new SimparaSniff()->process($doc, new File('file.xml', $content))); } #[Test] public function itReportsCorrectLineNumber(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = '' . PHP_EOL . ' Text' . PHP_EOL . '' ); - $violations = new SimparaSniff()->process($doc, '', 'file.xml'); + $violations = new SimparaSniff()->process($doc, new File('file.xml', $content)); self::assertSame(2, $violations[0]->line); } + #[Test] + public function itIgnoresTagShapedTextOutsideElementsWhenMappingSource(): void + { + $content = <<<'XML' + Declared">]> + + + CDATA]]> + Instruction?> + Source + + XML; + $doc = $this->createDocument($content); + + $violations = new SimparaSniff()->process($doc, new File('file.xml', $content)); + + self::assertCount(1, $violations); + self::assertSame('Source', $violations[0]->content); + self::assertSame(6, $violations[0]->line); + } + #[Test] public function itDoesNotFlagParaInsideFormalpara(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = ' Title @@ -123,13 +157,13 @@ public function itDoesNotFlagParaInsideFormalpara(): void ' ); - self::assertSame([], new SimparaSniff()->process($doc, '', 'file.xml')); + self::assertSame([], new SimparaSniff()->process($doc, new File('file.xml', $content))); } #[Test] public function itDoesNotFlagParaWithInlineContentInsideFormalpara(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = ' Title @@ -138,13 +172,13 @@ public function itDoesNotFlagParaWithInlineContentInsideFormalpara(): void ' ); - self::assertSame([], new SimparaSniff()->process($doc, '', 'file.xml')); + self::assertSame([], new SimparaSniff()->process($doc, new File('file.xml', $content))); } #[Test] public function itStillFlagsParasOutsideFormalparaWhenSiblingIsFormalpara(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = ' Title @@ -154,7 +188,7 @@ public function itStillFlagsParasOutsideFormalparaWhenSiblingIsFormalpara(): voi ' ); - $violations = new SimparaSniff()->process($doc, '', 'file.xml'); + $violations = new SimparaSniff()->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); } @@ -162,7 +196,7 @@ public function itStillFlagsParasOutsideFormalparaWhenSiblingIsFormalpara(): voi #[Test] public function itDoesNotFlagParaInFormalparaRegardlessOfCase(): void { - $doc = $this->createDocument( + $doc = $this->createDocument($content = ' Title @@ -171,6 +205,6 @@ public function itDoesNotFlagParaInFormalparaRegardlessOfCase(): void ' ); - self::assertSame([], new SimparaSniff()->process($doc, '', 'file.xml')); + self::assertSame([], new SimparaSniff()->process($doc, new File('file.xml', $content))); } } diff --git a/tests/Unit/Sniff/WhitespaceConcernSniffsTest.php b/tests/Unit/Sniff/WhitespaceConcernSniffsTest.php new file mode 100644 index 0000000..a1937e7 --- /dev/null +++ b/tests/Unit/Sniff/WhitespaceConcernSniffsTest.php @@ -0,0 +1,88 @@ + \r\n"; + $violations = new TrailingWhitespaceSniff()->process( + $this->createDocument($content), + new File('file.xml', $content), + ); + + self::assertCount(1, $violations); + self::assertSame('DocbookCS.TrailingWhitespace', $violations[0]->sniffCode); + self::assertSame('Trailing whitespace detected.', $violations[0]->message); + self::assertSame(' ', $violations[0]->content); + self::assertSame(strlen(''), $violations[0]->beginOffset); + self::assertSame(strlen(' '), $violations[0]->untilOffset); + self::assertSame(1, $violations[0]->line); + } + + #[Test] + public function itReportsOnlyMixedLeadingIndentationAsAffected(): void + { + $content = "\n \t\n"; + $lineOffset = strlen("\n"); + $violations = new MixedIndentationSniff()->process( + $this->createDocument($content), + new File('file.xml', $content), + ); + + self::assertCount(1, $violations); + self::assertSame('DocbookCS.MixedIndentation', $violations[0]->sniffCode); + self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message); + self::assertSame(" \t", $violations[0]->content); + self::assertSame($lineOffset, $violations[0]->beginOffset); + self::assertSame($lineOffset + 2, $violations[0]->untilOffset); + self::assertSame(2, $violations[0]->line); + } + + #[Test] + public function itReportsDisjointConcernsOnTheSameLine(): void + { + $content = "\n \t \n"; + $document = $this->createDocument($content); + + $source = new File('file.xml', $content); + $indentation = new MixedIndentationSniff()->process($document, $source)[0]; + $trailing = new TrailingWhitespaceSniff()->process($document, $source)[0]; + + self::assertSame(2, $indentation->line); + self::assertSame(2, $trailing->line); + self::assertLessThanOrEqual($trailing->beginOffset, $indentation->untilOffset); + } + + private function createDocument(string $xml): \DOMDocument + { + $document = new \DOMDocument(); + $document->loadXML($xml); + + return $document; + } +} diff --git a/tests/Unit/Sniff/WhitespaceSniffTest.php b/tests/Unit/Sniff/WhitespaceSniffTest.php index 54e5583..e13fc34 100644 --- a/tests/Unit/Sniff/WhitespaceSniffTest.php +++ b/tests/Unit/Sniff/WhitespaceSniffTest.php @@ -4,15 +4,21 @@ namespace DocbookCS\Tests\Unit\Sniff; -use DocbookCS\Report\Violation; use DocbookCS\Sniff\WhitespaceSniff; +use DocbookCS\Source\File; +use DocbookCS\Violation\SourceRange; +use DocbookCS\Violation\Violation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[ CoversClass(Violation::class), CoversClass(WhitespaceSniff::class), + // + UsesClass(File::class), + UsesClass(SourceRange::class), ] final class WhitespaceSniffTest extends TestCase { @@ -32,7 +38,7 @@ public function itReturnsEmptyWhenNoViolations(): void ""; $doc = $this->createDocument($content); - $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + $violations = (new WhitespaceSniff())->process($doc, new File('file.xml', $content)); self::assertSame([], $violations); } @@ -44,11 +50,14 @@ public function itDetectsTrailingWhitespace(): void ""; $doc = $this->createDocument($content); - $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + $violations = (new WhitespaceSniff())->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertSame('Trailing whitespace detected.', $violations[0]->message); self::assertSame(1, $violations[0]->line); + self::assertSame(' ', $violations[0]->content); + self::assertSame(0, $violations[0]->beginOffset); + self::assertSame(7, $violations[0]->untilOffset); } #[Test] @@ -59,11 +68,14 @@ public function itDetectsSpaceBeforeTab(): void ""; $doc = $this->createDocument($content); - $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + $violations = (new WhitespaceSniff())->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message); self::assertSame(2, $violations[0]->line); + self::assertSame(" \t", $violations[0]->content); + self::assertSame(strlen("" . PHP_EOL), $violations[0]->beginOffset); + self::assertSame(strlen("" . PHP_EOL . " \t"), $violations[0]->untilOffset); } #[Test] @@ -74,7 +86,7 @@ public function itDetectsMixedIndentationSpacesThenTabs(): void ""; $doc = $this->createDocument($content); - $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + $violations = (new WhitespaceSniff())->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message); @@ -88,7 +100,7 @@ public function itDetectsMixedIndentationTabsThenSpaces(): void ""; $doc = $this->createDocument($content); - $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + $violations = (new WhitespaceSniff())->process($doc, new File('file.xml', $content)); self::assertCount(1, $violations); self::assertSame('Mixed tabs and spaces in indentation.', $violations[0]->message); @@ -103,7 +115,7 @@ public function itHandlesMultipleViolations(): void ""; $doc = $this->createDocument($content); - $violations = (new WhitespaceSniff())->process($doc, $content, 'file.xml'); + $violations = (new WhitespaceSniff())->process($doc, new File('file.xml', $content)); self::assertCount(3, $violations); } @@ -115,7 +127,7 @@ public function itIncludesFilePathInViolation(): void ""; $doc = $this->createDocument($content); - $violations = (new WhitespaceSniff())->process($doc, $content, 'my-file.xml'); + $violations = (new WhitespaceSniff())->process($doc, new File('my-file.xml', $content)); self::assertCount(1, $violations); self::assertSame('my-file.xml', $violations[0]->filePath); diff --git a/tests/Unit/Source/FileTest.php b/tests/Unit/Source/FileTest.php new file mode 100644 index 0000000..7cca8e3 --- /dev/null +++ b/tests/Unit/Source/FileTest.php @@ -0,0 +1,96 @@ +lines()); + + self::assertEquals([ + new Line(1, 'one', "\r\n", 0), + new Line(2, 'two', "\n", 5), + new Line(3, 'three', "\r", 9), + new Line(4, 'four', '', 15), + ], $lines); + self::assertSame([3, 8, 14, 19], array_map( + static fn(Line $line): int => $line->offsetAfterContent(), + $lines, + )); + self::assertSame([5, 9, 15, 19], array_map( + static fn(Line $line): int => $line->offsetAfterLine(), + $lines, + )); + } + + #[Test] + public function itResolvesOffsetsToLineNumbers(): void + { + $file = new File('file.xml', "one\r\ntwo\nthree"); + + self::assertSame(1, $file->lineNumberAtOffset(0)); + self::assertSame(1, $file->lineNumberAtOffset(3)); + self::assertSame(1, $file->lineNumberAtOffset(4)); + self::assertSame(2, $file->lineNumberAtOffset(5)); + self::assertSame(2, $file->lineNumberAtOffset(8)); + self::assertSame(3, $file->lineNumberAtOffset(9)); + self::assertSame(3, $file->lineNumberAtOffset(14)); + } + + #[Test] + public function itIncludesAnEmptyLineAfterTheFinalLineEnding(): void + { + $file = new File('file.xml', "one\n"); + + self::assertEquals([ + new Line(1, 'one', "\n", 0), + new Line(2, '', '', 4), + ], iterator_to_array($file->lines())); + + self::assertSame(2, $file->lineNumberAtOffset(4)); + } + + #[Test] + public function itCreatesANewRevisionWithoutChangingTheCurrentRevision(): void + { + $initialFile = new File('file.xml', ''); + + $modifiedFile = $initialFile->withContent(''); + + self::assertSame('', $initialFile->content); + self::assertSame('file.xml', $modifiedFile->path); + self::assertSame('', $modifiedFile->content); + } + + #[Test] + public function itReusesTheCurrentRevisionForUnchangedContent(): void + { + $file = new File('file.xml', ''); + + self::assertSame($file, $file->withContent('')); + } + + #[Test] + public function itRejectsOffsetsOutsideTheSource(): void + { + $file = new File('file.xml', 'one'); + + $this->expectException(\OutOfBoundsException::class); + $file->lineNumberAtOffset(4); + } +} diff --git a/tests/Unit/Violation/AffectedRangesTest.php b/tests/Unit/Violation/AffectedRangesTest.php new file mode 100644 index 0000000..2fca9c0 --- /dev/null +++ b/tests/Unit/Violation/AffectedRangesTest.php @@ -0,0 +1,50 @@ +affectedRanges); + } + + #[Test] + public function relatedRangesDoNotChangeTheExistingFinding(): void + { + $ranges = [ + new SourceRange(3, 11, 15), + new SourceRange(8, 40, 44), + ]; + $violation = new Violation( + sniffCode: 'Test', + filePath: 'file.xml', + line: 3, + beginOffset: 10, + untilOffset: 45, + message: 'Message', + content: 'content', + affectedRanges: $ranges, + ); + + self::assertSame(10, $violation->beginOffset); + self::assertSame(45, $violation->untilOffset); + self::assertSame('content', $violation->content); + self::assertSame($ranges, $violation->affectedRanges); + } +}