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