Skip to content

Commit ad32861

Browse files
authored
Bleeding edge - check if required file exists
1 parent 637fe4d commit ad32861

10 files changed

+320
-0
lines changed

conf/bleedingEdge.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,6 @@ parameters:
6262
tooWidePropertyType: true
6363
explicitThrow: true
6464
absentTypeChecks: true
65+
requireFileExists: true
6566
stubFiles:
6667
- ../stubs/bleedingEdge/Rule.stub

conf/config.level0.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ conditionalTags:
3030
phpstan.rules.rule: %featureToggles.printfArrayParameters%
3131
PHPStan\Rules\Regexp\RegularExpressionQuotingRule:
3232
phpstan.rules.rule: %featureToggles.validatePregQuote%
33+
PHPStan\Rules\Keywords\RequireFileExistsRule:
34+
phpstan.rules.rule: %featureToggles.requireFileExists%
3335

3436
rules:
3537
- PHPStan\Rules\Api\ApiInstantiationRule
@@ -309,3 +311,7 @@ services:
309311

310312
-
311313
class: PHPStan\Rules\Regexp\RegularExpressionQuotingRule
314+
-
315+
class: PHPStan\Rules\Keywords\RequireFileExistsRule
316+
arguments:
317+
currentWorkingDirectory: %currentWorkingDirectory%

conf/config.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ parameters:
9494
preciseMissingReturn: false
9595
validatePregQuote: false
9696
noImplicitWildcard: false
97+
requireFileExists: false
9798
narrowPregMatches: true
9899
tooWidePropertyType: false
99100
explicitThrow: false

