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