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