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