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