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