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