diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a9f20432b2..8fb6e4da6a 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -62,5 +62,6 @@ parameters: tooWidePropertyType: true explicitThrow: true absentTypeChecks: true + requireFileExists: true stubFiles: - ../stubs/bleedingEdge/Rule.stub diff --git a/conf/config.level0.neon b/conf/config.level0.neon index 8052e5f5de..d23705fa92 100644 --- a/conf/config.level0.neon +++ b/conf/config.level0.neon @@ -30,6 +30,8 @@ conditionalTags: phpstan.rules.rule: %featureToggles.printfArrayParameters% PHPStan\Rules\Regexp\RegularExpressionQuotingRule: phpstan.rules.rule: %featureToggles.validatePregQuote% + PHPStan\Rules\Keywords\RequireFileExistsRule: + phpstan.rules.rule: %featureToggles.requireFileExists% rules: - PHPStan\Rules\Api\ApiInstantiationRule @@ -309,3 +311,7 @@ services: - class: PHPStan\Rules\Regexp\RegularExpressionQuotingRule + - + class: PHPStan\Rules\Keywords\RequireFileExistsRule + arguments: + currentWorkingDirectory: %currentWorkingDirectory% diff --git a/conf/config.neon b/conf/config.neon index 4097e0d85d..6033cc21b5 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -94,6 +94,7 @@ parameters: preciseMissingReturn: false validatePregQuote: false noImplicitWildcard: false + requireFileExists: false narrowPregMatches: true tooWidePropertyType: false explicitThrow: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 2f3ef3b668..ee3d76ee58 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -93,6 +93,7 @@ parametersSchema: tooWidePropertyType: bool() explicitThrow: bool() absentTypeChecks: bool() + requireFileExists: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php new file mode 100644 index 0000000000..f2a9af9989 --- /dev/null +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -0,0 +1,138 @@ + + */ +final class RequireFileExistsRule implements Rule +{ + + public function __construct(private string $currentWorkingDirectory) + { + } + + public function getNodeType(): string + { + return Include_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + $paths = $this->resolveFilePaths($node, $scope); + + foreach ($paths as $path) { + if ($this->doesFileExist($path, $scope)) { + continue; + } + + $errors[] = $this->getErrorMessage($node, $path); + } + + return $errors; + } + + /** + * We cannot use `stream_resolve_include_path` as it works based on the calling script. + * This method simulates the behavior of `stream_resolve_include_path` but for the given scope. + * The priority order is the following: + * 1. The current working directory. + * 2. The include path. + * 3. The path of the script that is being executed. + */ + private function doesFileExist(string $path, Scope $scope): bool + { + $directories = array_merge( + [$this->currentWorkingDirectory], + explode(PATH_SEPARATOR, get_include_path()), + [dirname($scope->getFile())], + ); + + foreach ($directories as $directory) { + if ($this->doesFileExistForDirectory($path, $directory)) { + return true; + } + } + + return false; + } + + private function doesFileExistForDirectory(string $path, string $workingDirectory): bool + { + $fileHelper = new FileHelper($workingDirectory); + $normalisedPath = $fileHelper->normalizePath($path); + $absolutePath = $fileHelper->absolutizePath($normalisedPath); + + return is_file($absolutePath); + } + + private function getErrorMessage(Include_ $node, string $filePath): IdentifierRuleError + { + $message = 'Path in %s() "%s" is not a file or it does not exist.'; + + switch ($node->type) { + case Include_::TYPE_REQUIRE: + $type = 'require'; + $identifierType = 'require'; + break; + case Include_::TYPE_REQUIRE_ONCE: + $type = 'require_once'; + $identifierType = 'requireOnce'; + break; + case Include_::TYPE_INCLUDE: + $type = 'include'; + $identifierType = 'include'; + break; + case Include_::TYPE_INCLUDE_ONCE: + $type = 'include_once'; + $identifierType = 'includeOnce'; + break; + default: + throw new ShouldNotHappenException('Rule should have already validated the node type.'); + } + + $identifier = sprintf('%s.fileNotFound', $identifierType); + + return RuleErrorBuilder::message( + sprintf( + $message, + $type, + $filePath, + ), + )->identifier($identifier)->build(); + } + + /** + * @return array + */ + private function resolveFilePaths(Include_ $node, Scope $scope): array + { + $paths = []; + $type = $scope->getType($node->expr); + $constantStrings = $type->getConstantStrings(); + + foreach ($constantStrings as $constantString) { + $paths[] = $constantString->getValue(); + } + + return $paths; + } + +} diff --git a/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php new file mode 100644 index 0000000000..287df68634 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php @@ -0,0 +1,136 @@ + + */ +class RequireFileExistsRuleTest extends RuleTestCase +{ + + private RequireFileExistsRule $rule; + + public function setUp(): void + { + parent::setUp(); + + $this->rule = $this->getDefaultRule(); + } + + protected function getRule(): Rule + { + return $this->rule; + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../Analyser/usePathConstantsAsConstantString.neon', + ]; + } + + private function getDefaultRule(): RequireFileExistsRule + { + return new RequireFileExistsRule(__DIR__ . '/../'); + } + + public function testBasicCase(): void + { + $this->analyse([__DIR__ . '/data/require-file-simple-case.php'], [ + [ + 'Path in include() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 11, + ], + [ + 'Path in include_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 12, + ], + [ + 'Path in require() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 13, + ], + [ + 'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 14, + ], + ]); + } + + public function testFileDoesNotExistConditionally(): void + { + $this->analyse([__DIR__ . '/data/require-file-conditionally.php'], [ + [ + 'Path in include() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 9, + ], + [ + 'Path in include_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 10, + ], + [ + 'Path in require() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 11, + ], + [ + 'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.', + 12, + ], + ]); + } + + public function testRelativePath(): void + { + $this->analyse([__DIR__ . '/data/require-file-relative-path.php'], [ + [ + 'Path in include() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.', + 8, + ], + [ + 'Path in include_once() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.', + 9, + ], + [ + 'Path in require() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.', + 10, + ], + [ + 'Path in require_once() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.', + 11, + ], + ]); + } + + public function testRelativePathWithIncludePath(): void + { + $includePaths = [realpath(__DIR__)]; + $includePaths[] = get_include_path(); + + set_include_path(implode(PATH_SEPARATOR, $includePaths)); + + try { + $this->analyse([__DIR__ . '/data/require-file-relative-path.php'], []); + } finally { + set_include_path($includePaths[1]); + } + } + + public function testRelativePathWithSameWorkingDirectory(): void + { + $this->rule = new RequireFileExistsRule(__DIR__); + + try { + $this->analyse([__DIR__ . '/data/require-file-relative-path.php'], []); + } finally { + $this->rule = $this->getDefaultRule(); + } + } + +} diff --git a/tests/PHPStan/Rules/Keywords/data/include-me-to-prove-you-work.txt b/tests/PHPStan/Rules/Keywords/data/include-me-to-prove-you-work.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/PHPStan/Rules/Keywords/data/require-file-conditionally.php b/tests/PHPStan/Rules/Keywords/data/require-file-conditionally.php new file mode 100644 index 0000000000..2d18f0066e --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/require-file-conditionally.php @@ -0,0 +1,12 @@ +