Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 111 additions & 41 deletions src/Analyser/RuleErrorTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\CloningVisitor;
use PhpParser\Parser;
use PhpParser\Token;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\File\FileReader;
Expand All @@ -27,6 +28,9 @@
use PHPStan\ShouldNotHappenException;
use SebastianBergmann\Diff\Differ;
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
use function array_key_exists;
use function array_shift;
use function count;
use function get_class;
use function hash;
use function str_contains;
Expand All @@ -36,8 +40,18 @@
final class RuleErrorTransformer
{

private const PARSED_FILES_LIMIT = 4;

private const PRINTERS_LIMIT = 4;

private Differ $differ;

/** @var array<string, array{code: string, hash: string, tokens: Token[], indent: string|null}> */
private array $parsedFiles = [];

/** @var array<string, PhpPrinter> */
private array $printers = [];

public function __construct(
#[AutowiredParameter(ref: '@currentPhpVersionPhpParser')]
private Parser $parser,
Expand Down Expand Up @@ -111,48 +125,13 @@ public function transform(
if ($ruleError->getOriginalNode() instanceof VirtualNode) {
throw new ShouldNotHappenException('Cannot fix virtual node');
}
$fixingFile = $filePath;
if ($traitFilePath !== null) {
$fixingFile = $traitFilePath;
}

$oldCode = FileReader::read($fixingFile);

$this->parser->parse($oldCode);
$hash = hash('sha256', $oldCode);
$oldTokens = $this->parser->getTokens();

$indentTraverser = new NodeTraverser();
$indentDetector = new PhpPrinterIndentationDetectorVisitor(new TokenStream($oldTokens, PhpPrinter::TAB_WIDTH));
$indentTraverser->addVisitor($indentDetector);
$indentTraverser->traverse($fileNodes);

$cloningTraverser = new NodeTraverser();
$cloningTraverser->addVisitor(new CloningVisitor());

/** @var Stmt[] $newStmts */
$newStmts = $cloningTraverser->traverse($fileNodes);

$traverser = new NodeTraverser();
$visitor = new ReplacingNodeVisitor($ruleError->getOriginalNode(), $ruleError->getNewNodeCallable());
$traverser->addVisitor($visitor);

/** @var Stmt[] $newStmts */
$newStmts = $traverser->traverse($newStmts);

if ($visitor->isFound()) {
if (str_contains($indentDetector->indentCharacter, "\t")) {
$indent = "\t";
} else {
$indent = str_repeat($indentDetector->indentCharacter, $indentDetector->indentSize);
}
$printer = new PhpPrinter(['indent' => $indent]);
$newCode = $printer->printFormatPreserving($newStmts, $fileNodes, $oldTokens);

if ($oldCode !== $newCode) {
$fixedErrorDiff = new FixedErrorDiff($hash, $this->differ->diff($oldCode, $newCode));
}
}
$fixedErrorDiff = $this->fix(
$traitFilePath ?? $filePath,
$fileNodes,
$ruleError->getOriginalNode(),
$ruleError->getNewNodeCallable(),
);
}

return new Error(
Expand All @@ -171,4 +150,95 @@ public function transform(
);
}

/**
* @param Node\Stmt[] $fileNodes
* @param callable(Node): Node $newNodeCallable
*/
private function fix(string $fixingFile, array $fileNodes, Node $originalNode, callable $newNodeCallable): ?FixedErrorDiff
{
if ($fileNodes === []) {
return null;
}

$parsedFile = $this->getParsedFile($fixingFile);

$indentDetector = null;
$visitors = [];
if ($parsedFile['indent'] === null) {
$indentDetector = new PhpPrinterIndentationDetectorVisitor(new TokenStream($parsedFile['tokens'], PhpPrinter::TAB_WIDTH));
$visitors[] = $indentDetector;
}
$visitors[] = new CloningVisitor();
$replacingVisitor = new ReplacingNodeVisitor($originalNode, $newNodeCallable);
$visitors[] = $replacingVisitor;

/** @var Stmt[] $newStmts */
$newStmts = (new NodeTraverser(...$visitors))->traverse($fileNodes);

$indent = $parsedFile['indent'];
if ($indentDetector !== null) {
if (str_contains($indentDetector->indentCharacter, "\t")) {
$indent = "\t";
} else {
$indent = str_repeat($indentDetector->indentCharacter, $indentDetector->indentSize);
}

if ($indentDetector->found) {
$this->parsedFiles[$fixingFile]['indent'] = $indent;
}
}

if (!$replacingVisitor->isFound() || $indent === null) {
return null;
}

$newCode = $this->getPrinter($indent)->printFormatPreserving($newStmts, $fileNodes, $parsedFile['tokens']);
if ($parsedFile['code'] === $newCode) {
return null;
}

return new FixedErrorDiff($parsedFile['hash'], $this->differ->diff($parsedFile['code'], $newCode));
}

/**
* @return array{code: string, hash: string, tokens: Token[], indent: string|null}
*/
private function getParsedFile(string $file): array
{
if (array_key_exists($file, $this->parsedFiles)) {
return $this->parsedFiles[$file];
}

$code = FileReader::read($file);
$this->parser->parse($code);

if (count($this->parsedFiles) >= self::PARSED_FILES_LIMIT) {
array_shift($this->parsedFiles);
}

return $this->parsedFiles[$file] = [
'code' => $code,
'hash' => hash('sha256', $code),
'tokens' => $this->parser->getTokens(),
'indent' => null,
];
}

/**
* Printers are reused because PhpPrinter memoizes several sizeable lookup tables
* the first time printFormatPreserving() is called on it.
*/
private function getPrinter(string $indent): PhpPrinter
{
if (array_key_exists($indent, $this->printers)) {
return $this->printers[$indent];
}

if (count($this->printers) >= self::PRINTERS_LIMIT) {
array_shift($this->printers);
}

return $this->printers[$indent] = new PhpPrinter(['indent' => $indent]);
}

}
14 changes: 11 additions & 3 deletions src/Fixable/PhpPrinterIndentationDetectorVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use Override;
use PhpParser\Internal\TokenStream;
use PhpParser\Node;
use PhpParser\NodeVisitor;
use PhpParser\NodeVisitorAbstract;
use function count;
use function in_array;
Expand All @@ -23,13 +22,23 @@ final class PhpPrinterIndentationDetectorVisitor extends NodeVisitorAbstract

public int $indentSize = 4;

public bool $found = false;

public function __construct(private TokenStream $origTokens)
{
}

/**
* Does not stop the traversal once the indentation is detected so that this visitor
* can share a single traversal with other visitors. Subsequent nodes are skipped instead.
*/
#[Override]
public function enterNode(Node $node): ?int
{
if ($this->found) {
return null;
}

if ($node instanceof Node\Stmt\Namespace_ || $node instanceof Node\Stmt\Declare_) {
return null;
}
Expand Down Expand Up @@ -74,8 +83,7 @@ public function enterNode(Node $node): ?int

$this->indentCharacter = $char;
$this->indentSize = $size;

return NodeVisitor::STOP_TRAVERSAL;
$this->found = true;
}

return null;
Expand Down
11 changes: 10 additions & 1 deletion src/Fixable/ReplacingNodeVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@ public function __construct(private Node $originalNode, private $newNodeCallable
{
}

/**
* The replacement happens in leaveNode() so that the callable receives a node
* whose children have already been cloned by CloningVisitor. That makes it
* possible to run both visitors in a single traversal.
*/
#[Override]
public function enterNode(Node $node): ?Node
public function leaveNode(Node $node): ?Node
{
if ($this->found) {
return null;
}

$origNode = $node->getAttribute('origNode');
if ($origNode !== $this->originalNode) {
return null;
Expand Down
Loading
Loading