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