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