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