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