1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Testing;
4:
5: use LogicException;
6: use PhpParser\Node;
7: use PhpParser\Node\Expr\StaticCall;
8: use PhpParser\Node\Name;
9: use PHPStan\Analyser\ExpressionResultFactory;
10: use PHPStan\Analyser\MutatingScope;
11: use PHPStan\Analyser\NodeScopeResolver;
12: use PHPStan\Analyser\PerFileAnalysisResettable;
13: use PHPStan\Analyser\Scope;
14: use PHPStan\Analyser\ScopeContext;
15: use PHPStan\File\FileHelper;
16: use PHPStan\File\SystemAgnosticSimpleRelativePathHelper;
17: use PHPStan\Node\InClassNode;
18: use PHPStan\PhpDoc\TypeStringResolver;
19: use PHPStan\Reflection\ReflectionProvider;
20: use PHPStan\Rules\Properties\ReadWritePropertiesExtension;
21: use PHPStan\ShouldNotHappenException;
22: use PHPStan\TrinaryLogic;
23: use PHPStan\Type\ConstantScalarType;
24: use PHPStan\Type\FileTypeMapper;
25: use PHPStan\Type\FunctionParameterClosureThisExtension;
26: use PHPStan\Type\FunctionParameterClosureTypeExtension;
27: use PHPStan\Type\FunctionParameterOutTypeExtension;
28: use PHPStan\Type\MethodParameterClosureThisExtension;
29: use PHPStan\Type\MethodParameterClosureTypeExtension;
30: use PHPStan\Type\MethodParameterOutTypeExtension;
31: use PHPStan\Type\StaticMethodParameterClosureThisExtension;
32: use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
33: use PHPStan\Type\StaticMethodParameterOutTypeExtension;
34: use PHPStan\Type\Type;
35: use PHPStan\Type\VerbosityLevel;
36: use Symfony\Component\Finder\Finder;
37: use function array_map;
38: use function array_merge;
39: use function count;
40: use function fclose;
41: use function fgets;
42: use function fopen;
43: use function in_array;
44: use function is_dir;
45: use function is_string;
46: use function preg_match;
47: use function sprintf;
48: use function str_contains;
49: use function str_starts_with;
50: use function stripos;
51: use function strtolower;
52: use function version_compare;
53: use const PHP_VERSION;
54:
55: /** @api */
56: abstract class TypeInferenceTestCase extends PHPStanTestCase
57: {
58:
59: protected static function createNodeScopeResolver(): NodeScopeResolver
60: {
61: $container = self::getContainer();
62: $reflectionProvider = self::createReflectionProvider();
63:
64: return new NodeScopeResolver(
65: $container,
66: $reflectionProvider,
67: $container->getExtensionsCollection(FunctionParameterOutTypeExtension::class),
68: $container->getExtensionsCollection(MethodParameterOutTypeExtension::class),
69: $container->getExtensionsCollection(StaticMethodParameterOutTypeExtension::class),
70: $container->getByType(FileTypeMapper::class),
71: $container->getExtensionsCollection(ReadWritePropertiesExtension::class),
72: $container->getExtensionsCollection(FunctionParameterClosureThisExtension::class),
73: $container->getExtensionsCollection(MethodParameterClosureThisExtension::class),
74: $container->getExtensionsCollection(StaticMethodParameterClosureThisExtension::class),
75: $container->getExtensionsCollection(FunctionParameterClosureTypeExtension::class),
76: $container->getExtensionsCollection(MethodParameterClosureTypeExtension::class),
77: $container->getExtensionsCollection(StaticMethodParameterClosureTypeExtension::class),
78: $container->getExtensionsCollection(PerFileAnalysisResettable::class),
79: $container->getParameter('polluteScopeWithLoopInitialAssignments'),
80: $container->getParameter('polluteScopeWithAlwaysIterableForeach'),
81: $container->getParameter('exceptions')['implicitThrows'],
82: $container->getParameter('treatPhpDocTypesAsCertain'),
83: $container->getByType(ExpressionResultFactory::class),
84: );
85: }
86:
87: /**
88: * @param string[] $dynamicConstantNames
89: */
90: protected static function createScope(
91: string $file,
92: array $dynamicConstantNames = [],
93: ): MutatingScope
94: {
95: $scopeFactory = self::createScopeFactory(self::createReflectionProvider(), self::getContainer()->getService('typeSpecifier'), $dynamicConstantNames);
96: return $scopeFactory->create(ScopeContext::create($file));
97: }
98:
99: /**
100: * @param callable(Node , Scope ): void $callback
101: * @param string[] $dynamicConstantNames
102: */
103: public static function processFile(
104: string $file,
105: callable $callback,
106: array $dynamicConstantNames = [],
107: ): void
108: {
109: $fileHelper = self::getContainer()->getByType(FileHelper::class);
110: $resolver = static::createNodeScopeResolver();
111: $resolver->setAnalysedFiles(array_map(static fn (string $file): string => $fileHelper->normalizePath($file), array_merge([$file], static::getAdditionalAnalysedFiles())));
112:
113: $resolver->resetPerFileAnalysisState();
114: $resolver->processNodes(
115: self::getParser()->parseFile($file),
116: self::createScope($file, $dynamicConstantNames),
117: $callback,
118: );
119: }
120:
121: /**
122: * @api
123: * @param mixed ...$args
124: */
125: public function assertFileAsserts(
126: string $assertType,
127: string $file,
128: ...$args,
129: ): void
130: {
131: if ($assertType === 'type') {
132: if ($args[0] instanceof Type) {
133: // backward compatibility
134: $expectedType = $args[0];
135: $this->assertInstanceOf(ConstantScalarType::class, $expectedType);
136: $expected = $expectedType->getValue();
137: $actualType = $args[1];
138: $actual = $actualType->describe(VerbosityLevel::precise());
139: } else {
140: $expected = $args[0];
141: $actual = $args[1];
142: }
143:
144: $failureMessage = sprintf('Expected type %s, got type %s in %s on line %d.', $expected, $actual, $file, $args[2]);
145:
146: $delayedErrors = $args[3] ?? [];
147: if (count($delayedErrors) > 0) {
148: $failureMessage .= sprintf(
149: "\n\nThis failure might be reported because of the following misconfiguration %s:\n\n",
150: count($delayedErrors) === 1 ? 'issue' : 'issues',
151: );
152: foreach ($delayedErrors as $delayedError) {
153: $failureMessage .= sprintf("* %s\n", $delayedError);
154: }
155: }
156:
157: $this->assertSame(
158: $expected,
159: $actual,
160: $failureMessage,
161: );
162: } elseif ($assertType === 'superType') {
163: $expected = $args[0];
164: $actual = $args[1];
165: $isCorrect = $args[2];
166:
167: $failureMessage = sprintf('Expected subtype of %s, got type %s in %s on line %d.', $expected, $actual, $file, $args[3]);
168:
169: $delayedErrors = $args[4] ?? [];
170: if (count($delayedErrors) > 0) {
171: $failureMessage .= sprintf(
172: "\n\nThis failure might be reported because of the following misconfiguration %s:\n\n",
173: count($delayedErrors) === 1 ? 'issue' : 'issues',
174: );
175: foreach ($delayedErrors as $delayedError) {
176: $failureMessage .= sprintf("* %s\n", $delayedError);
177: }
178: }
179:
180: $this->assertTrue(
181: $isCorrect,
182: $failureMessage,
183: );
184: } elseif ($assertType === 'variableCertainty') {
185: $expectedCertainty = $args[0];
186: $actualCertainty = $args[1];
187: $variableName = $args[2];
188:
189: $failureMessage = sprintf('Expected %s, actual certainty of %s is %s in %s on line %d.', $expectedCertainty->describe(), $variableName, $actualCertainty->describe(), $file, $args[3]);
190: $delayedErrors = $args[4] ?? [];
191: if (count($delayedErrors) > 0) {
192: $failureMessage .= sprintf(
193: "\n\nThis failure might be reported because of the following misconfiguration %s:\n\n",
194: count($delayedErrors) === 1 ? 'issue' : 'issues',
195: );
196: foreach ($delayedErrors as $delayedError) {
197: $failureMessage .= sprintf("* %s\n", $delayedError);
198: }
199: }
200:
201: $this->assertTrue(
202: $expectedCertainty->equals($actualCertainty),
203: $failureMessage,
204: );
205: }
206: }
207:
208: /**
209: * @return array<string, (
210: * array{0: 'type', 1: string, 2: int|float|string|bool|null, 3: string, 4: int, 5?: non-empty-list<non-falsy-string>}|
211: * array{0: 'superType', 1: string, 2: string, 3: string, 4: bool, 5: int, 6?: non-empty-list<non-falsy-string>}|
212: * array{0: 'variableCertainty', 1: string, 2: TrinaryLogic, 3: TrinaryLogic, 4: string, 5: int, 6?: non-empty-list<non-falsy-string>}
213: * )>
214: *
215: * @api
216: */
217: public static function gatherAssertTypes(string $file): array
218: {
219: $fileHelper = self::getContainer()->getByType(FileHelper::class);
220:
221: $relativePathHelper = new SystemAgnosticSimpleRelativePathHelper($fileHelper);
222: $reflectionProvider = self::getContainer()->getByType(ReflectionProvider::class);
223: $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class);
224:
225: $file = $fileHelper->normalizePath($file);
226:
227: $asserts = [];
228: $delayedErrors = [];
229: self::processFile($file, static function (Node $node, Scope $scope) use (&$asserts, &$delayedErrors, $file, $relativePathHelper, $reflectionProvider, $typeStringResolver): void {
230: if ($node instanceof InClassNode) {
231: if (!$reflectionProvider->hasClass($node->getClassReflection()->getName())) {
232: $delayedErrors[] = sprintf(
233: '%s %s in %s not found in ReflectionProvider. Configure "autoload-dev" section in composer.json to include your tests directory.',
234: $node->getClassReflection()->getClassTypeDescription(),
235: $node->getClassReflection()->getName(),
236: $file,
237: );
238: }
239: } elseif ($node instanceof Node\Stmt\Trait_) {
240: if ($node->namespacedName === null) {
241: throw new ShouldNotHappenException();
242: }
243: if (!$reflectionProvider->hasClass($node->namespacedName->toString())) {
244: $delayedErrors[] = sprintf('Trait %s not found in ReflectionProvider. Configure "autoload-dev" section in composer.json to include your tests directory.', $node->namespacedName->toString());
245: }
246: }
247: if (!$node instanceof Node\Expr\FuncCall) {
248: return;
249: }
250:
251: $nameNode = $node->name;
252: if (!$nameNode instanceof Name) {
253: return;
254: }
255:
256: $functionName = $nameNode->toString();
257: if (in_array(strtolower($functionName), ['asserttype', 'assertnativetype', 'assertsupertype', 'assertvariablecertainty'], true)) {
258: self::fail(sprintf(
259: 'Missing use statement for %s() in %s on line %d.',
260: $functionName,
261: $relativePathHelper->getRelativePath($file),
262: $node->getStartLine(),
263: ));
264: } elseif ($functionName === 'PHPStan\\Testing\\assertType') {
265: $expectedType = $scope->getType($node->getArgs()[0]->value);
266: if (!$expectedType instanceof ConstantScalarType) {
267: self::fail(sprintf(
268: 'Expected type must be a literal string, %s given in %s on line %d.',
269: $expectedType->describe(VerbosityLevel::precise()),
270: $relativePathHelper->getRelativePath($file),
271: $node->getStartLine(),
272: ));
273: }
274: $actualType = $scope->getType($node->getArgs()[1]->value);
275: $assert = ['type', $file, $expectedType->getValue(), $actualType->describe(VerbosityLevel::precise()), $node->getStartLine()];
276: } elseif ($functionName === 'PHPStan\\Testing\\assertNativeType') {
277: $expectedType = $scope->getType($node->getArgs()[0]->value);
278: if (!$expectedType instanceof ConstantScalarType) {
279: self::fail(sprintf(
280: 'Expected type must be a literal string, %s given in %s on line %d.',
281: $expectedType->describe(VerbosityLevel::precise()),
282: $relativePathHelper->getRelativePath($file),
283: $node->getStartLine(),
284: ));
285: }
286:
287: $actualType = $scope->getNativeType($node->getArgs()[1]->value);
288: $assert = ['type', $file, $expectedType->getValue(), $actualType->describe(VerbosityLevel::precise()), $node->getStartLine()];
289: } elseif ($functionName === 'PHPStan\\Testing\\assertSuperType') {
290: $expectedType = $scope->getType($node->getArgs()[0]->value);
291: $expectedTypeStrings = $expectedType->getConstantStrings();
292: if (count($expectedTypeStrings) !== 1) {
293: self::fail(sprintf(
294: 'Expected super type must be a literal string, %s given in %s on line %d.',
295: $expectedType->describe(VerbosityLevel::precise()),
296: $relativePathHelper->getRelativePath($file),
297: $node->getStartLine(),
298: ));
299: }
300:
301: $actualType = $scope->getType($node->getArgs()[1]->value);
302: $isCorrect = $typeStringResolver->resolve($expectedTypeStrings[0]->getValue())->isSuperTypeOf($actualType)->yes();
303:
304: $assert = ['superType', $file, $expectedTypeStrings[0]->getValue(), $actualType->describe(VerbosityLevel::precise()), $isCorrect, $node->getStartLine()];
305: } elseif ($functionName === 'PHPStan\\Testing\\assertVariableCertainty') {
306: $certainty = $node->getArgs()[0]->value;
307: if (!$certainty instanceof StaticCall) {
308: self::fail(sprintf('First argument of %s() must be TrinaryLogic call', $functionName));
309: }
310: if (!$certainty->class instanceof Node\Name) {
311: self::fail(sprintf('ERROR: Invalid TrinaryLogic call.'));
312: }
313:
314: if ($certainty->class->toString() !== 'PHPStan\\TrinaryLogic') {
315: self::fail(sprintf('ERROR: Invalid TrinaryLogic call.'));
316: }
317:
318: if (!$certainty->name instanceof Node\Identifier) {
319: self::fail(sprintf('ERROR: Invalid TrinaryLogic call.'));
320: }
321:
322: // @phpstan-ignore staticMethod.dynamicName
323: $expectedertaintyValue = TrinaryLogic::{$certainty->name->toString()}();
324: $variable = $node->getArgs()[1]->value;
325: if ($variable instanceof Node\Expr\Variable && is_string($variable->name)) {
326: $actualCertaintyValue = $scope->hasVariableType($variable->name);
327: $variableDescription = sprintf('variable $%s', $variable->name);
328: } elseif ($variable instanceof Node\Expr\ArrayDimFetch && $variable->dim !== null) {
329: $offset = $scope->getType($variable->dim);
330: $actualCertaintyValue = $scope->getType($variable->var)->hasOffsetValueType($offset);
331: $variableDescription = sprintf('offset %s', $offset->describe(VerbosityLevel::precise()));
332: } else {
333: self::fail(sprintf('ERROR: Invalid assertVariableCertainty call.'));
334: }
335:
336: $assert = ['variableCertainty', $file, $expectedertaintyValue, $actualCertaintyValue, $variableDescription, $node->getStartLine()];
337: } else {
338: $correctFunction = null;
339:
340: $assertFunctions = [
341: 'assertType' => 'PHPStan\\Testing\\assertType',
342: 'assertNativeType' => 'PHPStan\\Testing\\assertNativeType',
343: 'assertSuperType' => 'PHPStan\\Testing\\assertSuperType',
344: 'assertVariableCertainty' => 'PHPStan\\Testing\\assertVariableCertainty',
345: ];
346: foreach ($assertFunctions as $assertFn => $fqFunctionName) {
347: if (stripos($functionName, $assertFn) === false) {
348: continue;
349: }
350:
351: $correctFunction = $fqFunctionName;
352: }
353:
354: if ($correctFunction === null) {
355: return;
356: }
357:
358: self::fail(sprintf(
359: 'Function %s imported with wrong namespace %s called in %s on line %d.',
360: $correctFunction,
361: $functionName,
362: $relativePathHelper->getRelativePath($file),
363: $node->getStartLine(),
364: ));
365: }
366:
367: if (count($node->getArgs()) !== 2) {
368: self::fail(sprintf(
369: 'ERROR: Wrong %s() call in %s on line %d.',
370: $functionName,
371: $relativePathHelper->getRelativePath($file),
372: $node->getStartLine(),
373: ));
374: }
375:
376: $asserts[$file . ':' . $node->getStartLine()] = $assert;
377: });
378:
379: if (count($asserts) === 0) {
380: self::fail(sprintf('File %s does not contain any asserts', $file));
381: }
382:
383: if (count($delayedErrors) === 0) {
384: return $asserts;
385: }
386:
387: foreach ($asserts as $i => $assert) {
388: $assert[] = $delayedErrors;
389: $asserts[$i] = $assert;
390: }
391:
392: return $asserts;
393: }
394:
395: /**
396: * @api
397: * @return array<string, mixed[]>
398: */
399: public static function gatherAssertTypesFromDirectory(string $directory): array
400: {
401: $asserts = [];
402: foreach (self::findTestDataFilesFromDirectory($directory) as $path) {
403: foreach (self::gatherAssertTypes($path) as $key => $assert) {
404: $asserts[$key] = $assert;
405: }
406: }
407:
408: return $asserts;
409: }
410:
411: /**
412: * @return list<string>
413: */
414: public static function findTestDataFilesFromDirectory(string $directory): array
415: {
416: if (!is_dir($directory)) {
417: self::fail(sprintf('Directory %s does not exist.', $directory));
418: }
419:
420: $finder = new Finder();
421: $finder->followLinks();
422: $files = [];
423: foreach ($finder->files()->name('*.php')->in($directory) as $fileInfo) {
424: $path = $fileInfo->getPathname();
425: try {
426: if (self::isFileLintSkipped($path)) {
427: continue;
428: }
429: } catch (LogicException $e) {
430: self::fail($e->getMessage());
431: }
432: $files[] = $path;
433: }
434:
435: return $files;
436: }
437:
438: /**
439: * From https://github.com/php-parallel-lint/PHP-Parallel-Lint/blob/0c2706086ac36dce31967cb36062ff8915fe03f7/bin/skip-linting.php
440: *
441: * Copyright (c) 2012, Jakub Onderka
442: */
443: private static function isFileLintSkipped(string $file): bool
444: {
445: $f = @fopen($file, 'r');
446: if ($f !== false) {
447: $firstLine = fgets($f);
448: if ($firstLine === false) {
449: return false;
450: }
451:
452: // ignore shebang line
453: if (str_starts_with($firstLine, '#!')) {
454: $firstLine = fgets($f);
455: if ($firstLine === false) {
456: return false;
457: }
458: }
459:
460: @fclose($f);
461:
462: if (preg_match('~<?php\\s*\\/\\/\s*lint\s*([^\d\s]+)\s*([^\s]+)\s*~i', $firstLine, $m) === 1) {
463: return version_compare(PHP_VERSION, $m[2], $m[1]) === false;
464: } elseif (str_contains($firstLine, 'lint')) {
465: throw new LogicException(sprintf("'// lint' comment must immediately follow the php starting tag in %s on line 1", $file));
466: }
467: }
468:
469: return false;
470: }
471:
472: /** @return string[] */
473: protected static function getAdditionalAnalysedFiles(): array
474: {
475: return [];
476: }
477:
478: }
479: