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