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