1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Testing;
4:
5: use PhpParser\Node;
6: use PHPStan\Analyser\Analyser;
7: use PHPStan\Analyser\AnalyserResultFinalizer;
8: use PHPStan\Analyser\Error;
9: use PHPStan\Analyser\ExpressionResultFactory;
10: use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
11: use PHPStan\Analyser\Fiber\FiberNodeScopeResolver;
12: use PHPStan\Analyser\FileAnalyser;
13: use PHPStan\Analyser\IgnoreErrorExtension;
14: use PHPStan\Analyser\InternalError;
15: use PHPStan\Analyser\LocalIgnoresProcessor;
16: use PHPStan\Analyser\NodeScopeResolver;
17: use PHPStan\Analyser\PerFileAnalysisResettable;
18: use PHPStan\Analyser\RuleErrorTransformer;
19: use PHPStan\Analyser\TypeSpecifier;
20: use PHPStan\Collectors\Collector;
21: use PHPStan\Collectors\Registry as CollectorRegistry;
22: use PHPStan\Dependency\DependencyResolver;
23: use PHPStan\Dependency\PackageDependencyResolver;
24: use PHPStan\DependencyInjection\DirectExtensionsCollection;
25: use PHPStan\File\FileHelper;
26: use PHPStan\File\FileReader;
27: use PHPStan\Fixable\Patcher;
28: use PHPStan\PhpDoc\PhpDocInheritanceResolver;
29: use PHPStan\Reflection\ClassReflectionFactory;
30: use PHPStan\Reflection\InitializerExprTypeResolver;
31: use PHPStan\Rules\DirectRegistry as DirectRuleRegistry;
32: use PHPStan\Rules\IdentifierRuleError;
33: use PHPStan\Rules\Properties\ReadWritePropertiesExtension;
34: use PHPStan\Rules\Rule;
35: use PHPStan\Type\FileTypeMapper;
36: use PHPStan\Type\FunctionParameterClosureThisExtension;
37: use PHPStan\Type\FunctionParameterClosureTypeExtension;
38: use PHPStan\Type\FunctionParameterOutTypeExtension;
39: use PHPStan\Type\MethodParameterClosureThisExtension;
40: use PHPStan\Type\MethodParameterClosureTypeExtension;
41: use PHPStan\Type\MethodParameterOutTypeExtension;
42: use PHPStan\Type\StaticMethodParameterClosureThisExtension;
43: use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
44: use PHPStan\Type\StaticMethodParameterOutTypeExtension;
45: use function array_map;
46: use function array_merge;
47: use function count;
48: use function getenv;
49: use function implode;
50: use function sprintf;
51: use function str_replace;
52: use function strcmp;
53: use function usort;
54: use const PHP_VERSION_ID;
55:
56: /**
57: * @api
58: * @template TRule of Rule
59: */
60: abstract class RuleTestCase extends PHPStanTestCase
61: {
62:
63: private ?Analyser $analyser = null;
64:
65: /**
66: * @return TRule
67: */
68: abstract protected function getRule(): Rule;
69:
70: /**
71: * @return array<Collector<Node, mixed>>
72: */
73: protected function getCollectors(): array
74: {
75: return [];
76: }
77:
78: /**
79: * @return ReadWritePropertiesExtension[]
80: */
81: protected function getReadWritePropertiesExtensions(): array
82: {
83: return [];
84: }
85:
86: protected function getTypeSpecifier(): TypeSpecifier
87: {
88: return self::getContainer()->getService('typeSpecifier');
89: }
90:
91: protected function createNodeScopeResolver(): NodeScopeResolver
92: {
93: $readWritePropertiesExtensions = $this->getReadWritePropertiesExtensions();
94: $reflectionProvider = $this->createReflectionProvider();
95: $typeSpecifier = $this->getTypeSpecifier();
96:
97: $enableFnsr = getenv('PHPSTAN_FNSR');
98: $className = NodeScopeResolver::class;
99: if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0') {
100: $className = FiberNodeScopeResolver::class;
101: }
102:
103: return new $className(
104: self::getContainer(),
105: $reflectionProvider,
106: self::getContainer()->getByType(InitializerExprTypeResolver::class),
107: self::getReflector(),
108: self::getContainer()->getByType(ClassReflectionFactory::class),
109: self::getContainer()->getExtensionsCollection(FunctionParameterOutTypeExtension::class),
110: self::getContainer()->getExtensionsCollection(MethodParameterOutTypeExtension::class),
111: self::getContainer()->getExtensionsCollection(StaticMethodParameterOutTypeExtension::class),
112: $this->getParser(),
113: self::getContainer()->getByType(FileTypeMapper::class),
114: self::getContainer()->getByType(PhpDocInheritanceResolver::class),
115: self::getContainer()->getByType(FileHelper::class),
116: $typeSpecifier,
117: $readWritePropertiesExtensions !== [] ? new DirectExtensionsCollection($readWritePropertiesExtensions) : self::getContainer()->getExtensionsCollection(ReadWritePropertiesExtension::class),
118: self::getContainer()->getExtensionsCollection(FunctionParameterClosureThisExtension::class),
119: self::getContainer()->getExtensionsCollection(MethodParameterClosureThisExtension::class),
120: self::getContainer()->getExtensionsCollection(StaticMethodParameterClosureThisExtension::class),
121: self::getContainer()->getExtensionsCollection(FunctionParameterClosureTypeExtension::class),
122: self::getContainer()->getExtensionsCollection(MethodParameterClosureTypeExtension::class),
123: self::getContainer()->getExtensionsCollection(StaticMethodParameterClosureTypeExtension::class),
124: self::getContainer()->getExtensionsCollection(PerFileAnalysisResettable::class),
125: self::createScopeFactory($reflectionProvider, $typeSpecifier),
126: $this->shouldPolluteScopeWithLoopInitialAssignments(),
127: $this->shouldPolluteScopeWithAlwaysIterableForeach(),
128: self::getContainer()->getParameter('polluteScopeWithBlock'),
129: self::getContainer()->getParameter('exceptions')['implicitThrows'],
130: $this->shouldTreatPhpDocTypesAsCertain(),
131: self::getContainer()->getByType(ImplicitToStringCallHelper::class),
132: self::getContainer()->getByType(ExpressionResultFactory::class),
133: );
134: }
135:
136: private function getAnalyser(DirectRuleRegistry $ruleRegistry): Analyser
137: {
138: if ($this->analyser === null) {
139: $collectorRegistry = new CollectorRegistry($this->getCollectors());
140:
141: $nodeScopeResolver = $this->createNodeScopeResolver();
142:
143: $fileAnalyser = new FileAnalyser(
144: self::createScopeFactory(
145: $this->createReflectionProvider(),
146: $this->getTypeSpecifier(),
147: ),
148: $nodeScopeResolver,
149: $this->getParser(),
150: self::getContainer()->getByType(DependencyResolver::class),
151: self::getContainer()->getByType(PackageDependencyResolver::class),
152: self::getContainer()->getExtensionsCollection(IgnoreErrorExtension::class),
153: self::getContainer()->getByType(RuleErrorTransformer::class),
154: new LocalIgnoresProcessor(),
155: false,
156: );
157: $this->analyser = new Analyser(
158: $fileAnalyser,
159: $ruleRegistry,
160: $collectorRegistry,
161: $nodeScopeResolver,
162: 50,
163: );
164: }
165:
166: return $this->analyser;
167: }
168:
169: /**
170: * @param string[] $files
171: * @param list<array{0: string, 1: int, 2?: string|null}> $expectedErrors
172: */
173: public function analyse(array $files, array $expectedErrors): void
174: {
175: [$actualErrors, $delayedErrors] = $this->gatherAnalyserErrorsWithDelayedErrors($files);
176: $strictlyTypedSprintf = static function (int $line, string $message, ?string $tip): string {
177: $message = sprintf('%02d: %s', $line, $message);
178: if ($tip !== null) {
179: $message .= "\n 💡 " . $tip;
180: }
181:
182: return $message;
183: };
184:
185: usort($expectedErrors, static function ($a, $b) {
186: if ($a[1] !== $b[1]) {
187: return $a[1] <=> $b[1];
188: }
189:
190: if ($a[0] !== $b[0]) {
191: return strcmp($a[0], $b[0]);
192: }
193:
194: if (!isset($a[2])) {
195: if (!isset($b[2])) {
196: return 0;
197: }
198:
199: return 1;
200: } elseif (!isset($b[2])) {
201: return -1;
202: }
203:
204: return strcmp($a[2], $b[2]);
205: });
206:
207: $expectedErrors = array_map(
208: static fn (array $error): string => $strictlyTypedSprintf($error[1], $error[0], $error[2] ?? null),
209: $expectedErrors,
210: );
211:
212: usort($actualErrors, static function ($a, $b) {
213: if ($a->getLine() !== $b->getLine()) {
214: return $a->getLine() <=> $b->getLine();
215: }
216:
217: if ($a->getMessage() !== $b->getMessage()) {
218: return strcmp($a->getMessage(), $b->getMessage());
219: }
220:
221: if ($a->getTip() === null) {
222: if ($b->getTip() === null) {
223: return 0;
224: }
225:
226: return 1;
227: } elseif ($b->getTip() === null) {
228: return -1;
229: }
230:
231: return strcmp($a->getTip(), $b->getTip());
232: });
233:
234: $actualErrors = array_map(
235: static function (Error $error) use ($strictlyTypedSprintf): string {
236: $line = $error->getLine();
237: if ($line === null) {
238: return $strictlyTypedSprintf(-1, $error->getMessage(), $error->getTip());
239: }
240: return $strictlyTypedSprintf($line, $error->getMessage(), $error->getTip());
241: },
242: $actualErrors,
243: );
244:
245: $expectedErrorsString = implode("\n", $expectedErrors) . "\n";
246: $actualErrorsString = implode("\n", $actualErrors) . "\n";
247:
248: if (count($delayedErrors) === 0) {
249: $this->assertSame($expectedErrorsString, $actualErrorsString);
250: return;
251: }
252:
253: if ($expectedErrorsString === $actualErrorsString) {
254: $this->assertSame($expectedErrorsString, $actualErrorsString);
255: return;
256: }
257:
258: $actualErrorsString .= sprintf(
259: "\n%s might be reported because of the following misconfiguration %s:\n\n",
260: count($actualErrors) === 1 ? 'This error' : 'These errors',
261: count($delayedErrors) === 1 ? 'issue' : 'issues',
262: );
263:
264: foreach ($delayedErrors as $delayedError) {
265: $actualErrorsString .= sprintf("* %s\n", $delayedError->getMessage());
266: }
267:
268: $this->assertSame($expectedErrorsString, $actualErrorsString);
269: }
270:
271: public function fix(string $file, string $expectedFile): void
272: {
273: [$errors] = $this->gatherAnalyserErrorsWithDelayedErrors([$file]);
274: $diffs = [];
275: foreach ($errors as $error) {
276: if ($error->getFixedErrorDiff() === null) {
277: continue;
278: }
279: $diffs[] = $error->getFixedErrorDiff();
280: }
281:
282: $patcher = self::getContainer()->getByType(Patcher::class);
283: $newFileContents = $patcher->applyDiffs($file, $diffs); // @phpstan-ignore missingType.checkedException, missingType.checkedException
284:
285: $fixedFileContents = FileReader::read($expectedFile);
286:
287: $this->assertSame($this->normalizeLineEndings($fixedFileContents), $this->normalizeLineEndings($newFileContents));
288: }
289:
290: private function normalizeLineEndings(string $string): string
291: {
292: return str_replace("\r\n", "\n", $string);
293: }
294:
295: /**
296: * @param string[] $files
297: * @return list<Error>
298: */
299: public function gatherAnalyserErrors(array $files): array
300: {
301: return $this->gatherAnalyserErrorsWithDelayedErrors($files)[0];
302: }
303:
304: /**
305: * @param string[] $files
306: * @return array{list<Error>, list<IdentifierRuleError>}
307: */
308: private function gatherAnalyserErrorsWithDelayedErrors(array $files): array
309: {
310: $reflectionProvider = $this->createReflectionProvider();
311: $classRule = new DelayedRule(new NonexistentAnalysedClassRule($reflectionProvider));
312: $traitRule = new DelayedRule(new NonexistentAnalysedTraitRule($reflectionProvider));
313: $ruleRegistry = new DirectRuleRegistry([
314: $this->getRule(),
315: $classRule,
316: $traitRule,
317: ]);
318: $files = array_map([$this->getFileHelper(), 'normalizePath'], $files);
319: $analyserResult = $this->getAnalyser($ruleRegistry)->analyse(
320: $files,
321: null,
322: null,
323: true,
324: );
325: if (count($analyserResult->getInternalErrors()) > 0) {
326: $this->fail(implode("\n", array_map(static fn (InternalError $internalError) => $internalError->getMessage(), $analyserResult->getInternalErrors())));
327: }
328:
329: if ($this->shouldFailOnPhpErrors() && count($analyserResult->getAllPhpErrors()) > 0) {
330: $this->fail(implode("\n", array_map(
331: static fn (Error $error): string => sprintf('%s on %s:%d', $error->getMessage(), $error->getFile(), $error->getLine() ?? 0),
332: $analyserResult->getAllPhpErrors(),
333: )));
334: }
335:
336: $finalizer = new AnalyserResultFinalizer(
337: $ruleRegistry,
338: self::getContainer()->getExtensionsCollection(IgnoreErrorExtension::class),
339: self::getContainer()->getByType(RuleErrorTransformer::class),
340: self::createScopeFactory($reflectionProvider, self::getContainer()->getService('typeSpecifier')),
341: new LocalIgnoresProcessor(),
342: true,
343: );
344:
345: return [
346: $finalizer->finalize($analyserResult, false, true)->getAnalyserResult()->getUnorderedErrors(),
347: array_merge($classRule->getDelayedErrors(), $traitRule->getDelayedErrors()),
348: ];
349: }
350:
351: protected function shouldPolluteScopeWithLoopInitialAssignments(): bool
352: {
353: return true;
354: }
355:
356: protected function shouldPolluteScopeWithAlwaysIterableForeach(): bool
357: {
358: return true;
359: }
360:
361: protected function shouldFailOnPhpErrors(): bool
362: {
363: return true;
364: }
365:
366: public static function getAdditionalConfigFiles(): array
367: {
368: return [
369: __DIR__ . '/../../conf/bleedingEdge.neon',
370: ];
371: }
372:
373: }
374: