diff --git a/src/ExperimentalLineNumberTool/CodeChange.php b/src/ExperimentalLineNumberTool/CodeChange.php new file mode 100644 index 00000000000..f5f92016aaf --- /dev/null +++ b/src/ExperimentalLineNumberTool/CodeChange.php @@ -0,0 +1,23 @@ + + * Dariusz Rumiński + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace PhpCsFixer\ExperimentalLineNumberTool; + +final class CodeChange +{ + public string $content; + public int $change; + public ?int $newLineNumber = null; + public ?int $oldLineNumber = null; +} diff --git a/src/ExperimentalLineNumberTool/FixerBlame.php b/src/ExperimentalLineNumberTool/FixerBlame.php new file mode 100644 index 00000000000..0d4218a50d4 --- /dev/null +++ b/src/ExperimentalLineNumberTool/FixerBlame.php @@ -0,0 +1,312 @@ + + * Dariusz Rumiński + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace PhpCsFixer\ExperimentalLineNumberTool; + +use PhpCsFixer\AbstractFixer; +use PhpCsFixer\Tokenizer\Tokens; +use SebastianBergmann\Diff\Differ; +use SebastianBergmann\Diff\Output\StrictUnifiedDiffOutputBuilder; + +final class FixerBlame +{ + private Differ $differ; + + /** + * @var array + */ + private array $changeStack = []; + + public function __construct() + { + $this->differ = new Differ(new StrictUnifiedDiffOutputBuilder([ + 'collapseRanges' => true, + 'commonLineThreshold' => 1, + 'contextLines' => 1, + 'fromFile' => 'Original', + 'toFile' => 'New', + ])); + } + + public function originalCode($code): void + { + if ($code instanceof Tokens) { + $code = $code->generateCode(); + } + + $this->changeStack[] = [ + 'fixerName' => '__initial__', + 'source' => $code, + ]; + } + + public function snapshotCode(string $fixerName, string $source): void + { + $this->changeStack[] = [ + 'fixerName' => $fixerName, + 'source' => $source, + ]; + } + + public function snapshotTokens(AbstractFixer $fixer, Tokens $tokens): void + { + $this->changeStack[] = [ + 'fixerName' => $fixer->getName(), + 'source' => $tokens->generateCode(), + ]; + } + + /** + * @return array + */ + public function calculateChanges(): array + { + $changes = []; + + foreach ($this->changeStack as $changeIndex => $change) { + if (0 === $changeIndex) { + continue; + } + + $oldChangeContent = $this->changeStack[$changeIndex - 1]['source']; + $newChangeContent = $change['source']; + + $fixerName = $change['fixerName']; + + $diffResults = $this->diff($oldChangeContent, $newChangeContent); + $patches = $this->findPatches($diffResults); + + foreach ($patches as $patchInfo) { + $patchContent = $patchInfo->getPatchContent($diffResults); + + $numberOfChanges = \count($patchContent); + + // simple remove + if (1 === $numberOfChanges && Differ::REMOVED === $patchContent[0]->change) { + $changes[] = [ + 'fixerName' => $fixerName, + 'start' => $patchContent[0]->oldLineNumber, + 'changedSum' => $patchInfo->getChangeSum(), + 'changedAt' => 0, + ]; + + continue; + } + + // line changed + if (2 === $numberOfChanges && Differ::REMOVED === $patchContent[0]->change && Differ::ADDED === $patchContent[1]->change) { + $addedLine = $patchContent[1]->content; + $removedLine = $patchContent[0]->content; + + $changedAt = null; + + for ($i = 0; $i < min(\strlen($addedLine), \strlen($removedLine)); ++$i) { + if ($addedLine[$i] !== $removedLine[$i]) { + $changedAt = $i + 1; + + break; + } + } + + $changes[] = [ + 'fixerName' => $fixerName, + 'start' => $patchContent[0]->oldLineNumber, + 'changedSum' => $patchInfo->getChangeSum(), + 'changedAt' => $changedAt ?? \strlen($removedLine) + 1, + ]; + + continue; + } + + $onlyRemove = 0x1; + $onlyAdd = 0x1; + + foreach ($patchContent as $patchRow) { + if (Differ::ADDED === $patchRow->change) { + $onlyAdd &= 0x1; + } else { + $onlyAdd &= 0; + } + + if (Differ::REMOVED === $patchRow->change) { + $onlyRemove &= 0x1; + } else { + $onlyRemove &= 0; + } + } + + if (1 === $onlyAdd xor 1 === $onlyRemove) { + if (1 === $onlyAdd) { + $lineNumber = $patchContent[0]->newLineNumber; + } else { + $lineNumber = $patchContent[0]->oldLineNumber; + } + + $changes[] = [ + 'fixerName' => $fixerName, + 'start' => $lineNumber, + 'changedSum' => $patchInfo->getChangeSum(), + 'changedAt' => 0, + ]; + + continue; + } + if (Differ::ADDED === $patchContent[0]->change) { + throw new \RuntimeException('added lines first?'); + } + + $changes[] = [ + 'fixerName' => $fixerName, + 'start' => $patchContent[0]->oldLineNumber, + 'changedSum' => $patchInfo->getChangeSum(), + 'changedAt' => 0, + ]; + + continue; + + throw new \RuntimeException('unhandled case'); + } + } + + $changeSet = []; + foreach ($changes as $index => $change) { + $lineChanges = 0; + for ($i = $index - 1; $i >= 0; --$i) { + if ($changes[$i]['start'] >= $change['start']) { + continue; + } + + $lineChanges -= $changes[$i]['changedSum']; + } + + $changeSet[] = new FixerChange($change['fixerName'], $change['start'] + $lineChanges, $change['changedAt']); + } + + return $changeSet; + } + + /** + * @return array + */ + private function diff(string $oldCode, string $newCode): array + { + $diffResults = $this->differ->diffToArray($oldCode, $newCode); + + $linePointerInOldContent = 1; + $linePointerInNewContent = 1; + + $buffer = []; + foreach ($diffResults as $diffResult) { + if (Differ::ADDED === $diffResult[1]) { + $diff = new CodeChange(); + $diff->content = $diffResult[0]; + $diff->change = Differ::ADDED; + $diff->newLineNumber = $linePointerInNewContent++; + + $buffer[] = $diff; + + continue; + } + + if (Differ::REMOVED === $diffResult[1]) { + $diff = new CodeChange(); + $diff->content = $diffResult[0]; + $diff->change = Differ::REMOVED; + $diff->oldLineNumber = $linePointerInOldContent++; + + $buffer[] = $diff; + + continue; + } + + $diff = new CodeChange(); + $diff->content = $diffResult[0]; + $diff->change = Differ::OLD; + $diff->newLineNumber = $linePointerInNewContent++; + $diff->oldLineNumber = $linePointerInOldContent++; + + $buffer[] = $diff; + } + + return $buffer; + } + + /** + * @param array $diffs + * + * @return array + */ + private function findPatches(array $diffs): array + { + /** @var array $patches */ + $patches = []; + $patchInfo = null; + $state = 'file_start'; + + foreach ($diffs as $key => $diffResult) { + if ('file_start' === $state) { + if (Differ::OLD === $diffResult->change) { + $state = 'between_patch'; + + continue; + } + + if (Differ::ADDED === $diffResult->change || Differ::REMOVED === $diffResult->change) { + $patchInfo = new PatchInfo(); + $patchInfo->startKey = $key; + $patchInfo->countChange($diffResult->change); + + $state = 'in_patch'; + + continue; + } + } + + if ('between_patch' === $state && (Differ::ADDED === $diffResult->change || Differ::REMOVED === $diffResult->change)) { + $patchInfo = new PatchInfo(); + $patchInfo->startKey = $key; + $patchInfo->countChange($diffResult->change); + + $state = 'in_patch'; + + continue; + } + + if ('in_patch' === $state && Differ::OLD === $diffResult->change) { + $state = 'between_patch'; + + $patchInfo->endKey = $key; + $patches[] = $patchInfo; + $patchInfo = null; + + continue; + } + + if ('in_patch' === $state) { + $patchInfo->countChange($diffResult->change); + } + } + + if ('in_patch' === $state) { + $patchInfo->endKey = \count($diffs) - 1; + $patches[] = $patchInfo; + $patchInfo = null; + } + + return $patches; + } +} diff --git a/src/ExperimentalLineNumberTool/FixerChange.php b/src/ExperimentalLineNumberTool/FixerChange.php new file mode 100644 index 00000000000..3565781653d --- /dev/null +++ b/src/ExperimentalLineNumberTool/FixerChange.php @@ -0,0 +1,34 @@ + + * Dariusz Rumiński + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace PhpCsFixer\ExperimentalLineNumberTool; + +final class FixerChange +{ + public string $fixerName; + + public int $line; + + public int $char = 0; + + public function __construct( + string $fixerName, + int $line, + int $char = 0 + ) { + $this->fixerName = $fixerName; + $this->line = $line; + $this->char = $char; + } +} diff --git a/src/ExperimentalLineNumberTool/PatchInfo.php b/src/ExperimentalLineNumberTool/PatchInfo.php new file mode 100644 index 00000000000..c3447977f3b --- /dev/null +++ b/src/ExperimentalLineNumberTool/PatchInfo.php @@ -0,0 +1,55 @@ + + * Dariusz Rumiński + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace PhpCsFixer\ExperimentalLineNumberTool; + +use SebastianBergmann\Diff\Differ; + +final class PatchInfo +{ + public int $startKey; + public int $endKey; + public int $linesAdded = 0; + public int $linesRemoved = 0; + + public function countChange(int $changeType): void + { + if (Differ::ADDED === $changeType) { + ++$this->linesAdded; + } + + if (Differ::REMOVED === $changeType) { + ++$this->linesRemoved; + } + } + + /** + * @param array $diffResults + * + * @return array + */ + public function getPatchContent(array $diffResults): array + { + if ($this->startKey === $this->endKey) { + return [$diffResults[$this->startKey]]; + } + + return \array_slice($diffResults, $this->startKey, $this->endKey - $this->startKey); + } + + public function getChangeSum(): int + { + return $this->linesAdded - $this->linesRemoved; + } +} diff --git a/src/Runner/Runner.php b/src/Runner/Runner.php index 7a3bc822bcd..0101b8cf986 100644 --- a/src/Runner/Runner.php +++ b/src/Runner/Runner.php @@ -21,6 +21,8 @@ use PhpCsFixer\Differ\DifferInterface; use PhpCsFixer\Error\Error; use PhpCsFixer\Error\ErrorsManager; +use PhpCsFixer\ExperimentalLineNumberTool\FixerBlame; +use PhpCsFixer\ExperimentalLineNumberTool\FixerChange; use PhpCsFixer\FileReader; use PhpCsFixer\Fixer\FixerInterface; use PhpCsFixer\FixerFileProcessedEvent; @@ -63,6 +65,8 @@ final class Runner private bool $stopOnViolation; + private FixerBlame $blame; + /** * @param \Traversable<\SplFileInfo> $finder * @param list $fixers @@ -89,10 +93,11 @@ public function __construct( $this->cacheManager = $cacheManager; $this->directory = $directory ?? new Directory(''); $this->stopOnViolation = $stopOnViolation; + $this->blame = new FixerBlame(); } /** - * @return array, diff: string}> + * @return array, diff: string, blame: array}> */ public function fix(): array { @@ -160,6 +165,7 @@ private function fixFile(\SplFileInfo $file, LintingResultInterface $lintingResu $appliedFixers = []; try { + $this->blame->originalCode($tokens); foreach ($this->fixers as $fixer) { // for custom fixers we don't know is it safe to run `->fix()` without checking `->supports()` and `->isCandidate()`, // thus we need to check it and conditionally skip fixing @@ -174,6 +180,8 @@ private function fixFile(\SplFileInfo $file, LintingResultInterface $lintingResu if ($tokens->isChanged()) { $tokens->clearEmptyTokens(); + $this->blame->snapshotTokens($fixer, $tokens); + $tokens->clearChanged(); $appliedFixers[] = $fixer->getName(); } @@ -208,6 +216,7 @@ private function fixFile(\SplFileInfo $file, LintingResultInterface $lintingResu $fixInfo = [ 'appliedFixers' => $appliedFixers, 'diff' => $this->differ->diff($old, $new, $file), + 'blame' => $this->blame->calculateChanges(), ]; try { diff --git a/tests/Fixtures/Integration/misc/blame_test.test b/tests/Fixtures/Integration/misc/blame_test.test new file mode 100644 index 00000000000..0a85b3f4dbe --- /dev/null +++ b/tests/Fixtures/Integration/misc/blame_test.test @@ -0,0 +1,74 @@ +--TEST-- +Integration of fixers: Anonymous class /w PHPDoc and attributes on separate line. +--RULESET-- +{ + "@Symfony": true, + "@Symfony:risky": true, + "@PHP74Migration": true, + "@PHP74Migration:risky": true, + "@PHPUnit100Migration:risky": true, + "@PhpCsFixer": true, + "@PhpCsFixer:risky": true, + "general_phpdoc_annotation_remove": {"annotations": ["expectedDeprecation"]}, + "header_comment": {"header": "This file is part of PHP CS Fixer.\n\n(c) Fabien Potencier \n Dariusz Rumiński \n\nThis source file is subject to the MIT license that is bundled\nwith this source code in the file LICENSE."}, + "modernize_strpos": true, + "no_useless_concat_operator": true, + "numeric_literal_separator": true, + "single_line_comment_style": false +} +--EXPECT-- + + * Dariusz Rumiński + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +class MyClass +{ + public int $param; + + public function method(int $arg1 = null): void + { + if (' ' === substr('', 0, 5)) { + } + echo $arg1; + } +} + +--BLAME_EXPECT-- +line: 6 char: 0 fixer: no_superfluous_phpdoc_tags +line: 10 char: 45 fixer: void_return +line: 5 char: 0 fixer: no_empty_phpdoc +line: 1 char: 6 fixer: declare_strict_types +line: 1 char: 0 fixer: blank_line_after_opening_tag +line: 12 char: 13 fixer: yoda_style +line: 15 char: 9 fixer: no_mixed_echo_print +line: 3 char: 7 fixer: psr_autoloading +line: 13 char: 0 fixer: no_extra_blank_lines +line: 3 char: 0 fixer: header_comment +--INPUT-- + .*?))? (?:\s --REQUIREMENTS-- \r?\n(? .*?))? (?:\s --EXPECT-- \r?\n(? .*?\r?\n*))? + (?:\s --BLAME_EXPECT-- \r?\n(? .*?))? (?:\s --INPUT-- \r?\n(? .*))? $/sx', $file->getContents(), @@ -49,6 +50,7 @@ public function create(SplFileInfo $file): IntegrationCase 'settings' => null, 'requirements' => null, 'expect' => null, + 'blame_expect' => null, 'input' => null, ], $match @@ -62,7 +64,8 @@ public function create(SplFileInfo $file): IntegrationCase $this->determineConfig($file, $match['config']), $this->determineRuleset($file, $match['ruleset']), $this->determineExpectedCode($file, $match['expect']), - $this->determineInputCode($file, $match['input']) + $this->determineInputCode($file, $match['input']), + $this->determineBlameExpect($file, $match['blame_expect']) ); } catch (\InvalidArgumentException $e) { throw new \InvalidArgumentException( @@ -155,6 +158,11 @@ protected function determineTitle(SplFileInfo $file, string $config): string return $config; } + protected function determineBlameExpect(SplFileInfo $file, ?string $config): ?string + { + return '' !== $config ? $config : null; + } + /** * Parses the '--SETTINGS--' block of a '.test' file and determines settings. * diff --git a/tests/Test/AbstractIntegrationTestCase.php b/tests/Test/AbstractIntegrationTestCase.php index 1fb064d5cb3..f1515e231de 100644 --- a/tests/Test/AbstractIntegrationTestCase.php +++ b/tests/Test/AbstractIntegrationTestCase.php @@ -18,6 +18,7 @@ use PhpCsFixer\Differ\UnifiedDiffer; use PhpCsFixer\Error\Error; use PhpCsFixer\Error\ErrorsManager; +use PhpCsFixer\ExperimentalLineNumberTool\FixerChange; use PhpCsFixer\FileRemoval; use PhpCsFixer\Fixer\FixerInterface; use PhpCsFixer\FixerFactory; @@ -268,6 +269,10 @@ protected function doTest(IntegrationCase $case): void $result = $runner->fix(); $changed = array_pop($result); + if ($case->hasBlameCode()) { + self::assertSame($case->getBlameCode(), self::blameChangesArrayToString($changed['blame'])); + } + if (!$errorsManager->isEmpty()) { $errors = $errorsManager->getExceptionErrors(); self::assertEmpty($errors, sprintf('Errors reported during fixing of file "%s": %s', $case->getFileName(), $this->implodeErrors($errors))); @@ -394,4 +399,18 @@ private function getLinter(): LinterInterface return $linter; } + + /** + * @param array $changes + */ + private static function blameChangesArrayToString(array $changes): string + { + $buffer = []; + + foreach ($changes as $change) { + $buffer[] = sprintf('line: % 2d char: % 2d fixer: %s', $change->line, $change->char, $change->fixerName); + } + + return implode("\n", $buffer); + } } diff --git a/tests/Test/IntegrationCase.php b/tests/Test/IntegrationCase.php index 31bab0ce638..2252a983192 100644 --- a/tests/Test/IntegrationCase.php +++ b/tests/Test/IntegrationCase.php @@ -50,6 +50,8 @@ final class IntegrationCase private string $title; + private ?string $blameCode; + /** * @param array{checkPriority: bool, deprecations: list, isExplicitPriorityCheck?: bool} $settings * @param array{php: int, "php<": int, os: list} $requirements @@ -63,7 +65,8 @@ public function __construct( array $config, RuleSet $ruleset, string $expectedCode, - ?string $inputCode + ?string $inputCode, + ?string $blameCode = null ) { $this->fileName = $fileName; $this->title = $title; @@ -73,6 +76,7 @@ public function __construct( $this->ruleset = $ruleset; $this->expectedCode = $expectedCode; $this->inputCode = $inputCode; + $this->blameCode = $blameCode; } public function hasInputCode(): bool @@ -144,4 +148,14 @@ public function getTitle(): string { return $this->title; } + + public function hasBlameCode(): bool + { + return null !== $this->blameCode; + } + + public function getBlameCode(): ?string + { + return $this->blameCode; + } }