Skip to content
Open
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
24 changes: 22 additions & 2 deletions src/Application/ApplicationFileProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PHPStan\Parser\ParserErrorsException;
use Rector\Application\Provider\CurrentFileProvider;
use Rector\Caching\Detector\ChangedFilesDetector;
use Rector\Caching\DryRunDiffCache;
use Rector\Configuration\Option;
use Rector\Configuration\Parameter\SimpleParameterProvider;
use Rector\FileSystem\FilesFinder;
Expand Down Expand Up @@ -49,6 +50,7 @@ public function __construct(
private readonly FileProcessor $fileProcessor,
private readonly ArrayParametersMerger $arrayParametersMerger,
private readonly MissConfigurationReporter $missConfigurationReporter,
private readonly DryRunDiffCache $dryRunDiffCache,
) {
}

Expand Down Expand Up @@ -168,12 +170,30 @@ private function processFile(File $file, Configuration $configuration): FileProc
{
$this->currentFileProvider->setFile($file);

// a selective run applies a subset of rules, so its results are not
// interchangeable with full runs → bypass both caches entirely
$isSelectiveRun = $configuration->getOnlyRule() !== null || $configuration->getOnlySuffix() !== null;
$useDiffCache = $configuration->isDryRun() && ! $isSelectiveRun;

if ($useDiffCache) {
$cachedFileProcessResult = $this->dryRunDiffCache->load($file, $configuration);
if ($cachedFileProcessResult instanceof FileProcessResult) {
return $cachedFileProcessResult;
}
}

$fileProcessResult = $this->fileProcessor->processFile($file, $configuration);
$fileDiff = $fileProcessResult->getFileDiff();

if ($fileProcessResult->getSystemErrors() !== []) {
$this->changedFilesDetector->invalidateFile($file->getFilePath());
} elseif (! $configuration->isDryRun() || ! $fileProcessResult->getFileDiff() instanceof FileDiff) {
$this->changedFilesDetector->cacheFile($file->getFilePath());
} elseif (! $configuration->isDryRun() || ! $fileDiff instanceof FileDiff) {
// a file clean under a subset of rules is not necessarily clean under all rules
if (! $isSelectiveRun) {
$this->changedFilesDetector->cacheFile($file->getFilePath());
}
} elseif ($useDiffCache) {
$this->dryRunDiffCache->save($file, $configuration, $fileDiff, $fileProcessResult->hasChanged());
}

return $fileProcessResult;
Expand Down
54 changes: 41 additions & 13 deletions src/Caching/Detector/ChangedFilesDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Rector\Caching\Cache;
use Rector\Caching\Config\FileHashComputer;
use Rector\Caching\Enum\CacheKey;
use Rector\Caching\FileDependencyCollector;
use Rector\Util\FileHasher;

/**
Expand All @@ -24,7 +25,8 @@ final class ChangedFilesDetector
public function __construct(
private readonly FileHashComputer $fileHashComputer,
private readonly Cache $cache,
private readonly FileHasher $fileHasher
private readonly FileHasher $fileHasher,
private readonly FileDependencyCollector $fileDependencyCollector
) {
}

Expand All @@ -36,9 +38,26 @@ public function cacheFile(string $filePath): void
return;
}

$hash = $this->hashFile($filePath);
// a failed capture means a possibly incomplete set, skip caching so the file is reprocessed
$dependencyHashes = $this->fileDependencyCollector->getDependencyFileHashes($filePath);
if ($dependencyHashes === null) {
return;
}

$this->cache->save($filePathCacheKey, CacheKey::FILE_HASH_KEY, $hash);
// the file may have just been written, recompute its hash fresh
// rather than trusting a memo entry from the pre-write filter pass
$this->fileDependencyCollector->forgetContentHash($filePath);
$ownHash = $this->fileDependencyCollector->contentHash($filePath);
if ($ownHash === null) {
return;
}

// store the own content hash plus one per dependency, so a dependency change
// invalidates this file even when its own content is unchanged
$this->cache->save($filePathCacheKey, CacheKey::FILE_HASH_KEY, [
'hash' => $ownHash,
'deps' => $dependencyHashes,
]);
}

public function addCacheableFile(string $filePath): void
Expand All @@ -52,13 +71,27 @@ public function hasFileChanged(string $filePath): bool
$fileInfoCacheKey = $this->getFilePathCacheKey($filePath);
$cachedValue = $this->cache->load($fileInfoCacheKey, CacheKey::FILE_HASH_KEY);

if ($cachedValue !== null) {
$currentFileHash = $this->hashFile($filePath);
return $currentFileHash !== $cachedValue;
// no value to compare against → be defensive and assume changed
if ($cachedValue === null) {
return true;
}

// legacy string entry → own-hash comparison only, rewritten in the new format on next cacheFile()
if (is_string($cachedValue)) {
return $this->fileDependencyCollector->contentHash($filePath) !== $cachedValue;
}

// we don't have a value to compare against. Be defensive and assume its changed
return true;
if (! is_array($cachedValue)) {
return true;
}

// own content changed
if (($cachedValue['hash'] ?? null) !== $this->fileDependencyCollector->contentHash($filePath)) {
return true;
}

// any recorded dependency changed
return $this->fileDependencyCollector->hasAnyChangedDependency($cachedValue['deps'] ?? []);
}

public function invalidateFile(string $filePath): void
Expand Down Expand Up @@ -98,11 +131,6 @@ private function getFilePathCacheKey(string $filePath): string
return $this->fileHasher->hash($this->resolvePath($filePath));
}

private function hashFile(string $filePath): string
{
return $this->fileHasher->hashFiles([$this->resolvePath($filePath)]);
}

private function storeConfigurationDataHash(string $filePath, string $configurationHash): void
{
$key = CacheKey::CONFIGURATION_HASH_KEY . '_' . $this->getFilePathCacheKey($filePath);
Expand Down
115 changes: 115 additions & 0 deletions src/Caching/DryRunDiffCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace Rector\Caching;

use Rector\Caching\Enum\CacheKey;
use Rector\ChangesReporting\ValueObject\RectorWithLineChange;
use Rector\Configuration\Parameter\SimpleParameterProvider;
use Rector\Parallel\ValueObject\BridgeItem;
use Rector\Util\FileHasher;
use Rector\ValueObject\Application\File;
use Rector\ValueObject\Configuration;
use Rector\ValueObject\FileProcessResult;
use Rector\ValueObject\Reporting\FileDiff;

/**
* Caches dry-run FileDiffs. Files with a pending diff are never marked clean, as a
* dry-run must keep reporting them, so they are fully reprocessed on every run. When
* the file and all its captured dependencies are unchanged, the cached diff is
* replayed instead, skipping the whole pipeline including PHPStan scope resolution.
*
* @see \Rector\Tests\Caching\DryRunDiffCacheTest
*/
final class DryRunDiffCache
{
/**
* memoized: hashing serializes the whole parameter bag, and this runs per file
*/
private ?string $parameterHash = null;

public function __construct(
private readonly Cache $cache,
private readonly FileHasher $fileHasher,
private readonly FileDependencyCollector $fileDependencyCollector,
) {
}

public function load(File $file, Configuration $configuration): ?FileProcessResult
{
$cached = $this->cache->load($this->key($file), CacheKey::FILE_DIFF_KEY);
if (! is_array($cached)) {
return null;
}

// own content + config must match
if (($cached['hash'] ?? null) !== $this->contentHash($file, $configuration)) {
return null;
}

// every dependency captured last run must still hash to the same value
$cachedDependencyHashes = $cached['deps'] ?? null;
if (! is_array($cachedDependencyHashes)) {
return null;
}

if ($this->fileDependencyCollector->hasAnyChangedDependency($cachedDependencyHashes)) {
return null;
}

$diffJson = $cached['diff'] ?? null;
if (! is_array($diffJson)) {
return null;
}

// a rule can report line changes while printing identical content, so a diff
// does not imply a changed file → replay the original flag or warm runs
// would report phantom changed files
$hasChanged = $cached['changed'] ?? null;
if (! is_bool($hasChanged)) {
return null;
}

return new FileProcessResult([], FileDiff::decode($diffJson), $hasChanged);
}

public function save(File $file, Configuration $configuration, FileDiff $fileDiff, bool $hasChanged): void
{
// a failed capture means a possibly incomplete set, skip caching so the file is reprocessed
$dependencyHashes = $this->fileDependencyCollector->getDependencyFileHashes($file->getFilePath());
if ($dependencyHashes === null) {
return;
}

$diffJson = $fileDiff->jsonSerialize();
// decompose objects to plain arrays so the var_export-based file cache can round-trip them
$diffJson[BridgeItem::RECTORS_WITH_LINE_CHANGES] = array_map(
static fn (RectorWithLineChange $rectorWithLineChange): array => $rectorWithLineChange->jsonSerialize(),
$diffJson[BridgeItem::RECTORS_WITH_LINE_CHANGES]
);

$this->cache->save($this->key($file), CacheKey::FILE_DIFF_KEY, [
'hash' => $this->contentHash($file, $configuration),
'deps' => $dependencyHashes,
'diff' => $diffJson,
'changed' => $hasChanged,
]);
}

private function key(File $file): string
{
return 'diff_' . $this->fileHasher->hash($file->getFilePath());
}

private function contentHash(File $file, Configuration $configuration): string
{
// --no-diffs changes the produced FileDiff content but is not part of the
// parameter hash, include it so entries do not cross-replay
$this->parameterHash ??= SimpleParameterProvider::hash();

return $this->fileHasher->hash($file->getOriginalFileContent())
. '_' . $this->parameterHash
. ($configuration->shouldShowDiffs() ? '' : '_no-diffs');
}
}
2 changes: 2 additions & 0 deletions src/Caching/Enum/CacheKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ final class CacheKey
public const string CONFIGURATION_HASH_KEY = 'configuration_hash';

public const string FILE_HASH_KEY = 'file_hash';

public const string FILE_DIFF_KEY = 'file_diff';
}
Loading
Loading