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