Skip to content
Draft
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
8 changes: 8 additions & 0 deletions build/ffi-new-not-nullable.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
parameters:
ignoreErrors:
-
# FFI::new() is typed nullable only in the PHP 8.0-8.3 stubs
message: '#^Strict comparison using \=\=\= between FFI\\CData and null will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: ../src/File/FsEventsFileMonitor.php
4 changes: 4 additions & 0 deletions build/ignore-by-php-version.neon.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
$includes[] = __DIR__ . '/deprecated-8.4.neon';
}

if (PHP_VERSION_ID < 80000 || PHP_VERSION_ID >= 80400) {
$includes[] = __DIR__ . '/ffi-new-not-nullable.neon';
}

if (PHP_VERSION_ID < 80200) {
$includes[] = __DIR__ . '/old-phpunit.neon';
} else {
Expand Down
8 changes: 8 additions & 0 deletions build/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ parameters:
- '#^Dynamic call to static method PHPUnit\\Framework\\\S+\(\)\.$#'
- '#should be contravariant with parameter \$node \(PhpParser\\Node\) of method PHPStan\\Rules\\Rule<PhpParser\\Node>::processNode\(\)$#'
- '#Variable property access on PhpParser\\Node#'
-
# FFI's methods are the C functions declared in the cdef string at
# runtime, so no reflection can know them
identifier: method.notFound
message: '#^Call to an undefined method FFI::#'
paths:
- ../src/File/FsEventsFileMonitor.php
- ../src/File/InotifyFileMonitor.php
-
identifier: shipmonk.deadMethod
message: '#^Unused .*?::__construct#' # likely used in DIC
Expand Down
24 changes: 16 additions & 8 deletions src/Command/FixerApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\File\FileMonitor;
use PHPStan\File\FileMonitorFactory;
use PHPStan\File\FileMonitorResult;
use PHPStan\File\FileReader;
use PHPStan\File\FileWriter;
Expand Down Expand Up @@ -75,7 +76,7 @@ final class FixerApplication
* @param string[] $bootstrapFiles
*/
public function __construct(
private FileMonitor $fileMonitor,
private FileMonitorFactory $fileMonitorFactory,
private IgnoredErrorHelper $ignoredErrorHelper,
private StubFilesProvider $stubFilesProvider,
#[AutowiredParameter]
Expand Down Expand Up @@ -103,6 +104,8 @@ public function __construct(
{
}

private ?FileMonitor $fileMonitor = null;

public function run(
InceptionResult $inceptionResult,
InputInterface $input,
Expand Down Expand Up @@ -154,7 +157,7 @@ public function run(
}
});

$this->fileMonitor->initialize(array_merge(
$this->fileMonitor = $this->fileMonitorFactory->create(array_merge(
$this->getComposerLocks(),
$this->getComposerInstalled(),
$this->getExecutedFiles(),
Expand Down Expand Up @@ -402,24 +405,29 @@ private function writeInfoFile(string $infoPath, string $version, string $branch
*/
private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCallback): void
{
$callback = function () use (&$callback, $loop, $hasChangesCallback): void {
if ($this->fileMonitor === null) {
throw new ShouldNotHappenException();
}
$fileMonitor = $this->fileMonitor;
$interval = $fileMonitor->getPollInterval();
$callback = function () use (&$callback, $loop, $hasChangesCallback, $fileMonitor, $interval): void {
if (!$this->fileMonitorActive) {
$loop->addTimer(1.0, $callback);
$loop->addTimer($interval, $callback);
return;
}
if ($this->processInProgress !== null) {
$loop->addTimer(1.0, $callback);
$loop->addTimer($interval, $callback);
return;
}
$changes = $this->fileMonitor->getChanges();
$changes = $fileMonitor->getChanges();

if ($changes->hasAnyChanges()) {
$hasChangesCallback($changes);
}

$loop->addTimer(1.0, $callback);
$loop->addTimer($interval, $callback);
};
$loop->addTimer(1.0, $callback);
$loop->addTimer($interval, $callback);
}

private function analyse(
Expand Down
2 changes: 1 addition & 1 deletion src/File/DirectoryWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
* walk between the two - both happen before the analysis starts, on a file set that is snapshotted
* for the whole run anyway.
*
* FileMonitor detects changes by re-running the finder and must see the filesystem as it is now,
* HashingFileMonitor detects changes by re-running the finder and must see the filesystem as it is now,
* so it walks uncached and clears the shared walks before each check.
*
* Reading the directories is what a walk costs - on a tree the size of Drupal core (28k directories)
Expand Down
159 changes: 18 additions & 141 deletions src/File/FileMonitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,151 +2,28 @@

namespace PHPStan\File;

use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\ShouldNotHappenException;
use function array_diff;
use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_unique;
use function hash_file;
use function is_dir;
use function is_file;

#[AutowiredService]
final class FileMonitor
/**
* Watches the analysed and scanned files for changes between PHPStan Pro analyses.
*
* {@see HashingFileMonitor} is the portable implementation: it re-hashes every
* monitored file on every poll. The native implementations
* ({@see FsEventsFileMonitor}, {@see InotifyFileMonitor}) wrap it and let the
* kernel answer "did anything change at all", so an idle poll touches no files;
* once the kernel says yes, they delegate to the hashing monitor so the reported
* result is identical either way.
*
* {@see FileMonitorFactory} picks the implementation for the current platform
* and hands it back initialized.
*/
interface FileMonitor
{

/** @var array<string, string>|null */
private ?array $fileHashes = null;

/** @var array<string>|null */
private ?array $filePaths = null;

/**
* @param string[] $analysedPaths
* @param string[] $analysedPathsFromConfig
* @param string[] $scanFiles
* @param string[] $scanDirectories
*/
public function __construct(
#[AutowiredParameter(ref: '@fileFinderAnalyse')]
private FileFinder $analyseFileFinder,
#[AutowiredParameter(ref: '@fileFinderScan')]
private FileFinder $scanFileFinder,
#[AutowiredParameter]
private array $analysedPaths,
#[AutowiredParameter]
private array $analysedPathsFromConfig,
#[AutowiredParameter]
private array $scanFiles,
#[AutowiredParameter]
private array $scanDirectories,
private DirectoryWalker $directoryWalker,
)
{
}
public function getChanges(): FileMonitorResult;

/**
* @param array<string> $filePaths
* How often the caller should poll. A monitor whose idle poll is free can
* afford a much shorter interval, which is what makes an edit noticed sooner.
*/
public function initialize(array $filePaths): void
{
$finderResult = $this->analyseFileFinder->findFiles($this->analysedPaths);
$fileHashes = [];
foreach (array_unique(array_merge($finderResult->getFiles(), $filePaths, $this->getScannedFiles($finderResult->getFiles()))) as $filePath) {
$fileHashes[$filePath] = $this->getFileHash($filePath);
}

$this->fileHashes = $fileHashes;
$this->filePaths = $filePaths;
}

public function getChanges(): FileMonitorResult
{
if ($this->fileHashes === null || $this->filePaths === null) {
throw new ShouldNotHappenException();
}

// Detecting a change means looking at the filesystem as it is now, so the walks shared
// with the analysis file discovery must not be reused here.
$this->directoryWalker->clearCachedWalks();
$finderResult = $this->analyseFileFinder->findFiles($this->analysedPaths);
$oldFileHashes = $this->fileHashes;
$fileHashes = [];
$newFiles = [];
$changedFiles = [];
$deletedFiles = [];
$filePaths = array_unique(array_merge($finderResult->getFiles(), $this->filePaths, $this->getScannedFiles($finderResult->getFiles())));
foreach ($filePaths as $filePath) {
if (!array_key_exists($filePath, $oldFileHashes)) {
$newFiles[] = $filePath;
$fileHashes[$filePath] = $this->getFileHash($filePath);
continue;
}

$oldHash = $oldFileHashes[$filePath];
unset($oldFileHashes[$filePath]);
$newHash = $this->getFileHash($filePath);
$fileHashes[$filePath] = $newHash;
if ($oldHash === $newHash) {
continue;
}

$changedFiles[] = $filePath;
}

$this->fileHashes = $fileHashes;

foreach (array_keys($oldFileHashes) as $file) {
$deletedFiles[] = $file;
}

return new FileMonitorResult(
$newFiles,
$changedFiles,
$deletedFiles,
);
}

private function getFileHash(string $filePath): string
{
$hash = hash_file('sha256', $filePath);

if ($hash === false) {
throw new CouldNotReadFileException($filePath);
}

return $hash;
}

/**
* @param string[] $allAnalysedFiles
* @return array<string>
*/
private function getScannedFiles(array $allAnalysedFiles): array
{
$scannedFiles = $this->scanFiles;
$analysedDirectories = [];
foreach (array_merge($this->analysedPaths, $this->analysedPathsFromConfig) as $analysedPath) {
if (is_file($analysedPath)) {
continue;
}

if (!is_dir($analysedPath)) {
continue;
}

$analysedDirectories[] = $analysedPath;
}

$directories = array_unique(array_merge($analysedDirectories, $this->scanDirectories));
foreach ($this->scanFileFinder->findFiles($directories)->getFiles() as $file) {
$scannedFiles[] = $file;
}

return array_diff($scannedFiles, $allAnalysedFiles);
}
public function getPollInterval(): float;

}
94 changes: 94 additions & 0 deletions src/File/FileMonitorFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php declare(strict_types = 1);

namespace PHPStan\File;

use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use function getenv;
use const PHP_OS_FAMILY;

/**
* Picks the {@see FileMonitor} for this platform and hands it back initialized.
*
* Whether a native monitor can actually run is only known once it has opened
* its kernel handle and armed every watch, so the fallback decision belongs
* here rather than at the call site: a native monitor that cannot promise to
* see every change is discarded whole, and {@see HashingFileMonitor} - correct
* everywhere, just slower - answers instead.
*/
#[AutowiredService]
final class FileMonitorFactory
{

/** Set to anything to force {@see HashingFileMonitor}. */
private const DISABLE_ENV_VARIABLE = 'PHPSTAN_DISABLE_NATIVE_FILE_MONITOR';

/**
* @param string[] $analysedPaths
* @param string[] $analysedPathsFromConfig
* @param string[] $scanDirectories
*/
public function __construct(
private HashingFileMonitor $hashingFileMonitor,
#[AutowiredParameter]
private array $analysedPaths,
#[AutowiredParameter]
private array $analysedPathsFromConfig,
#[AutowiredParameter]
private array $scanDirectories,
)
{
}

/**
* @param array<string> $filePaths extra files to monitor besides the analysed and scanned ones
*/
public function create(array $filePaths): FileMonitor
{
$native = $this->createNative();
if ($native !== null) {
try {
$native->initialize($filePaths);

return $native;
} catch (FileMonitorNotSupportedException) {
// fall through to hashing
}
}

$this->hashingFileMonitor->initialize($filePaths);

return $this->hashingFileMonitor;
}

private function createNative(): ?NativeFileMonitor
{
// escape hatch: the native monitors depend on FFI and on kernel
// facilities a container or a hardened host can take away in ways they
// cannot detect
if (getenv(self::DISABLE_ENV_VARIABLE) !== false) {
return null;
}

if (PHP_OS_FAMILY === 'Darwin') {
return new FsEventsFileMonitor(
$this->hashingFileMonitor,
$this->analysedPaths,
$this->analysedPathsFromConfig,
$this->scanDirectories,
);
}

if (PHP_OS_FAMILY === 'Linux') {
return new InotifyFileMonitor(
$this->hashingFileMonitor,
$this->analysedPaths,
$this->analysedPathsFromConfig,
$this->scanDirectories,
);
}

return null;
}

}
15 changes: 15 additions & 0 deletions src/File/FileMonitorNotSupportedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types = 1);

namespace PHPStan\File;

use Exception;

/**
* A native file monitor cannot run here - no FFI, the kernel refused a watch,
* or the project has more directories than we are willing to watch.
* {@see FileMonitorFactory} answers with {@see HashingFileMonitor} instead.
*/
final class FileMonitorNotSupportedException extends Exception
{

}
Loading
Loading