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