Skip to content

Commit

Permalink
Added FixerBlame tool and one test
Browse files Browse the repository at this point in the history
  • Loading branch information
connorhu committed Mar 5, 2024
1 parent 8794475 commit 6203087
Show file tree
Hide file tree
Showing 9 changed files with 551 additions and 3 deletions.
23 changes: 23 additions & 0 deletions src/ExperimentalLineNumberTool/CodeChange.php
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* 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;
}
312 changes: 312 additions & 0 deletions src/ExperimentalLineNumberTool/FixerBlame.php
@@ -0,0 +1,312 @@
<?php

declare(strict_types=1);

/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* 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<array{
* fixerName: string,
* source: string
* }>
*/
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<FixerChange>
*/
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<CodeChange>
*/
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<CodeChange> $diffs
*
* @return array<PatchInfo>
*/
private function findPatches(array $diffs): array
{
/** @var array<PatchInfo> $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;
}
}
34 changes: 34 additions & 0 deletions src/ExperimentalLineNumberTool/FixerChange.php
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* 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;
}
}

0 comments on commit 6203087

Please sign in to comment.