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