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