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