diff --git a/src/Parser/IncludeResolutionChangedVisitor.php b/src/Parser/IncludeResolutionChangedVisitor.php new file mode 100644 index 00000000000..09f81a26beb --- /dev/null +++ b/src/Parser/IncludeResolutionChangedVisitor.php @@ -0,0 +1,100 @@ +changed = false; + + return null; + } + + #[Override] + public function enterNode(Node $node): ?Node + { + if ($node instanceof Node\Expr\Include_) { + if ($this->changed) { + $node->setAttribute(self::ATTRIBUTE_NAME, true); + } + + return null; + } + + if ( + !$node instanceof Node\Expr\FuncCall + || !$node->name instanceof Node\Name + || $node->isFirstClassCallable() + ) { + return null; + } + + $functionName = $node->name->toLowerString(); + if (!in_array($functionName, self::FUNCTION_NAMES, true)) { + return null; + } + + if ( + ($functionName === 'ini_set' || $functionName === 'ini_alter') + && !$this->couldSetIncludePath($node) + ) { + return null; + } + + $this->changed = true; + + return null; + } + + /** + * An `ini_set()` of an unrelated option such as `memory_limit` leaves include resolution alone. + * An option name that is not a literal string could be `include_path` just as well as anything + * else. + */ + private function couldSetIncludePath(Node\Expr\FuncCall $node): bool + { + $args = $node->getArgs(); + if ($args === []) { + return false; + } + + $optionName = $args[0]->value; + if (!$optionName instanceof Node\Scalar\String_) { + return true; + } + + return strtolower($optionName->value) === 'include_path'; + } + +} diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php index b1c496608b5..0a3a79aff0d 100644 --- a/src/Rules/Keywords/RequireFileExistsRule.php +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -14,6 +14,7 @@ use PHPStan\File\FileHelper; use PHPStan\File\IncludedFilePathResolver; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Parser\IncludeResolutionChangedVisitor; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -64,6 +65,12 @@ public function processNode(Node $node, Scope $scope): array return []; } + if ($node->getAttribute(IncludeResolutionChangedVisitor::ATTRIBUTE_NAME) === true) { + // A call earlier in the file moved the working directory, changed the include path or + // registered a stream wrapper, so the path no longer names a place PHPStan can look at. + return []; + } + $errors = []; $usedMagicDirFallback = false; $paths = $this->resolveFilePaths($node->expr, $scope, $usedMagicDirFallback); diff --git a/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php index b86dd1053b6..136fcc595f4 100644 --- a/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php +++ b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php @@ -57,6 +57,18 @@ public function testPathWithAnUnregisteredStreamWrapper(): void ]); } + public function testStreamWrapperRegisteredByTheFileItself(): void + { + // Only the include before the stream_wrapper_register() call is reported: after it, the + // file resolves paths through wrappers PHPStan does not have. + $this->analyse([__DIR__ . '/data/require-file-stream-wrapper-registered.php'], [ + [ + 'Path in require_once() "modulea://sites/default/modulea.php" is not a file or it does not exist.', + 5, + ], + ]); + } + public function testBasicCase(): void { $this->analyse([__DIR__ . '/data/require-file-simple-case.php'], [ @@ -204,6 +216,58 @@ public function testBug15015(): void ]); } + public function testChdir(): void + { + // The include after the chdir() is not reported: the path is relative to a working + // directory the file moved, so it no longer names a place PHPStan can look at. Neither is + // the absolute one - the whole file is given up on, which is the point of keeping this + // simple. + $this->analyse([__DIR__ . '/data/require-file-chdir.php'], [ + [ + 'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 5, + ], + ]); + } + + public function testSetIncludePath(): void + { + $this->analyse([__DIR__ . '/data/require-file-set-include-path.php'], [ + [ + 'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 5, + ], + ]); + } + + public function testIniSetIncludePath(): void + { + // ini_set('memory_limit', ...) and ini_alter('precision', ...) leave include resolution + // alone, ini_set('INCLUDE_PATH', ...) does not - the option name is case-insensitive. + $this->analyse([__DIR__ . '/data/require-file-include-path.php'], [ + [ + 'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 8, + ], + ]); + } + + public function testIniSetWithUnknownOption(): void + { + // The option could be include_path just as well as anything else. + $this->analyse([__DIR__ . '/data/require-file-ini-set-unknown.php'], [ + [ + 'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 5, + ], + ]); + } + + public function testBug15260(): void + { + $this->analyse([__DIR__ . '/data/bug-15260/sub/bug-15260.php'], []); + } + public function testInFileExists(): void { $this->analyse([__DIR__ . '/data/include-in-file-exists.php'], []); diff --git a/tests/PHPStan/Rules/Keywords/data/bug-15260/config.php b/tests/PHPStan/Rules/Keywords/data/bug-15260/config.php new file mode 100644 index 00000000000..4541f888d71 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/bug-15260/config.php @@ -0,0 +1,3 @@ +