diff --git a/README.md b/README.md index ab97366..a52c340 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ PHPStan Level MAX

------- +--- -TypePHP is a transparent, pure-PHP runtime type checker. You don't have to refactor a single line of your codebase, setup complex build toolchains, or compile C-extensions and simply run your existing code, and TypePHP will enforce your extended PHPDoc contracts (generics, array shapes, `key-of`/`value-of` extractions, and scalar refinements) dynamically at runtime. +TypePHP is a transparent, pure-PHP runtime type checker. You don't have to refactor a single line of your codebase, set up complex build toolchains, or compile C-extensions. Simply run your existing code, and TypePHP will enforce your extended PHPDoc contracts (generics, array shapes, `key-of`/`value-of` extractions, and scalar refinements) dynamically at runtime. **[Read the full TypePHP documentation »](https://typephp-php.github.io/typephp/)** @@ -29,15 +29,17 @@ All the documentation lives on the [typephp-php.github.io/typephp website](https * [Getting Started & Installation Guide](https://typephp-php.github.io/typephp/getting-started/installation) * [Quick Start Guide](https://typephp-php.github.io/typephp/getting-started/quick-start) -* [Architecture: How It Works](https://typephp-php.github.io/typephp/architecture/how-it-works) -* [Core Concepts: Function Contracts](https://typephp-php.github.io/typephp/core-concepts/function-contracts) -* [Core Concepts: Generics & Bounds](https://typephp-php.github.io/typephp/core-concepts/generics-and-bounds) +* [Configuration Guide](https://typephp-php.github.io/typephp/getting-started/configuration) +* [CLI Commands Reference](https://typephp-php.github.io/typephp/getting-started/cli-commands) +* [Enforcement Boundaries: Function Contracts](https://typephp-php.github.io/typephp/core-concepts/function-contracts) +* [Runtime Generics & Bounds](https://typephp-php.github.io/typephp/generics/generics-and-bounds) * [Supported Types: Arrays & Shapes](https://typephp-php.github.io/typephp/supported-types/arrays-and-shapes) -* [Troubleshooting & FAQ](https://typephp-php.github.io/typephp/advanced/troubleshooting) +* [Architecture: How It Works](https://typephp-php.github.io/typephp/advanced/how-it-works) +* [Troubleshooting & FAQ](https://typephp-php.github.io/typephp/troubleshooting) ## Inspiration -TypePHP is conceptually inspired by Python's [Beartype](https://github.com/beartype/beartype), but bringing transparent runtime type enforcement for type annotations to the PHP ecosystem without any decorators or attributes. +TypePHP is conceptually inspired by Python's [Beartype](https://github.com/beartype/beartype), bringing transparent runtime type enforcement for type annotations to the PHP ecosystem without any decorators or attributes. ## Sponsors diff --git a/src/Command/CommandRunner.php b/src/Command/CommandRunner.php index e7ab1fb..a9091ca 100644 --- a/src/Command/CommandRunner.php +++ b/src/Command/CommandRunner.php @@ -6,6 +6,14 @@ final class CommandRunner { + private const KNOWN_COMMANDS = [ + 'config:init', + 'cache:clear', + 'cache:warm', + 'cache:rebuild', + 'help', + ]; + /** * Parses CLI arguments and routes execution to the corresponding command class. * @@ -15,28 +23,51 @@ final class CommandRunner */ public static function run(array $args, $outputStream = STDOUT, $errorStream = STDERR): int { - $showHelp = \in_array('help', $args, true) || \in_array('typephp:help', $args, true) || \in_array('--help', $args, true) || \in_array('-h', $args, true); + $c = [CliFormatter::class, 'color']; + + $showHelp = \in_array('help', $args, true) + || \in_array('typephp:help', $args, true) + || \in_array('--help', $args, true) + || \in_array('-h', $args, true) + || $args === []; - if ($showHelp || $args === []) { + if ($showHelp) { return (new HelpCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('config:init', $args, true) || \in_array('init', $args, true)) { + $firstArg = $args[0] ?? ''; + + if ($firstArg === 'config:init' || $firstArg === 'init') { return (new ConfigInitCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:rebuild', $args, true)) { + if ($firstArg === 'cache:rebuild') { return (new CacheRebuildCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:clear', $args, true)) { + if ($firstArg === 'cache:clear') { return (new CacheClearCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:warm', $args, true)) { + if ($firstArg === 'cache:warm') { return (new CacheWarmCommand())->execute($args, $outputStream, $errorStream); } + $hasFileExtension = str_contains(basename($firstArg), '.'); + $isFileTarget = file_exists($firstArg) || $hasFileExtension; + + if (! $isFileTarget) { + fwrite($errorStream, "\n " . $c(' TYPEPHP ', 'badge_red') . ' ' . $c('Error', 'bold') . "\n\n"); + fwrite($errorStream, ' ' . $c('✗', 'red') . ' Command ' . $c('"' . $firstArg . '"', 'bold') . " is not defined.\n\n"); + fwrite($errorStream, ' ' . $c('Did you mean one of these?', 'yellow') . "\n"); + foreach (self::KNOWN_COMMANDS as $cmd) { + fwrite($errorStream, ' ' . $c('•', 'cyan') . ' ' . $cmd . "\n"); + } + fwrite($errorStream, "\n"); + + return 1; + } + return (new RunCommand())->execute($args, $outputStream, $errorStream); } } diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index a576643..67bc72c 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -11,6 +11,8 @@ */ final class RunCommand implements CommandInterface { + private const VALID_PHP_EXTENSIONS = ['php', 'phtml', 'php5', 'php7', 'php8', 'phps']; + public function execute(array $args, $outputStream = STDOUT, $errorStream = STDERR): int { $c = [CliFormatter::class, 'color']; @@ -21,6 +23,15 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE foreach ($args as $arg) { if (! str_starts_with($arg, '--') && ! str_starts_with($arg, '-')) { $givenTargetCandidate = $arg; + $ext = strtolower(pathinfo($arg, PATHINFO_EXTENSION)); + + if ($ext !== '' && ! \in_array($ext, self::VALID_PHP_EXTENSIONS, true)) { + fwrite($errorStream, "\n " . $c(' TYPEPHP ', 'badge_red') . ' ' . $c('Error', 'bold') . "\n\n"); + fwrite($errorStream, ' ' . $c('✗', 'red') . ' Target file ' . $c('"' . $arg . '"', 'bold') . " is not a PHP script file. TypePHP can only execute PHP files.\n\n"); + + return 1; + } + if (file_exists($arg)) { $target = $arg; } @@ -31,7 +42,7 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE if ($givenTargetCandidate !== null && $target === null) { fwrite($errorStream, "\n " . $c(' TYPEPHP ', 'badge_red') . ' ' . $c('Error', 'bold') . "\n\n"); - fwrite($errorStream, ' ' . $c('✗', 'red') . ' Target file ' . $c('"' . $givenTargetCandidate . '"', 'bold') . " does not exist or is not readable.\n\n"); + fwrite($errorStream, ' ' . $c('✗', 'red') . ' Target script file ' . $c('"' . $givenTargetCandidate . '"', 'bold') . " does not exist or is not readable.\n\n"); return 1; } diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index bc9c8f1..f80cf8b 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -5,6 +5,7 @@ namespace TypePHP\Internal; use PhpParser\Node; +use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use TypePHP\Internal\Visitor\FunctionContractInjector; use TypePHP\Internal\Visitor\NodeBuilder; @@ -26,9 +27,9 @@ public function __construct() /** * Traverses and transforms AST nodes during entry. * - * @return array|null + * @return array|int|null */ - public function enterNode(Node $node): array|null + public function enterNode(Node $node): array|int|null { if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod @@ -47,6 +48,15 @@ public function enterNode(Node $node): array|null } if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { + $doc = $node->getDocComment(); + if ($doc !== null) { + $docText = $doc->getText(); + $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); + if ($shouldRespectIgnore && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable'))) { + return NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + } + FunctionContractInjector::inject($node); return null; diff --git a/src/Internal/DocblockNormalizer.php b/src/Internal/DocblockNormalizer.php index d391f2b..f274f0b 100644 --- a/src/Internal/DocblockNormalizer.php +++ b/src/Internal/DocblockNormalizer.php @@ -28,6 +28,8 @@ final class DocblockNormalizer */ public static function normalize(string $doc): string { + $doc = preg_replace('/(\\\\?[a-zA-Z_\x80-\xff][\\\\a-zA-Z0-9_\x80-\xff]*::[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*(\??:)/', '"$1"$2', $doc) ?? $doc; + if (! str_contains($doc, '{')) { return $doc; } diff --git a/src/Internal/Visitor/PropertyHookInjector.php b/src/Internal/Visitor/PropertyHookInjector.php index 65e26d3..0b843c6 100644 --- a/src/Internal/Visitor/PropertyHookInjector.php +++ b/src/Internal/Visitor/PropertyHookInjector.php @@ -16,7 +16,7 @@ final class PropertyHookInjector { public static function process(Node\Stmt\Property $node): void { - if ($node->hooks === []) { + if (! isset($node->hooks) || ! \is_array($node->hooks) || $node->hooks === []) { return; } @@ -76,15 +76,15 @@ public function __construct(private string $propertyName) { } - public function enterNode(Node $n): int|null + public function enterNode(Node $node): int|null { - if ($n instanceof Node\Expr\Closure || $n instanceof Node\Expr\ArrowFunction || $n instanceof Node\Stmt\Function_ || $n instanceof Node\Stmt\ClassMethod) { + if ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction || $node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { return NodeTraverser::DONT_TRAVERSE_CHILDREN; } - if ($n instanceof Node\Stmt\Return_ && $n->expr !== null) { - $checkCall = NodeBuilder::createPropertyCheckCall($n->expr, new Node\Expr\Variable('this'), $this->propertyName); - $n->expr = NodeBuilder::createTernaryThrowExpr($checkCall); + if ($node instanceof Node\Stmt\Return_ && $node->expr !== null) { + $checkCall = NodeBuilder::createPropertyCheckCall($node->expr, new Node\Expr\Variable('this'), $this->propertyName); + $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall); } return null; diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 4a243f1..8da48d2 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -68,23 +68,13 @@ public static function checkThisIdentity(TypeNode $returnTypeNode, mixed $value, } /** - * Recursively resolves special type identifiers (self, static, parent, FQCNs, ConstFetch class names) in a TypeNode AST using Reflection context. + * Recursively resolves special type identifiers in a TypeNode AST using Reflection context. * * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj = null): TypeNode { - if (\is_string($context)) { - if (str_contains($context, '::')) { - [$className, $methodName] = explode('::', $context, 2); - $ref = new \ReflectionMethod($className, $methodName); - } else { - $ref = new \ReflectionFunction($context); - } - } else { - $ref = $context; - } - + $ref = self::getReflectionContext($context); $declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : ($ref instanceof \ReflectionClass ? $ref->getName() : null); if ($node instanceof ThisTypeNode) { @@ -92,55 +82,16 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof IdentifierTypeNode) { - $lower = strtolower($node->name); - - if ($lower === '$this' || $lower === 'static') { - return $node; - } - - if ($lower === 'self' && $declaringClass !== null) { - return new IdentifierTypeNode($declaringClass); - } - - if ($lower === 'parent' && $declaringClass !== null) { - $parentClass = get_parent_class($declaringClass); - if ($parentClass !== false) { - return new IdentifierTypeNode($parentClass); - } - } - - $fqcn = self::resolveFqcn($node->name, $ref); - if ($fqcn !== $node->name) { - return new IdentifierTypeNode($fqcn); - } + return self::resolveIdentifier($node, $declaringClass, $ref); } if ($node instanceof ConstTypeNode) { - if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { - $className = $node->constExpr->className; - $lowerClassName = strtolower($className); - - if ($lowerClassName === 'self' && $declaringClass !== null) { - $resolvedClass = $declaringClass; - } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { - $parentClass = get_parent_class($declaringClass); - $resolvedClass = $parentClass !== false ? $parentClass : $className; - } else { - $resolvedClass = self::resolveFqcn($className, $ref); - } - - return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); - } - - return $node; + return self::resolveConstType($node, $declaringClass, $ref); } if ($node instanceof GenericTypeNode) { - $genericType = self::resolve($node->type, $context, $thisObj); - $innerTypes = array_map( - fn ($t) => self::resolve($t, $context, $thisObj), - $node->genericTypes - ); + $genericType = self::resolve($node->type, $ref, $thisObj); + $innerTypes = array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -149,164 +100,56 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct ); } - if ($node instanceof ConditionalTypeNode) { - return new ConditionalTypeNode( - self::resolve($node->subjectType, $context, $thisObj), - self::resolve($node->targetType, $context, $thisObj), - self::resolve($node->if, $context, $thisObj), - self::resolve($node->else, $context, $thisObj), - $node->negated - ); - } - - if ($node instanceof ConditionalTypeForParameterNode) { - return new ConditionalTypeForParameterNode( - $node->parameterName, - self::resolve($node->targetType, $context, $thisObj), - self::resolve($node->if, $context, $thisObj), - self::resolve($node->else, $context, $thisObj), - $node->negated - ); - } - if ($node instanceof OffsetAccessTypeNode) { - $baseType = self::resolve($node->type, $context, $thisObj); - $offsetType = self::resolve($node->offset, $context, $thisObj); - - $offsetKey = null; - if ($offsetType instanceof ConstTypeNode) { - $expr = $offsetType->constExpr; - if ($expr instanceof ConstExprStringNode) { - $offsetKey = $expr->value; - } elseif ($expr instanceof ConstExprIntegerNode) { - $offsetKey = (int) $expr->value; - } - } elseif ($offsetType instanceof IdentifierTypeNode) { - $offsetKey = $offsetType->name; - } - - if ($offsetKey !== null) { - if ($baseType instanceof ArrayShapeNode) { - foreach ($baseType->items as $item) { - $itemKey = null; - if ($item->keyName instanceof ConstExprStringNode) { - $itemKey = $item->keyName->value; - } elseif ($item->keyName instanceof IdentifierTypeNode) { - $itemKey = $item->keyName->name; - } elseif ($item->keyName instanceof ConstExprIntegerNode) { - $itemKey = (int) $item->keyName->value; - } - - if ((string) $itemKey === (string) $offsetKey) { - return $item->valueType; - } - } - } - - if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { - $constExpr = $baseType->constExpr; - $fqcn = $constExpr->className; - $constName = $constExpr->name; - - if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { - $val = $constValue[$offsetKey]; - if (\is_string($val)) { - return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); - } elseif (\is_int($val)) { - return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); - } - } - } - } catch (\ReflectionException $e) { - } - } - } - } - - return new OffsetAccessTypeNode($baseType, $offsetType); + return self::resolveOffsetAccess($node, $ref, $thisObj); } if ($node instanceof ArrayShapeNode) { - $items = array_map(function ($item) use ($context, $thisObj) { - return new ArrayShapeItemNode( - $item->keyName, - $item->optional, - self::resolve($item->valueType, $context, $thisObj) - ); - }, $node->items); - - if ($node->sealed) { - return ArrayShapeNode::createSealed($items, $node->kind); - } else { - $unsealedType = null; - if ($node->unsealedType !== null) { - $unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $context, $thisObj) : null; - $unsealedValue = self::resolve($node->unsealedType->valueType, $context, $thisObj); - $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); - } - - return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); - } + return self::resolveArrayShape($node, $ref, $thisObj); } if ($node instanceof ObjectShapeNode) { - $items = array_map(function ($item) use ($context, $thisObj) { - return new ObjectShapeItemNode( - $item->keyName, - $item->optional, - self::resolve($item->valueType, $context, $thisObj) - ); - }, $node->items); - - return new ObjectShapeNode($items); + return self::resolveObjectShape($node, $ref, $thisObj); } if ($node instanceof CallableTypeNode) { - $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($context, $thisObj) { - return new CallableTypeParameterNode( - self::resolve($param->type, $context, $thisObj), - $param->isReference, - $param->isVariadic, - $param->parameterName, - $param->isOptional - ); - }, $node->parameters); - - $resolvedReturnType = self::resolve($node->returnType, $context, $thisObj); - - return new CallableTypeNode( - $node->identifier, - $resolvedParameters, - $resolvedReturnType, - $node->templateTypes + return self::resolveCallable($node, $ref, $thisObj); + } + + if ($node instanceof ConditionalTypeNode) { + return new ConditionalTypeNode( + self::resolve($node->subjectType, $ref, $thisObj), + self::resolve($node->targetType, $ref, $thisObj), + self::resolve($node->if, $ref, $thisObj), + self::resolve($node->else, $ref, $thisObj), + $node->negated + ); + } + + if ($node instanceof ConditionalTypeForParameterNode) { + return new ConditionalTypeForParameterNode( + $node->parameterName, + self::resolve($node->targetType, $ref, $thisObj), + self::resolve($node->if, $ref, $thisObj), + self::resolve($node->else, $ref, $thisObj), + $node->negated ); } if ($node instanceof NullableTypeNode) { - return new NullableTypeNode(self::resolve($node->type, $context, $thisObj)); + return new NullableTypeNode(self::resolve($node->type, $ref, $thisObj)); } if ($node instanceof ArrayTypeNode) { - return new ArrayTypeNode(self::resolve($node->type, $context, $thisObj)); + return new ArrayTypeNode(self::resolve($node->type, $ref, $thisObj)); } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map( - fn ($t) => self::resolve($t, $context, $thisObj), - $node->types - )); + return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map( - fn ($t) => self::resolve($t, $context, $thisObj), - $node->types - )); + return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->types)); } return $node; @@ -328,34 +171,17 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode } $fqcn = self::resolveFqcnForFile($node->name, $file); - if ($fqcn !== $node->name) { - return new IdentifierTypeNode($fqcn); - } + + return $fqcn !== $node->name ? new IdentifierTypeNode($fqcn) : clone $node; } if ($node instanceof ConstTypeNode) { - if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { - $className = $node->constExpr->className; - $lowerClassName = strtolower($className); - - if ($lowerClassName === 'self' || $lowerClassName === 'parent') { - $resolvedClass = $className; - } else { - $resolvedClass = self::resolveFqcnForFile($className, $file); - } - - return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); - } - - return clone $node; + return self::resolveConstTypeForFile($node, $file); } if ($node instanceof GenericTypeNode) { $genericType = self::resolveForFile($node->type, $file); - $innerTypes = array_map( - fn ($t) => self::resolveForFile($t, $file), - $node->genericTypes - ); + $innerTypes = array_map(fn ($t) => self::resolveForFile($t, $file), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -364,6 +190,22 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode ); } + if ($node instanceof OffsetAccessTypeNode) { + return self::resolveOffsetAccessForFile($node, $file); + } + + if ($node instanceof ArrayShapeNode) { + return self::resolveArrayShapeForFile($node, $file); + } + + if ($node instanceof ObjectShapeNode) { + return self::resolveObjectShapeForFile($node, $file); + } + + if ($node instanceof CallableTypeNode) { + return self::resolveCallableForFile($node, $file); + } + if ($node instanceof ConditionalTypeNode) { return new ConditionalTypeNode( self::resolveForFile($node->subjectType, $file), @@ -384,147 +226,429 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode ); } - if ($node instanceof OffsetAccessTypeNode) { - $baseType = self::resolveForFile($node->type, $file); - $offsetType = self::resolveForFile($node->offset, $file); - - $offsetKey = null; - if ($offsetType instanceof ConstTypeNode) { - $expr = $offsetType->constExpr; - if ($expr instanceof ConstExprStringNode) { - $offsetKey = $expr->value; - } elseif ($expr instanceof ConstExprIntegerNode) { - $offsetKey = (int) $expr->value; - } - } elseif ($offsetType instanceof IdentifierTypeNode) { - $offsetKey = $offsetType->name; + if ($node instanceof NullableTypeNode) { + return new NullableTypeNode(self::resolveForFile($node->type, $file)); + } + + if ($node instanceof ArrayTypeNode) { + return new ArrayTypeNode(self::resolveForFile($node->type, $file)); + } + + if ($node instanceof UnionTypeNode) { + return new UnionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + } + + if ($node instanceof IntersectionTypeNode) { + return new IntersectionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + } + + return clone $node; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context + * + * @return \ReflectionClass|\ReflectionFunction|\ReflectionMethod + */ + private static function getReflectionContext(\ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context): \ReflectionClass|\ReflectionFunction|\ReflectionMethod + { + if (\is_string($context)) { + if (str_contains($context, '::')) { + [$className, $methodName] = explode('::', $context, 2); + + return new \ReflectionMethod($className, $methodName); } - if ($offsetKey !== null) { - if ($baseType instanceof ArrayShapeNode) { - foreach ($baseType->items as $item) { - $itemKey = null; - if ($item->keyName instanceof ConstExprStringNode) { - $itemKey = $item->keyName->value; - } elseif ($item->keyName instanceof IdentifierTypeNode) { - $itemKey = $item->keyName->name; - } elseif ($item->keyName instanceof ConstExprIntegerNode) { - $itemKey = (int) $item->keyName->value; - } + return new \ReflectionFunction($context); + } - if ((string) $itemKey === (string) $offsetKey) { - return $item->valueType; - } + return $context; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveIdentifier(IdentifierTypeNode $node, ?string $declaringClass, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref): IdentifierTypeNode + { + $lower = strtolower($node->name); + + if ($lower === '$this' || $lower === 'static') { + return $node; + } + + if ($lower === 'self' && $declaringClass !== null) { + return new IdentifierTypeNode($declaringClass); + } + + if ($lower === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + if ($parentClass !== false) { + return new IdentifierTypeNode($parentClass); + } + } + + $fqcn = self::resolveFqcn($node->name, $ref); + + return $fqcn !== $node->name ? new IdentifierTypeNode($fqcn) : $node; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveConstType(ConstTypeNode $node, ?string $declaringClass, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref): ConstTypeNode + { + if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { + $className = $node->constExpr->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' && $declaringClass !== null) { + $resolvedClass = $declaringClass; + } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + $resolvedClass = $parentClass !== false ? $parentClass : $className; + } else { + $resolvedClass = self::resolveFqcn($className, $ref); + } + + return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); + } + + return $node; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveOffsetAccess(OffsetAccessTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): TypeNode + { + $baseType = self::resolve($node->type, $ref, $thisObj); + $offsetType = self::resolve($node->offset, $ref, $thisObj); + + $offsetKey = self::extractOffsetKey($offsetType); + + if ($offsetKey !== null) { + if ($baseType instanceof ArrayShapeNode) { + foreach ($baseType->items as $item) { + $itemKey = self::extractItemKey($item->keyName); + if ((string) $itemKey === (string) $offsetKey) { + return $item->valueType; } } + } - if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { - $constExpr = $baseType->constExpr; - $fqcn = $constExpr->className; - $constName = $constExpr->name; - - if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { - $val = $constValue[$offsetKey]; - if (\is_string($val)) { - return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); - } elseif (\is_int($val)) { - return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); - } - } - } - } catch (\ReflectionException $e) { - } - } + if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { + $resolvedNode = self::resolveConstantOffsetValue($baseType->constExpr->className, $baseType->constExpr->name, $offsetKey); + if ($resolvedNode !== null) { + return $resolvedNode; } } + } - return new OffsetAccessTypeNode($baseType, $offsetType); + return new OffsetAccessTypeNode($baseType, $offsetType); + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): ArrayShapeNode + { + $items = array_map(function ($item) use ($ref, $thisObj) { + /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ + $keyName = $item->keyName; + + $className = null; + $constName = null; + + if ($keyName instanceof ConstFetchNode && $keyName->className !== '') { + $className = $keyName->className; + $constName = $keyName->name; + } elseif ($keyName instanceof IdentifierTypeNode && str_contains($keyName->name, '::')) { + [$className, $constName] = explode('::', $keyName->name, 2); + } elseif ($keyName instanceof ConstExprStringNode && str_contains($keyName->value, '::')) { + [$className, $constName] = explode('::', $keyName->value, 2); + } + + if ($className !== null && $constName !== null) { + $lowerClassName = strtolower($className); + $declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : null; + + if ($lowerClassName === 'self' && $declaringClass !== null) { + $resolvedClass = $declaringClass; + } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + $resolvedClass = $parentClass !== false ? $parentClass : $className; + } else { + $resolvedClass = self::resolveFqcn($className, $ref); + } + + $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $constName); + if ($resolvedKeyNode !== null) { + $keyName = $resolvedKeyNode; + } + } + + return new ArrayShapeItemNode( + $keyName, + $item->optional, + self::resolve($item->valueType, $ref, $thisObj) + ); + }, $node->items); + + if ($node->sealed) { + return ArrayShapeNode::createSealed($items, $node->kind); } - if ($node instanceof ArrayShapeNode) { - $items = array_map(function ($item) use ($file) { - return new ArrayShapeItemNode( - $item->keyName, - $item->optional, - self::resolveForFile($item->valueType, $file) - ); - }, $node->items); - - if ($node->sealed) { - return ArrayShapeNode::createSealed($items, $node->kind); + $unsealedType = null; + if ($node->unsealedType !== null) { + $unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $ref, $thisObj) : null; + $unsealedValue = self::resolve($node->unsealedType->valueType, $ref, $thisObj); + $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); + } + + return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): ObjectShapeNode + { + $items = array_map(function ($item) use ($ref, $thisObj) { + return new ObjectShapeItemNode( + $item->keyName, + $item->optional, + self::resolve($item->valueType, $ref, $thisObj) + ); + }, $node->items); + + return new ObjectShapeNode($items); + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveCallable(CallableTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): CallableTypeNode + { + $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($ref, $thisObj) { + return new CallableTypeParameterNode( + self::resolve($param->type, $ref, $thisObj), + $param->isReference, + $param->isVariadic, + $param->parameterName, + $param->isOptional + ); + }, $node->parameters); + + $resolvedReturnType = self::resolve($node->returnType, $ref, $thisObj); + + return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); + } + + private static function resolveConstTypeForFile(ConstTypeNode $node, string $file): ConstTypeNode + { + if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { + $className = $node->constExpr->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'parent') { + $resolvedClass = $className; } else { - $unsealedType = null; - if ($node->unsealedType !== null) { - $unsealedKey = $node->unsealedType->keyType !== null ? self::resolveForFile($node->unsealedType->keyType, $file) : null; - $unsealedValue = self::resolveForFile($node->unsealedType->valueType, $file); - $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); + $resolvedClass = self::resolveFqcnForFile($className, $file); + } + + return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); + } + + return clone $node; + } + + private static function resolveOffsetAccessForFile(OffsetAccessTypeNode $node, string $file): TypeNode + { + $baseType = self::resolveForFile($node->type, $file); + $offsetType = self::resolveForFile($node->offset, $file); + + $offsetKey = self::extractOffsetKey($offsetType); + + if ($offsetKey !== null) { + if ($baseType instanceof ArrayShapeNode) { + foreach ($baseType->items as $item) { + $itemKey = self::extractItemKey($item->keyName); + if ((string) $itemKey === (string) $offsetKey) { + return $item->valueType; + } } + } - return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { + $resolvedNode = self::resolveConstantOffsetValue($baseType->constExpr->className, $baseType->constExpr->name, $offsetKey); + if ($resolvedNode !== null) { + return $resolvedNode; + } } } - if ($node instanceof ObjectShapeNode) { - $items = array_map(function ($item) use ($file) { - return new ObjectShapeItemNode( - $item->keyName, - $item->optional, - self::resolveForFile($item->valueType, $file) - ); - }, $node->items); + return new OffsetAccessTypeNode($baseType, $offsetType); + } - return new ObjectShapeNode($items); - } + private static function resolveArrayShapeForFile(ArrayShapeNode $node, string $file): ArrayShapeNode + { + $items = array_map(function ($item) use ($file) { + /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ + $keyName = $item->keyName; + + $className = null; + $constName = null; + + if ($keyName instanceof ConstFetchNode && $keyName->className !== '') { + $className = $keyName->className; + $constName = $keyName->name; + } elseif ($keyName instanceof IdentifierTypeNode && str_contains($keyName->name, '::')) { + [$className, $constName] = explode('::', $keyName->name, 2); + } elseif ($keyName instanceof ConstExprStringNode && str_contains($keyName->value, '::')) { + [$className, $constName] = explode('::', $keyName->value, 2); + } - if ($node instanceof CallableTypeNode) { - $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($file) { - return new CallableTypeParameterNode( - self::resolveForFile($param->type, $file), - $param->isReference, - $param->isVariadic, - $param->parameterName, - $param->isOptional - ); - }, $node->parameters); - - $resolvedReturnType = self::resolveForFile($node->returnType, $file); - - return new CallableTypeNode( - $node->identifier, - $resolvedParameters, - $resolvedReturnType, - $node->templateTypes + if ($className !== null && $constName !== null) { + $lowerClassName = strtolower($className); + + if ($lowerClassName !== 'self' && $lowerClassName !== 'parent') { + $resolvedClass = self::resolveFqcnForFile($className, $file); + $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $constName); + if ($resolvedKeyNode !== null) { + $keyName = $resolvedKeyNode; + } + } + } + + return new ArrayShapeItemNode( + $keyName, + $item->optional, + self::resolveForFile($item->valueType, $file) ); + }, $node->items); + + if ($node->sealed) { + return ArrayShapeNode::createSealed($items, $node->kind); } - if ($node instanceof NullableTypeNode) { - return new NullableTypeNode(self::resolveForFile($node->type, $file)); + $unsealedType = null; + if ($node->unsealedType !== null) { + $unsealedKey = $node->unsealedType->keyType !== null ? self::resolveForFile($node->unsealedType->keyType, $file) : null; + $unsealedValue = self::resolveForFile($node->unsealedType->valueType, $file); + $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); } - if ($node instanceof ArrayTypeNode) { - return new ArrayTypeNode(self::resolveForFile($node->type, $file)); + return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + } + + private static function resolveObjectShapeForFile(ObjectShapeNode $node, string $file): ObjectShapeNode + { + $items = array_map(function ($item) use ($file) { + return new ObjectShapeItemNode( + $item->keyName, + $item->optional, + self::resolveForFile($item->valueType, $file) + ); + }, $node->items); + + return new ObjectShapeNode($items); + } + + private static function resolveCallableForFile(CallableTypeNode $node, string $file): CallableTypeNode + { + $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($file) { + return new CallableTypeParameterNode( + self::resolveForFile($param->type, $file), + $param->isReference, + $param->isVariadic, + $param->parameterName, + $param->isOptional + ); + }, $node->parameters); + + $resolvedReturnType = self::resolveForFile($node->returnType, $file); + + return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); + } + + // --- Shared Utilities --- + + private static function extractOffsetKey(TypeNode $offsetType): string|int|null + { + if ($offsetType instanceof ConstTypeNode) { + $expr = $offsetType->constExpr; + if ($expr instanceof ConstExprStringNode) { + return $expr->value; + } + if ($expr instanceof ConstExprIntegerNode) { + return (int) $expr->value; + } + } + if ($offsetType instanceof IdentifierTypeNode) { + return $offsetType->name; } - if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map( - fn ($t) => self::resolveForFile($t, $file), - $node->types - )); + return null; + } + + private static function extractItemKey(mixed $keyName): string|int|null + { + if ($keyName instanceof ConstExprStringNode) { + return $keyName->value; + } + if ($keyName instanceof IdentifierTypeNode) { + return $keyName->name; + } + if ($keyName instanceof ConstExprIntegerNode) { + return (int) $keyName->value; } - if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map( - fn ($t) => self::resolveForFile($t, $file), - $node->types - )); + return null; + } + + private static function resolveConstantOffsetValue(string $fqcn, string $constName, string|int $offsetKey): ?TypeNode + { + if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { + $val = $constValue[$offsetKey]; + if (\is_string($val)) { + return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); + } + if (\is_int($val)) { + return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); + } + } + } + } catch (\ReflectionException $e) { + } } - return clone $node; + return null; + } + + private static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null + { + if (class_exists($fqcn) || interface_exists($fqcn)) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $val = $refClass->getConstant($constName); + if (\is_string($val)) { + return new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED); + } + if (\is_int($val)) { + return new ConstExprIntegerNode((string) $val); + } + } + } catch (\ReflectionException $e) { + } + } + + return null; } /** diff --git a/tests/Command/CommandRunnerTest.php b/tests/Command/CommandRunnerTest.php index bd40b0c..30ee72f 100644 --- a/tests/Command/CommandRunnerTest.php +++ b/tests/Command/CommandRunnerTest.php @@ -54,16 +54,49 @@ expect($exitCode)->toBe(0); }); - test('returns exit code 1 when target file does not exist', function () { + test('returns exit code 1 and detects unknown command for typo like helps', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['helps'], $stream, $stream); + + rewind($stream); + $rawOutput = stream_get_contents($stream); + fclose($stream); + + $output = preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('Command "helps" is not defined') + ->and($output)->toContain('Did you mean one of these?') + ; + }); + + test('returns exit code 1 and warns when target file has non-PHP extension', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['index.js'], $stream, $stream); + + rewind($stream); + $rawOutput = stream_get_contents($stream); + fclose($stream); + + $output = preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('Target file "index.js" is not a PHP script file') + ; + }); + + test('returns exit code 1 when target script file ending in .php does not exist', function () { $stream = fopen('php://memory', 'r+'); $exitCode = CommandRunner::run(['non_existent_script_123.php'], $stream, $stream); rewind($stream); - $output = stream_get_contents($stream); + $rawOutput = stream_get_contents($stream); fclose($stream); + $output = preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + expect($exitCode)->toBe(1) - ->and($output)->toContain('Error') + ->and($output)->toContain('Target script file "non_existent_script_123.php" does not exist or is not readable') ; }); }); diff --git a/tests/Fixtures/Types/ClassStringFactoryContainer.php b/tests/Fixtures/Types/ClassStringFactoryContainer.php new file mode 100644 index 0000000..6bd01c7 --- /dev/null +++ b/tests/Fixtures/Types/ClassStringFactoryContainer.php @@ -0,0 +1,18 @@ + $class + */ + public static function makeCountable(string $class): string + { + return $class; + } +} diff --git a/tests/Fixtures/Types/ConstKeyContainer.php b/tests/Fixtures/Types/ConstKeyContainer.php new file mode 100644 index 0000000..3c56895 --- /dev/null +++ b/tests/Fixtures/Types/ConstKeyContainer.php @@ -0,0 +1,19 @@ +toBe($expected); }); + + test('wraps class constant array shape keys in quotes for legacy phpdoc-parser compatibility', function () { + $doc = '/** @param array{self::KEY_ID: int, App\Constants::ROLE: string} $payload */'; + + $expected = '/** @param array{"self::KEY_ID": int, "App\Constants::ROLE": string} $payload */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); }); diff --git a/tests/TypeChecking/AdvancedTypesAndEnumsTest.php b/tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php similarity index 100% rename from tests/TypeChecking/AdvancedTypesAndEnumsTest.php rename to tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php diff --git a/tests/TypeChecking/ArrayAndListTypesTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php similarity index 100% rename from tests/TypeChecking/ArrayAndListTypesTest.php rename to tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php diff --git a/tests/TypeChecking/ArraysAndShapes/ClassConstKeyShapeTest.php b/tests/TypeChecking/ArraysAndShapes/ClassConstKeyShapeTest.php new file mode 100644 index 0000000..2c28b92 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/ClassConstKeyShapeTest.php @@ -0,0 +1,36 @@ +process([ + 'user_id' => 42, + 'user_role' => 'admin', + ]))->toBeTrue(); + }); + + test('throws TypeError when array shape item with class constant key violates type contract', function () { + $container = new ConstKeyContainer(); + + expect(fn () => $container->process([ + 'user_id' => -5, + 'user_role' => 'admin', + ]))->toThrow(TypeError::class, "['user_id'] must be of type positive-int"); + }); + + test('throws TypeError when array shape references a non-existent class constant key', function () { + $container = new MissingConstKeyContainer(); + + expect(fn () => $container->process(['user_id' => 42])) + ->toThrow(TypeError::class, "is missing required key 'self::NON_EXISTENT_KEY'") + ; + }); + +}); diff --git a/tests/TypeChecking/KeyOfValueOfTest.php b/tests/TypeChecking/ArraysAndShapes/KeyOfValueOfTest.php similarity index 100% rename from tests/TypeChecking/KeyOfValueOfTest.php rename to tests/TypeChecking/ArraysAndShapes/KeyOfValueOfTest.php diff --git a/tests/TypeChecking/OffsetAccessTest.php b/tests/TypeChecking/ArraysAndShapes/OffsetAccessTest.php similarity index 100% rename from tests/TypeChecking/OffsetAccessTest.php rename to tests/TypeChecking/ArraysAndShapes/OffsetAccessTest.php diff --git a/tests/TypeChecking/UnionAndIntersectionTypesTest.php b/tests/TypeChecking/ArraysAndShapes/UnionAndIntersectionTypesTest.php similarity index 100% rename from tests/TypeChecking/UnionAndIntersectionTypesTest.php rename to tests/TypeChecking/ArraysAndShapes/UnionAndIntersectionTypesTest.php diff --git a/tests/TypeChecking/BlockScopeShadowingTest.php b/tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php similarity index 100% rename from tests/TypeChecking/BlockScopeShadowingTest.php rename to tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php diff --git a/tests/TypeChecking/ClosureVariableScopeTest.php b/tests/TypeChecking/Boundaries/ClosureVariableScopeTest.php similarity index 100% rename from tests/TypeChecking/ClosureVariableScopeTest.php rename to tests/TypeChecking/Boundaries/ClosureVariableScopeTest.php diff --git a/tests/TypeChecking/ExtendedReturnTypesTest.php b/tests/TypeChecking/Boundaries/ExtendedReturnTypesTest.php similarity index 100% rename from tests/TypeChecking/ExtendedReturnTypesTest.php rename to tests/TypeChecking/Boundaries/ExtendedReturnTypesTest.php diff --git a/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php b/tests/TypeChecking/Boundaries/IgnoreUnrecognizeDoctypeTest.php similarity index 100% rename from tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php rename to tests/TypeChecking/Boundaries/IgnoreUnrecognizeDoctypeTest.php diff --git a/tests/TypeChecking/ImportedFunctionsTest.php b/tests/TypeChecking/Boundaries/ImportedFunctionsTest.php similarity index 100% rename from tests/TypeChecking/ImportedFunctionsTest.php rename to tests/TypeChecking/Boundaries/ImportedFunctionsTest.php diff --git a/tests/TypeChecking/InlineVariableValidationTest.php b/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php similarity index 100% rename from tests/TypeChecking/InlineVariableValidationTest.php rename to tests/TypeChecking/Boundaries/InlineVariableValidationTest.php diff --git a/tests/TypeChecking/ListDestructuringTest.php b/tests/TypeChecking/Boundaries/ListDestructuringTest.php similarity index 100% rename from tests/TypeChecking/ListDestructuringTest.php rename to tests/TypeChecking/Boundaries/ListDestructuringTest.php diff --git a/tests/TypeChecking/NamedArgumentsTest.php b/tests/TypeChecking/Boundaries/NamedArgumentsTest.php similarity index 100% rename from tests/TypeChecking/NamedArgumentsTest.php rename to tests/TypeChecking/Boundaries/NamedArgumentsTest.php diff --git a/tests/TypeChecking/ParamContractsTest.php b/tests/TypeChecking/Boundaries/ParamContractsTest.php similarity index 100% rename from tests/TypeChecking/ParamContractsTest.php rename to tests/TypeChecking/Boundaries/ParamContractsTest.php diff --git a/tests/TypeChecking/PropertyHooksTest.php b/tests/TypeChecking/Boundaries/PropertyHooksTest.php similarity index 100% rename from tests/TypeChecking/PropertyHooksTest.php rename to tests/TypeChecking/Boundaries/PropertyHooksTest.php diff --git a/tests/TypeChecking/PropertyValidationTest.php b/tests/TypeChecking/Boundaries/PropertyValidationTest.php similarity index 100% rename from tests/TypeChecking/PropertyValidationTest.php rename to tests/TypeChecking/Boundaries/PropertyValidationTest.php diff --git a/tests/TypeChecking/ReturnContractsTest.php b/tests/TypeChecking/Boundaries/ReturnContractsTest.php similarity index 100% rename from tests/TypeChecking/ReturnContractsTest.php rename to tests/TypeChecking/Boundaries/ReturnContractsTest.php diff --git a/tests/TypeChecking/VarAnnotationPrebindingTest.php b/tests/TypeChecking/Boundaries/VarAnnotationPrebindingTest.php similarity index 100% rename from tests/TypeChecking/VarAnnotationPrebindingTest.php rename to tests/TypeChecking/Boundaries/VarAnnotationPrebindingTest.php diff --git a/tests/TypeChecking/CallableAndClosureContractsTest.php b/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php similarity index 100% rename from tests/TypeChecking/CallableAndClosureContractsTest.php rename to tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php diff --git a/tests/TypeChecking/LazyIteratorsAndGeneratorsTest.php b/tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php similarity index 100% rename from tests/TypeChecking/LazyIteratorsAndGeneratorsTest.php rename to tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php diff --git a/tests/TypeChecking/BoundaryConfigTest.php b/tests/TypeChecking/Configuration/BoundaryConfigTest.php similarity index 100% rename from tests/TypeChecking/BoundaryConfigTest.php rename to tests/TypeChecking/Configuration/BoundaryConfigTest.php diff --git a/tests/TypeChecking/DocblockIgnoreTagsTest.php b/tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php similarity index 71% rename from tests/TypeChecking/DocblockIgnoreTagsTest.php rename to tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php index 6cbce28..d636966 100644 --- a/tests/TypeChecking/DocblockIgnoreTagsTest.php +++ b/tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php @@ -52,4 +52,23 @@ function testIgnoredFunction(int $id): int $result = $fileFixture->process(-500); expect($result)->toBe(-500); }); + + test('skips inline variable validation inside methods marked with @typephp-ignore', function () { + $fixture = new class () { + /** + * @typephp-ignore + * + * @param positive-int $id + */ + public function ignoredMethodWithInlineVar(int $id): bool + { + /** @var string */ + $string = 1; // Invalid type assignment, but skipped because of @typephp-ignore above! + + return true; + } + }; + + expect($fixture->ignoredMethodWithInlineVar(-500))->toBeTrue(); + }); }); diff --git a/tests/TypeChecking/RecursionAndExceptionLeakTest.php b/tests/TypeChecking/Configuration/RecursionAndExceptionLeakTest.php similarity index 100% rename from tests/TypeChecking/RecursionAndExceptionLeakTest.php rename to tests/TypeChecking/Configuration/RecursionAndExceptionLeakTest.php diff --git a/tests/TypeChecking/RespectIgnoreTagsConfigTest.php b/tests/TypeChecking/Configuration/RespectIgnoreTagsConfigTest.php similarity index 100% rename from tests/TypeChecking/RespectIgnoreTagsConfigTest.php rename to tests/TypeChecking/Configuration/RespectIgnoreTagsConfigTest.php diff --git a/tests/TypeChecking/AdvancedGenericsAndShapesTest.php b/tests/TypeChecking/Generics/AdvancedGenericsAndShapesTest.php similarity index 100% rename from tests/TypeChecking/AdvancedGenericsAndShapesTest.php rename to tests/TypeChecking/Generics/AdvancedGenericsAndShapesTest.php diff --git a/tests/TypeChecking/CloneGenericInstanceTest.php b/tests/TypeChecking/Generics/CloneGenericInstanceTest.php similarity index 100% rename from tests/TypeChecking/CloneGenericInstanceTest.php rename to tests/TypeChecking/Generics/CloneGenericInstanceTest.php diff --git a/tests/TypeChecking/ConditionalTypesWithGenericsTest.php b/tests/TypeChecking/Generics/ConditionalTypesWithGenericsTest.php similarity index 100% rename from tests/TypeChecking/ConditionalTypesWithGenericsTest.php rename to tests/TypeChecking/Generics/ConditionalTypesWithGenericsTest.php diff --git a/tests/TypeChecking/DefaultTemplateTypesTest.php b/tests/TypeChecking/Generics/DefaultTemplateTypesTest.php similarity index 100% rename from tests/TypeChecking/DefaultTemplateTypesTest.php rename to tests/TypeChecking/Generics/DefaultTemplateTypesTest.php diff --git a/tests/TypeChecking/GenericPropertyHooksTest.php b/tests/TypeChecking/Generics/GenericPropertyHooksTest.php similarity index 100% rename from tests/TypeChecking/GenericPropertyHooksTest.php rename to tests/TypeChecking/Generics/GenericPropertyHooksTest.php diff --git a/tests/TypeChecking/GenericsAndInheritanceTest.php b/tests/TypeChecking/Generics/GenericsAndInheritanceTest.php similarity index 100% rename from tests/TypeChecking/GenericsAndInheritanceTest.php rename to tests/TypeChecking/Generics/GenericsAndInheritanceTest.php diff --git a/tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php b/tests/TypeChecking/Generics/InheritedGenericCloneAndConditionalTest.php similarity index 100% rename from tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php rename to tests/TypeChecking/Generics/InheritedGenericCloneAndConditionalTest.php diff --git a/tests/TypeChecking/AttributeConstructorInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/AttributeConstructorInheritanceTest.php similarity index 100% rename from tests/TypeChecking/AttributeConstructorInheritanceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/AttributeConstructorInheritanceTest.php diff --git a/tests/TypeChecking/DeepInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/DeepInheritanceTest.php similarity index 100% rename from tests/TypeChecking/DeepInheritanceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/DeepInheritanceTest.php diff --git a/tests/TypeChecking/LiskovAndVendorIsolationTest.php b/tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php similarity index 100% rename from tests/TypeChecking/LiskovAndVendorIsolationTest.php rename to tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php diff --git a/tests/TypeChecking/NamespaceResolutionTest.php b/tests/TypeChecking/InheritanceAndAttributes/NamespaceResolutionTest.php similarity index 100% rename from tests/TypeChecking/NamespaceResolutionTest.php rename to tests/TypeChecking/InheritanceAndAttributes/NamespaceResolutionTest.php diff --git a/tests/TypeChecking/OopInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/OopInheritanceTest.php similarity index 100% rename from tests/TypeChecking/OopInheritanceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/OopInheritanceTest.php diff --git a/tests/TypeChecking/PhpAttributesCoexistenceTest.php b/tests/TypeChecking/InheritanceAndAttributes/PhpAttributesCoexistenceTest.php similarity index 100% rename from tests/TypeChecking/PhpAttributesCoexistenceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/PhpAttributesCoexistenceTest.php diff --git a/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php b/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php new file mode 100644 index 0000000..a41aca8 --- /dev/null +++ b/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php @@ -0,0 +1,28 @@ + Subtype Validation', function () { + + test('accepts concrete class string implementing the target interface', function () { + expect(ClassStringFactoryContainer::makeCountable(CountableArrayAccess::class)) + ->toBe(CountableArrayAccess::class) + ; + }); + + test('accepts the interface string itself', function () { + expect(ClassStringFactoryContainer::makeCountable(Countable::class)) + ->toBe(Countable::class) + ; + }); + + test('throws TypeError when class string does not implement the target interface', function () { + expect(fn () => ClassStringFactoryContainer::makeCountable(Car::class)) + ->toThrow(TypeError::class, 'must be a class-string of Countable') + ; + }); +}); diff --git a/tests/TypeChecking/ExtendedScalarTypesTest.php b/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php similarity index 100% rename from tests/TypeChecking/ExtendedScalarTypesTest.php rename to tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php diff --git a/tests/TypeChecking/FloatLiteralsTest.php b/tests/TypeChecking/Scalars/FloatLiteralsTest.php similarity index 100% rename from tests/TypeChecking/FloatLiteralsTest.php rename to tests/TypeChecking/Scalars/FloatLiteralsTest.php diff --git a/tests/TypeChecking/IntMaskTest.php b/tests/TypeChecking/Scalars/IntMaskTest.php similarity index 100% rename from tests/TypeChecking/IntMaskTest.php rename to tests/TypeChecking/Scalars/IntMaskTest.php diff --git a/tests/TypeChecking/NewScalarAndPseudoTypesTest.php b/tests/TypeChecking/Scalars/NewScalarAndPseudoTypesTest.php similarity index 100% rename from tests/TypeChecking/NewScalarAndPseudoTypesTest.php rename to tests/TypeChecking/Scalars/NewScalarAndPseudoTypesTest.php diff --git a/tests/TypeChecking/UppercaseAndArrayKeyTest.php b/tests/TypeChecking/Scalars/UppercaseAndArrayKeyTest.php similarity index 100% rename from tests/TypeChecking/UppercaseAndArrayKeyTest.php rename to tests/TypeChecking/Scalars/UppercaseAndArrayKeyTest.php