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