conf/parametersSchema.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ parametersSchema:
9393
tooWidePropertyType: bool()
9494
explicitThrow: bool()
9595
absentTypeChecks: bool()
96+
requireFileExists: bool()
9697
])
9798
fileExtensions: listOf(string())
9899
checkAdvancedIsset: bool()
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Keywords;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\Include_;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\File\FileHelper;
9+
use PHPStan\Rules\IdentifierRuleError;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use PHPStan\ShouldNotHappenException;
13+
use function array_merge;
14+
use function dirname;
15+
use function explode;
16+
use function get_include_path;
17+
use function is_file;
18+
use function sprintf;
19+
use const PATH_SEPARATOR;
20+
21+
/**
22+
* @implements Rule<Include_>
23+
*/
24+
final class RequireFileExistsRule implements Rule
25+
{
26+
27+
public function __construct(private string $currentWorkingDirectory)
28+
{
29+
}
30+
31+
public function getNodeType(): string
32+
{
33+
return Include_::class;
34+
}
35+
36+
public function processNode(Node $node, Scope $scope): array
37+
{
38+
$errors = [];
39+
$paths = $this->resolveFilePaths($node, $scope);
40+
41+
foreach ($paths as $path) {
42+
if ($this->doesFileExist($path, $scope)) {
43+
continue;
44+
}
45+
46+
$errors[] = $this->getErrorMessage($node, $path);
47+
}
48+
49+
return $errors;
50+
}
51+
52+
/**
53+
* We cannot use `stream_resolve_include_path` as it works based on the calling script.
54+
* This method simulates the behavior of `stream_resolve_include_path` but for the given scope.
55+
* The priority order is the following:
56+
* 1. The current working directory.
57+
* 2. The include path.
58+
* 3. The path of the script that is being executed.
59+
*/
60+
private function doesFileExist(string $path, Scope $scope): bool
61+
{
62+
$directories = array_merge(
63+
[$this->currentWorkingDirectory],
64+
explode(PATH_SEPARATOR, get_include_path()),
65+
[dirname($scope->getFile())],
66+
);
67+
68+
foreach ($directories as $directory) {
69+
if ($this->doesFileExistForDirectory($path, $directory)) {
70+
return true;
71+
}
72+
}
73+
74+
return false;
75+
}
76+
77+
private function doesFileExistForDirectory(string $path, string $workingDirectory): bool
78+
{
79+
$fileHelper = new FileHelper($workingDirectory);
80+
$normalisedPath = $fileHelper->normalizePath($path);
81+
$absolutePath = $fileHelper->absolutizePath($normalisedPath);
82+
83+
return is_file($absolutePath);
84+
}
85+
86+
private function getErrorMessage(Include_ $node, string $filePath): IdentifierRuleError
87+
{
88+
$message = 'Path in %s() "%s" is not a file or it does not exist.';
89+
90+
switch ($node->type) {
91+
case Include_::TYPE_REQUIRE:
92+
$type = 'require';
93+
$identifierType = 'require';
94+
break;
95+
case Include_::TYPE_REQUIRE_ONCE:
96+
$type = 'require_once';
97+
$identifierType = 'requireOnce';
98+
break;
99+
case Include_::TYPE_INCLUDE:
100+
$type = 'include';
101+
$identifierType = 'include';
102+
break;
103+
case Include_::TYPE_INCLUDE_ONCE:
104+
$type = 'include_once';
105+
$identifierType = 'includeOnce';
106+
break;
107+
default:
108+
throw new ShouldNotHappenException('Rule should have already validated the node type.');
109+
}
110+
111+
$identifier = sprintf('%s.fileNotFound', $identifierType);
112+
113+
return RuleErrorBuilder::message(
114+
sprintf(
115+
$message,
116+
$type,
117+
$filePath,
118+
),
119+
)->identifier($identifier)->build();
120+
}
121+
122+
/**
123+
* @return array<string>
124+
*/
125+
private function resolveFilePaths(Include_ $node, Scope $scope): array
126+
{
127+
$paths = [];
128+
$type = $scope->getType($node->expr);
129+
$constantStrings = $type->getConstantStrings();
130+
131+
foreach ($constantStrings as $constantString) {
132+
$paths[] = $constantString->getValue();
133+
}
134+
135+
return $paths;
136+
}
137+
138+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Keywords;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use function get_include_path;
8+
use function implode;
9+
use function realpath;
10+
use function set_include_path;
11+
use const PATH_SEPARATOR;
12+
13+
/**
14+
* @extends RuleTestCase<RequireFileExistsRule>
15+
*/
16+
class RequireFileExistsRuleTest extends RuleTestCase
17+
{
18+
19+
private RequireFileExistsRule $rule;
20+
21+
public function setUp(): void
22+
{
23+
parent::setUp();
24+
25+
$this->rule = $this->getDefaultRule();
26+
}
27+
28+
protected function getRule(): Rule
29+
{
30+
return $this->rule;
31+
}
32+
33+
public static function getAdditionalConfigFiles(): array
34+
{
35+
return [
36+
__DIR__ . '/../../Analyser/usePathConstantsAsConstantString.neon',
37+
];
38+
}
39+
40+
private function getDefaultRule(): RequireFileExistsRule
41+
{
42+
return new RequireFileExistsRule(__DIR__ . '/../');
43+
}
44+
45+
public function testBasicCase(): void
46+
{
47+
$this->analyse([__DIR__ . '/data/require-file-simple-case.php'], [
48+
[
49+
'Path in include() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
50+
11,
51+
],
52+
[
53+
'Path in include_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
54+
12,
55+
],
56+
[
57+
'Path in require() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
58+
13,
59+
],
60+
[
61+
'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
62+
14,
63+
],
64+
]);
65+
}
66+
67+
public function testFileDoesNotExistConditionally(): void
68+
{
69+
$this->analyse([__DIR__ . '/data/require-file-conditionally.php'], [
70+
[
71+
'Path in include() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
72+
9,
73+
],
74+
[
75+
'Path in include_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
76+
10,
77+
],
78+
[
79+
'Path in require() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
80+
11,
81+
],
82+
[
83+
'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
84+
12,
85+
],
86+
]);
87+
}
88+
89+
public function testRelativePath(): void
90+
{
91+
$this->analyse([__DIR__ . '/data/require-file-relative-path.php'], [
92+
[
93+
'Path in include() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.',
94+
8,
95+
],
96+
[
97+
'Path in include_once() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.',
98+
9,
99+
],
100+
[
101+
'Path in require() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.',
102+
10,
103+
],
104+
[
105+
'Path in require_once() "data/include-me-to-prove-you-work.txt" is not a file or it does not exist.',
106+
11,
107+
],
108+
]);
109+
}
110+
111+
public function testRelativePathWithIncludePath(): void
112+
{
113+
$includePaths = [realpath(__DIR__)];
114+
$includePaths[] = get_include_path();
115+
116+
set_include_path(implode(PATH_SEPARATOR, $includePaths));
117+
118+
try {
119+
$this->analyse([__DIR__ . '/data/require-file-relative-path.php'], []);
120+
} finally {
121+
set_include_path($includePaths[1]);
122+
}
123+
}
124+
125+
public function testRelativePathWithSameWorkingDirectory(): void
126+
{
127+
$this->rule = new RequireFileExistsRule(__DIR__);
128+
129+
try {
130+
$this->analyse([__DIR__ . '/data/require-file-relative-path.php'], []);
131+
} finally {
132+
$this->rule = $this->getDefaultRule();
133+
}
134+
}
135+
136+
}

tests/PHPStan/Rules/Keywords/data/include-me-to-prove-you-work.txt

Whitespace-only changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php declare(strict_types=1);
2+
3+
$path = __DIR__ . '/include-me-to-prove-you-work.txt';
4+
5+
if (rand(0,1)) {
6+
$path = 'a-file-that-does-not-exist.php';
7+
}
8+
9+
include $path;
10+
include_once $path;
11+
require $path;
12+
require_once $path;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php declare(strict_types=1);
2+
3+
include 'include-me-to-prove-you-work.txt';
4+
include_once 'include-me-to-prove-you-work.txt';
5+
require 'include-me-to-prove-you-work.txt';
6+
require_once 'include-me-to-prove-you-work.txt';
7+
8+
include 'data/include-me-to-prove-you-work.txt';
9+
include_once 'data/include-me-to-prove-you-work.txt';
10+
require 'data/include-me-to-prove-you-work.txt';
11+
require_once 'data/include-me-to-prove-you-work.txt';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php declare(strict_types=1);
2+
3+
$fileThatExists = __DIR__ . '/include-me-to-prove-you-work.txt';
4+
$fileThatDoesNotExist = 'a-file-that-does-not-exist.php';
5+
6+
include $fileThatExists;
7+
include_once $fileThatExists;
8+
require $fileThatExists;
9+
require_once $fileThatExists;
10+
11+
include $fileThatDoesNotExist;
12+
include_once $fileThatDoesNotExist;
13+
require $fileThatDoesNotExist;
14+
require_once $fileThatDoesNotExist;

0 commit comments

Comments
 (0)