├── LICENSE ├── README.md ├── composer.json └── generics ├── Concrete.php ├── Enable.php ├── GenericTrait.php ├── Internal ├── Container.php ├── GenericsVisitor.php ├── Loader.php ├── VirtualFile.php ├── model │ ├── ArrowAstAnalyzer.php │ └── ClassAstAnalyzer.php ├── service │ ├── ComposerAdapter.php │ ├── Opcache.php │ └── PharWrapperAdapter.php ├── tokens │ ├── ClassAggregate.php │ ├── ConcreteInstantiationToken.php │ ├── FileAggregate.php │ ├── MethodHeaderAggregate.php │ ├── Parameter.php │ └── Token.php └── view │ ├── ConcreteView.php │ └── Transformer.php ├── ReturnT.php ├── T.php └── TypeError.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Grigori Kochanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generics in PHP 2 | 3 | * [Story of generics in PHP](documentation/story.md) 4 | * [Why do you need generics programming](#why-do-you-need-generics) 5 | * [How to use this package](#how-to-use) 6 | * [Syntax](documentation/syntax.md) 7 | * [System requirements and compatibility](documentation/compatibility.md) 8 | * [Solution diagrams](documentation/implementation.md) 9 | * [Performance impact](documentation/performance.md) 10 | 11 | ### Why do you need generics? 12 | [Generic programming](https://en.wikipedia.org/wiki/Generic_programming) is an algorithm where data types are declared as "to-be-specified-later", when needed. 13 | 14 | It allows writing much less code, and have data types checked by the PHP engine in data sets. 15 | 16 | Data may have unexpected structure, especially when it is obtained from databases, APIs, and 3rd party code. 17 | For single-value variables we define parameter types, but for the composite types such as array, ArrayObject, 18 | SplFixedArray one cannot define types of values in runtime. 19 | To define data types for values we could create multiple classes with the same code, 20 | where the only difference would be a type of a parameter. 21 | E.g. 22 | ```php 23 | class CollectionInt extends \ArrayObject{ 24 | public function offsetSet(int $key, int $value ) 25 | { 26 | parent::offsetSet($key,$value); 27 | } 28 | } 29 | class CollectionFoo extends \ArrayObject{ 30 | public function offsetSet(int $key, \Foo $value ) 31 | { 32 | parent::offsetSet($key,$value); 33 | } 34 | } 35 | ``` 36 | This feels wrong, and violates the "Don't repeat yourself" principle. 37 | 38 | Generics allow defining types of parameters when you create an instance, with just one short clause. 39 | And yet you have just one class declaration for all types you need. 40 | 41 | ### How to use 42 | 1. Add the package as a dependency for Composer, as usually: `composer require grikdotnet\generics`. 43 | 2. Call `new \grikdotnet\generics\Enable();` in bootstrap to enable the class loader. 44 | 3. Declare a wildcard class. 45 | 46 | ```php 47 | #[\Generics\T] 48 | class Collection extends \ArrayObject{ 49 | use \grikdotnet\generics\GenericTrait; 50 | 51 | public function offsetSet(#[\Generics\T] $key, #[\Generics\T] $value ) 52 | { 53 | parent::offsetSet($key,$value); 54 | } 55 | } 56 | ``` 57 | 58 | Using the trait is optional. It provides a convenient shortcut method `T()` to create concrete types: 59 | 60 | ```php 61 | /** @var Collection $collection */ 62 | $collection = new (Collection::T("int","float"))(); 63 | $collection[] = 0.5; 64 | ``` 65 | That's it. Now PHP will check the type of values added to the ArrayObject instance, and trigger a TypeError 66 | when the type does not match. 67 | 68 | Now let's use the typed Collection as a parameter type in a method: 69 | 70 | ```php 71 | class Model{ 72 | /** 73 | * @param Collection $numeric 74 | * @return int 75 | */ 76 | public function multiply(#[\Generics\T("Collection")] $numeric): int 77 | { 78 | $result = 0; 79 | for ($numeric as $key => $value) { 80 | $value *= 2; 81 | } 82 | return $result; 83 | } 84 | } 85 | ``` 86 | 87 | This way data types of elements are checked by the PHP engine, and 88 | we can avoid writing a lot of type checks in a loop over data sets in every method. 89 | 90 | Find more about syntax in the [documentation](documentation/syntax.md). -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grikdotnet/generics", 3 | "description": "generics implementation in PHP", 4 | "type": "library", 5 | "require": { 6 | "php": ">=8.2", 7 | "ext-tokenizer": "*", 8 | "ext-ctype": "*", 9 | "ext-mbstring":"*", 10 | "ext-zend-opcache": "*", 11 | "composer-runtime-api": "*", 12 | "nikic/php-parser": "^5.4" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "grikdotnet\\generics\\": "generics/", 17 | "Generics\\": "generics/" 18 | } 19 | }, 20 | "license": "MIT", 21 | "authors": [ 22 | { 23 | "name": "Grigori Kochanov" 24 | } 25 | ], 26 | "minimum-stability": "dev", 27 | "prefer-stable": true, 28 | "require-dev": { 29 | "phpunit/phpunit": "^12", 30 | "guzzlehttp/guzzle": "^7.9" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /generics/Concrete.php: -------------------------------------------------------------------------------- 1 | createConcreteClass($wildcard_class_name,$types) 35 | ){ 36 | return $concrete_class_name; 37 | } 38 | throw new \RuntimeException('Could not create concrete class '.$concrete_class_name); 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /generics/Enable.php: -------------------------------------------------------------------------------- 1 | getAttributes(T::class)) { 18 | throw new \RuntimeException('The class '.__CLASS__.' is not a generic template'); 19 | } 20 | return Concrete::createClass(__CLASS__,$types); 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /generics/Internal/Container.php: -------------------------------------------------------------------------------- 1 | $fileAggregates 17 | */ 18 | public array $fileAggregates = []; 19 | /** 20 | * @var array $classAggregates 21 | */ 22 | public array $classAggregates = []; 23 | /** 24 | * @var array 25 | */ 26 | public array $instantiations = []; 27 | /** 28 | * @var array 29 | */ 30 | public array $skip_files = []; 31 | 32 | /** 33 | * Populated from OpCache, contains dehydrated tokens as numeric arrays 34 | * @var array 35 | */ 36 | private array $classes_tokens_cache = []; 37 | 38 | /** 39 | * Populated from OpCache, contains dehydrated tokens as numeric arrays 40 | * @var array 41 | */ 42 | private array $files_tokens_cache = []; 43 | 44 | /** 45 | * Populated from OpCache, contains dehydrated tokens as numeric arrays 46 | * @var array 47 | */ 48 | private array $instantiations_cache = []; 49 | 50 | /** 51 | * A boolean mask marking which items were added to store in Opcache 52 | * @var int 53 | */ 54 | private int $modified = 0; 55 | 56 | /** 57 | * A singleton implementation 58 | * @return self 59 | */ 60 | public static function getInstance(): self 61 | { 62 | return self::$instance ?? self::$instance = new self(); 63 | } 64 | private function __construct() 65 | {} 66 | 67 | 68 | /** 69 | * @param class-string $class_name 70 | * @return ClassAggregate|null 71 | */ 72 | public function getClassTokens(string $class_name): ?ClassAggregate 73 | { 74 | if (isset($this->classAggregates[$class_name])) { 75 | return $this->classAggregates[$class_name]; 76 | } 77 | if (isset($this->classes_tokens_cache[$class_name])) { 78 | return $this->classAggregates[$class_name] = ClassAggregate::fromArray($this->classes_tokens_cache[$class_name]); 79 | } 80 | return null; 81 | } 82 | 83 | /** 84 | * @param string $path 85 | * @return FileAggregate|null 86 | */ 87 | public function getFileTokens(string $path): ?FileAggregate 88 | { 89 | if (isset($this->fileAggregates[$path])) { 90 | return $this->fileAggregates[$path]; 91 | } 92 | if (isset($this->files_tokens_cache[$path])) { 93 | return $this->fileAggregates[$path] = FileAggregate::fromArray($this->files_tokens_cache[$path]); 94 | } 95 | return null; 96 | } 97 | 98 | 99 | /** 100 | * @param class-string $class 101 | * @return ConcreteInstantiationToken|null 102 | */ 103 | public function getInstantiationTokens(string $class): ?ConcreteInstantiationToken 104 | { 105 | if (isset($this->instantiations[$class])) { 106 | return $this->instantiations[$class]; 107 | } 108 | if (isset($this->instantiations_cache[$class])) { 109 | return $this->instantiations[$class] = new ConcreteInstantiationToken(...$this->instantiations_cache[$class]); 110 | } 111 | return null; 112 | } 113 | 114 | /** 115 | * @param FileAggregate $fileAggregate 116 | * @return void 117 | */ 118 | public function addFileTokens(FileAggregate $fileAggregate): void 119 | { 120 | $this->fileAggregates[$fileAggregate->path] = $fileAggregate; 121 | foreach ($fileAggregate->classAggregates as $c) { 122 | $this->classAggregates[$c->classname] = $c; 123 | } 124 | foreach ($fileAggregate->instantiations as $i) { 125 | $this->instantiations[ltrim($i->concrete_name,'\\')] = $i; 126 | } 127 | $this->modified |= 1; 128 | } 129 | 130 | /** 131 | * @param string $path 132 | * @return void 133 | */ 134 | public function addToSkipFiles(string $path): void 135 | { 136 | $this->skip_files[] = $path; 137 | $this->modified |= 1; 138 | } 139 | 140 | public function isModified(): bool 141 | { 142 | return $this->modified !== 0; 143 | } 144 | public function areNewTokens(): bool 145 | { 146 | return (bool)($this->modified & 1); 147 | } 148 | 149 | /** 150 | * Create a reference to opcache during initialization 151 | * 152 | * @param array $file_tokens 153 | * @param array $class_tokens 154 | * @param array $instantiations 155 | * @param array $skip_files 156 | * @return void 157 | */ 158 | public function setCache(array $file_tokens, array $class_tokens, array $instantiations, array $skip_files): void 159 | { 160 | $this->files_tokens_cache = $file_tokens; 161 | $this->classes_tokens_cache = $class_tokens; 162 | $this->instantiations_cache = $instantiations; 163 | $this->skip_files = $skip_files; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /generics/Internal/GenericsVisitor.php: -------------------------------------------------------------------------------- 1 | source_code); 47 | $classAggregate = $analyzer->do($node); 48 | if ($classAggregate->hasGenerics()) { 49 | $this->classes[$classAggregate->getFQCN()] = $classAggregate; 50 | } 51 | } 52 | if ($node instanceof \PhpParser\Node\Expr\ArrowFunction) { 53 | if ($token = ArrowAstAnalyzer::do($this->source_code,$node)) { 54 | $this->instantiations[$token->offset] = $token; 55 | } 56 | } 57 | return null; 58 | } 59 | 60 | public function getFileTokens(): FileAggregate 61 | { 62 | return new FileAggregate($this->path,$this->classes,$this->instantiations); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /generics/Internal/Loader.php: -------------------------------------------------------------------------------- 1 | composer = new ComposerAdapter(...$loaders); 38 | self::$instance = $this; 39 | $this->opcache = new Opcache; 40 | spl_autoload_register($this->autoloader(...), true,true); 41 | if ($this->opcache->is_available) { 42 | $this->opcache->linkCachedTokens($this->container); 43 | register_shutdown_function(fn()=>$this->opcache->store($container)); 44 | } 45 | } 46 | 47 | /** 48 | * Called from the PHP core. 49 | * Reads the source file, parses, transforms, and stores to Zend OpCache 50 | * 51 | * @param class-string $class 52 | * @return bool 53 | */ 54 | public function autoloader(string $class): bool 55 | { 56 | // if the class is a known concrete type, create it 57 | if (null !== $iToken = $this->container->getInstantiationTokens($class)) { 58 | return $this->createConcreteClass($iToken->type,$iToken->concrete_types); 59 | } 60 | //find class location via Composer 61 | if (!($path = $this->composer->findClassFile($class))) { 62 | return false; 63 | } 64 | $tokens = $content = false; 65 | 66 | if ($this->opcache->is_available) { 67 | if ($tokens = $this->container->getFileTokens($path)) { 68 | if ($this->opcache->includeAugmentedFile($path)) { 69 | //the augmented content and tokens are found in the cache 70 | return class_exists($class,false); 71 | } 72 | } elseif (in_array($path,$this->container->skip_files)) { 73 | return false; 74 | } 75 | } 76 | 77 | if ( !($content = $this->getFileContents($path)) ) { 78 | $this->container->addToSkipFiles($path); 79 | return false; 80 | } 81 | 82 | if (!$tokens) { 83 | $tokens = $this->parse($path, $content); 84 | if ($tokens->isEmpty()) { 85 | $this->container->addToSkipFiles($path); 86 | // nothing to do with this file 87 | return false; 88 | } 89 | $this->container->addFileTokens($tokens); 90 | } 91 | $augmented = Transformer::augment($content,$tokens); 92 | PharWrapperAdapter::include($path,$augmented); 93 | 94 | return class_exists($class,false); 95 | } 96 | 97 | /** 98 | * Creates concrete classes based on the wildcard templates 99 | * 100 | * @param class-string $wildcard_class 101 | * @param string[] $types 102 | * @return bool 103 | */ 104 | public function createConcreteClass(string $wildcard_class, array $types): bool 105 | { 106 | $wildcard_class = ltrim($wildcard_class,'\\'); 107 | $concrete_class_name = ConcreteView::makeConcreteName($wildcard_class, $types); 108 | if ($this->opcache->is_available) { 109 | if ($this->opcache->loadVirtualClass($concrete_class_name)) { 110 | return true; 111 | } 112 | } 113 | $classAggregate = $this->container->getClassTokens($wildcard_class); 114 | if ($classAggregate === null) { 115 | //read and parse the wildcard source file 116 | if (!($path = $this->composer->findClassFile($wildcard_class))) { 117 | return false; 118 | } 119 | if (in_array($path,$this->container->skip_files)) { 120 | return false; 121 | } 122 | if (null === $tokens = $this->container->getFileTokens($path)) { 123 | if ( !($content = $this->getFileContents($path)) ) { 124 | $this->container->addToSkipFiles($path); 125 | return false; 126 | } 127 | $tokens = $this->parse($path, $content); 128 | if ($tokens->isEmpty()) { 129 | $this->container->addToSkipFiles($path); 130 | // nothing to do with this file 131 | return false; 132 | } 133 | $this->container->addFileTokens($tokens); 134 | } 135 | if (!isset($tokens->classAggregates[$wildcard_class])) { 136 | return false; 137 | } 138 | $classAggregate = $tokens->classAggregates[$wildcard_class]; 139 | } 140 | 141 | $View = new ConcreteView($classAggregate); 142 | $class_declaration = $View->generateConcreteDeclaration($types); 143 | if ($this->opcache->is_available) { 144 | $this->opcache->includeVirtualClass($concrete_class_name,$class_declaration); 145 | } else { 146 | eval($class_declaration); 147 | } 148 | 149 | return class_exists($concrete_class_name,false); 150 | } 151 | 152 | /** 153 | * An adapter for the parser 154 | * 155 | * @param string $path 156 | * @param string $content 157 | * @return FileAggregate 158 | */ 159 | private function parse(string $path, string $content): FileAggregate 160 | { 161 | $errorHandler = new Collecting; 162 | $parser = new Php8(new Lexer); 163 | $ast = $parser->parse($content, $errorHandler); 164 | //the Visitor class is actually a model with logic 165 | $nameResolver = new \PhpParser\NodeVisitor\NameResolver; 166 | $visitor = new GenericsVisitor($path, $content); 167 | $traverser = new NodeTraverser(); 168 | $traverser->addVisitor($nameResolver); 169 | $traverser->addVisitor($visitor); 170 | $traverser->traverse($ast); 171 | 172 | return $visitor->getFileTokens(); 173 | } 174 | 175 | 176 | /** 177 | * @param string $path 178 | * @return string|false 179 | */ 180 | private function getFileContents(string $path): string|false 181 | { 182 | if (file_exists($path) && is_readable($path)) { 183 | $content = file_get_contents($path); 184 | if ( !$content || !str_contains($content,'Generics\\') ) { 185 | return false; 186 | } 187 | return $content; 188 | } 189 | return false; 190 | } 191 | 192 | } 193 | -------------------------------------------------------------------------------- /generics/Internal/VirtualFile.php: -------------------------------------------------------------------------------- 1 | path,$this->content,$this->reference_path]; 15 | } 16 | } -------------------------------------------------------------------------------- /generics/Internal/model/ArrowAstAnalyzer.php: -------------------------------------------------------------------------------- 1 | attrGroups as $group) 17 | foreach ($group->attrs as $attr) 18 | if (0 === strcasecmp($attr->name->name, 'Generics\T')) { 19 | $attribute = $attr; 20 | break 2; 21 | } 22 | if (!isset($attribute)) { 23 | return null; 24 | } 25 | if (! $node->expr instanceof \PhpParser\Node\Expr\New_) { 26 | return null; 27 | } 28 | 29 | if (!isset($attribute->args[0])) { 30 | throw new \TypeError('Missing concrete type for the generic instance'); 31 | } 32 | $parameterNode = $attribute->args[0]->value; 33 | 34 | if ($parameterNode instanceof \PhpParser\Node\Expr\ConstFetch) { 35 | $concrete_types[] = substr( 36 | $source_code, 37 | $s = $parameterNode->getStartFilePos(), 38 | $parameterNode->getEndFilePos() - $s +1 39 | ); 40 | } elseif ($parameterNode instanceof \PhpParser\Node\Scalar\String_) { 41 | $concrete_types[] = $parameterNode->value; 42 | } else{ 43 | throw new \TypeError('Invalid parameter type for the generic instance'); 44 | } 45 | $wildcard_class = substr($source_code, 46 | $s = $node->expr->class->getStartFilePos(), 47 | $node->expr->class->getEndFilePos() - $s +1 48 | ); 49 | 50 | $concretw_class_name = ConcreteView::makeConcreteName($wildcard_class,$concrete_types); 51 | 52 | return new ConcreteInstantiationToken( 53 | offset: $node->expr->class->getStartFilePos(), 54 | length: strlen($wildcard_class), 55 | type: $wildcard_class, 56 | concrete_types: $concrete_types, 57 | concrete_name: $concretw_class_name 58 | ); 59 | } 60 | } -------------------------------------------------------------------------------- /generics/Internal/model/ClassAstAnalyzer.php: -------------------------------------------------------------------------------- 1 | 0,'abstract'=>0,'and'=>0,'array'=>0,'as'=>0,'break'=>0,'callable'=>0,'case'=>0,'catch'=>0,'class'=>0,'clone'=>0,'const'=>0,'continue'=>0,'declare'=>0,'default'=>0,'die'=>0,'do'=>0,'echo'=>0,'else'=>0,'elseif'=>0,'empty'=>0,'enddeclare'=>0,'endfor'=>0,'endforeach'=>0,'endif'=>0,'endswitch'=>0,'endwhile'=>0,'enum'=>0,'eval'=>0,'exit'=>0,'extends'=>0,'final'=>0,'finally'=>0,'for'=>0,'foreach'=>0,'fn'=>0,'function'=>0,'global'=>0,'goto'=>0,'if'=>0,'implements'=>0,'include'=>0,'include_once'=>0,'instanceof'=>0,'insteadof'=>0,'interface'=>0,'match'=>0,'isset'=>0,'list'=>0,'namespace'=>0,'never'=>0,'new'=>0,'object'=>0,'or'=>0,'print'=>0,'private'=>0,'protected'=>0,'public'=>0,'require'=>0,'require_once'=>0,'return'=>0,'switch'=>0,'throw'=>0,'trait'=>0,'try'=>0,'unset'=>0,'use'=>0,'var'=>0,'void'=>0,'while'=>0,'xor'=>0,'yield'=>0,'self'=>0,'parent'=>0,'static'=>0,'__class__'=>0,'__dir__'=>0,'__file__'=>0,'__function__'=>0,'__line__'=>0,'__method__'=>0,'__namespace__'=>0,'__trait__'=>0]; 27 | public function __construct( 28 | private readonly string $source_code, 29 | ){} 30 | 31 | public function do(Class_ $node): ClassAggregate 32 | { 33 | if (isset($node->namespacedName) && $node->namespacedName->name !== $node->name->name) { 34 | $namespace = substr($node->namespacedName->name,0,strrpos($node->namespacedName->name,'\\')); 35 | } 36 | $class = new ClassAggregate($node->name->name, $namespace??''); 37 | $this->fqcn = isset($node->namespacedName) ? $node->namespacedName->name : $node->name->name; 38 | 39 | //check if the class has a #[\Generics\T] attribute 40 | foreach ($node->attrGroups as $group) 41 | foreach ($group->attrs as $attr) 42 | if (0 === strcasecmp($attr->name->name, 'Generics\T')) { 43 | if ($node->isFinal()) { 44 | throw new \ParseError('A template class can not be final: '.$node->name->name); 45 | } 46 | $class->setIsTemplate(); 47 | break 2; 48 | } 49 | 50 | $substitutions = []; 51 | foreach ($node->getMethods() as $method) { 52 | if (!($methodAggregate = $this->makeMethodAggregate($method))) { 53 | continue; 54 | } 55 | $is_generic = false; 56 | foreach ($method->attrGroups as $attrGroup) { 57 | foreach ($attrGroup->attrs as $methodAttribute) 58 | if ($methodAttribute->name->name === 'Generics\ReturnT'){ 59 | $methodAggregate->setWildcardReturn(); 60 | $is_generic = true; 61 | } 62 | } 63 | 64 | foreach ($method->params as $param) { 65 | //find parameters with a #[\Generics\T] attribute 66 | foreach ($param->attrGroups as $attrGroup) 67 | foreach ($attrGroup->attrs as $attr) 68 | if ($attr->name->name == 'Generics\T') { 69 | if ($attr->args === []) { 70 | if (!$class->isTemplate()) { 71 | $message = 'Missing concrete type of the generic parameter ' 72 | . $this->fqcn . '::' . $method->name->name . '($' . $param->var->name . ')' 73 | . ' on line ' . $attr->getLine(); 74 | throw new \ParseError($message); 75 | } 76 | $token = $this->wildcardParameter($method->name->name, $param); 77 | } else { 78 | //this is a concrete generic type parameter, i.e. Foo 79 | $token = $this->concreteParameter($method->name->name, $param, $attr); 80 | } 81 | $methodAggregate->addParameter($token); 82 | $is_generic = true; 83 | continue 3; 84 | } 85 | $methodAggregate->addParameter(new Parameter( 86 | offset: $s = $param->getStartFilePos(), 87 | length: $param->var->getEndFilePos() - $s +1, 88 | name: $param->var->name, 89 | //type will include variadic and reference modifiers 90 | type: ($s === $param->var->getStartFilePos()) 91 | ? '' 92 | : trim(substr($this->source_code,$s,$param->var->getStartFilePos()-$s)) 93 | )); 94 | } 95 | 96 | if ($is_generic) { 97 | $class->addMethodAggregate($methodAggregate); 98 | } 99 | } 100 | return $class; 101 | } 102 | 103 | private function makeMethodAggregate(ClassMethod $classMethod): MethodHeaderAggregate| false 104 | { 105 | $s = $classMethod->getStartFilePos(); 106 | $void = false; 107 | if ($classMethod->returnType) { 108 | $header_end_position = $classMethod->returnType->getEndFilePos()+1; 109 | $headline = substr($this->source_code, $s, $header_end_position - $s); 110 | if ('void' == $classMethod->returnType) { 111 | $void = true; 112 | } 113 | } elseif ($classMethod->params !== []) { 114 | $header_end_position = end($classMethod->params)->getEndFilePos()+1; 115 | $headline = substr($this->source_code, $s, $header_end_position - $s).')'; 116 | } else { 117 | return false; 118 | } 119 | $headline = ConcreteView::strip($headline); 120 | return new MethodHeaderAggregate( 121 | offset: $s, 122 | length: $header_end_position - $s, 123 | name: $classMethod->name->name, 124 | headline: $headline, 125 | void: $void, 126 | ); 127 | } 128 | 129 | /** 130 | * @param string $method_name 131 | * @param Param $param 132 | * @return Parameter 133 | */ 134 | private function wildcardParameter(string $method_name, Param $param): Parameter 135 | { 136 | $name = $param->var->name; 137 | if ($param->type !== null) { 138 | $message = 'The template parameter should have no type in '.$this->fqcn.'::'.$method_name 139 | .'('.$param->type->name .' $'.$name.') line '. $param->getLine(); 140 | throw new \ParseError($message); 141 | } 142 | return new Parameter( 143 | offset: $s = $param->var->getStartFilePos(), 144 | length: $param->var->getEndFilePos() - $s +1, 145 | name: $name, 146 | type: '', 147 | is_wildcard: true 148 | ); 149 | } 150 | 151 | /** 152 | * Create a token for a concrete parameter 153 | * 154 | * @param string $method_name 155 | * @param Param $param 156 | * @param Attribute $attr 157 | * @return Parameter 158 | * @throws \ParseError 159 | * @throws \RuntimeException 160 | */ 161 | private function concreteParameter(string $method_name, Param $param, Attribute $attr): Parameter 162 | { 163 | if ($attr->args === []) { 164 | throw new \RuntimeException('Parse error: the concrete type attribute has no parameter'); 165 | } 166 | 167 | $wildcard_type = false; 168 | if ($param->type !== null ) { 169 | if (!$param->type instanceof Name) { 170 | throw new \ParseError('Invalid wildcard type for a concrete parameter in ' . 171 | $this->fqcn . '::' . $method_name . '($' . $param->var->name . ') line ' . $attr->getLine() 172 | ); 173 | } 174 | $wildcard_type = ($param->type instanceof Name\FullyQualified ? '\\' : '') . $param->type->name; 175 | } 176 | 177 | //There are seversal possible syntaxes to define concrete types to define a concrete type 178 | // for a wildcard parameter. The first is #[\Generics\T("int", "float")] Foo $param 179 | if (isset($attr->args[1])) { 180 | //more than one parameter provided, so it is the first syntax with several concrete types 181 | //ensure the generic type is provided 182 | if (!$wildcard_type) { 183 | throw new \ParseError('Missing wildcard type for a concrete parameter in '. 184 | $this->fqcn.'::'.$method_name.'($'.$param->var->name.') line '.$attr->getLine() 185 | ); 186 | } 187 | $concrete_types = array_map(fn($a)=>$this->getSource($a->value),$attr->args); 188 | } else { 189 | // the second syntax is eitehr #[\Generics\T("Foo")] Foo $param 190 | $attributeParamExpr = $attr->args[0]->value; 191 | try { 192 | $concrete_type_declaration = $this->getSource($attributeParamExpr); 193 | } catch (\TypeError $E) { 194 | throw new \ParseError ( 195 | 'Invalid concrete type ' . $attributeParamExpr->getType() . ' in '. 196 | $this->fqcn.'::'.$method_name.'($'.$param->var->name.') line '.$attr->getLine() 197 | ); 198 | } 199 | if (preg_match('/^\s*([^<>\s]+)\s*<\s*[^<>\s]+\s*>/',$concrete_type_declaration,$matches)) { 200 | if ($wildcard_type && 0 !== strcasecmp($param->type->name,$wildcard_type)) { 201 | throw new \ParseError ( 202 | 'The parameter ' . $param->type->name . ' type does not match the wildcard type '. 203 | $wildcard_type .' in ' . 204 | $this->fqcn.'::'.$method_name.'('.$param->type->name.' $'.$param->var->name.') on line ' 205 | .$attr->getLine() 206 | ); 207 | } 208 | if (!$wildcard_type) { 209 | $wildcard_type = $matches[1]; 210 | } 211 | preg_match_all('/\s*<\s*([^<>\s]+)\s*>\s*/', $concrete_type_declaration, $matches); 212 | $concrete_types = $matches[1]; 213 | } elseif($wildcard_type) { 214 | // it is a syntax #[\Generics\T("float")] Foo $param 215 | $concrete_types = [$concrete_type_declaration]; 216 | } else { 217 | throw new \ParseError ( 218 | 'Invalid concrete type ' . $attributeParamExpr->getType() . ' in '. 219 | $this->fqcn.'::'.$method_name.'($'.$param->var->name.') line '.$attr->getLine() 220 | ); 221 | } 222 | } 223 | 224 | $token = new Parameter( 225 | offset: $s = $param->getStartFilePos(), 226 | length: $param->getEndFilePos() - $s +1, 227 | name: $param->var->name, 228 | type: $wildcard_type, 229 | concrete_types: $concrete_types 230 | ); 231 | return $token; 232 | } 233 | 234 | private function extractConcreteParameterParts(string $type): array 235 | { 236 | $pattern = '/^([^<>]+)(?:\s*<\s*([^<>]*)\s*>\s*)+/'; 237 | 238 | if (preg_match($pattern, $type, $matches)) { 239 | $wildcard = trim($matches[1]); 240 | 241 | // Extract all values inside angle brackets, allowing for spaces 242 | preg_match_all('/\s*<\s*([^<>]*)\s*>\s*/', $type, $matches); 243 | $concrete = array_map('trim', $matches[1]); 244 | 245 | return [$wildcard, $concrete]; 246 | } 247 | return []; 248 | } 249 | 250 | /** 251 | * Fetch the type from the source code, cause php-parser removes leading \ from namespaces 252 | * 253 | * @param Expr $expr 254 | * @return string 255 | */ 256 | private function getSource(Expr $expr): string 257 | { 258 | $param_type = match (true) { 259 | $expr instanceof String_ => $expr->value, 260 | $expr instanceof ConstFetch => 261 | substr($this->source_code, $s = $expr->getStartFilePos(), $expr->getEndFilePos() - $s + 1), 262 | $expr instanceof ClassConstFetch => 263 | substr($this->source_code, $s = $expr->getStartFilePos(), $expr->getEndFilePos() - $s - 6), 264 | default => throw new \TypeError() 265 | }; 266 | if (!$param_type || is_countable($param_type) || isset($this->restricted_names[strtolower($param_type)])) { 267 | throw new \TypeError(); 268 | } 269 | return $param_type; 270 | } 271 | 272 | } -------------------------------------------------------------------------------- /generics/Internal/service/ComposerAdapter.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private array $loaders; 15 | public function __construct(ClassLoader ... $loaders) 16 | { 17 | $this->loaders = $loaders; 18 | } 19 | 20 | public function findClassFile(string $class): string | false 21 | { 22 | if (str_starts_with($class, 'grikdotnet\\generics')) { 23 | return false; 24 | } 25 | $path = false; 26 | foreach ($this->loaders as $l) { 27 | if ($path = $l->findFile($class)) { 28 | break; 29 | } 30 | } 31 | if (!$path) { 32 | return false; 33 | } 34 | if (str_starts_with($path, 'file://')) { 35 | $path = substr($path,7); 36 | } elseif (preg_match('~^\w+://~',$path)) { 37 | return false; 38 | } 39 | return $path; 40 | } 41 | } -------------------------------------------------------------------------------- /generics/Internal/service/Opcache.php: -------------------------------------------------------------------------------- 1 | is_available = php_sapi_name() !== 'cli' 24 | && ini_get('opcache.enable') 25 | && ini_get('opcache.revalidate_freq') > 0; 26 | if (!$this->is_available) { 27 | return; 28 | } 29 | if (extension_loaded('Phar')) { 30 | PharWrapperAdapter::$dance_with_wrapper = true; 31 | } else { 32 | PharWrapperAdapter::registerPermanent(); 33 | } 34 | } 35 | 36 | /** 37 | * Link tokens from opcache to the container 38 | * 39 | * @param Container $container 40 | * @return bool 41 | */ 42 | public function linkCachedTokens(Container $container): bool 43 | { 44 | if ($this->is_available && opcache_is_script_cached(self::GENERIC_TOKENS_KEY)) { 45 | if (is_array($data = @include self::GENERIC_TOKENS_KEY)) { 46 | $container->setCache($data[0],$data[1],$data[2],$data[3]); 47 | return true; 48 | } 49 | } 50 | return false; 51 | } 52 | 53 | /** 54 | * @param Container $container 55 | * @return void 56 | */ 57 | public function store(Container $container): void 58 | { 59 | if (!$this->is_available || !$container->isModified()) { 60 | return; 61 | } 62 | 63 | if ($container->areNewTokens()) { 64 | $data[0] = array_map(fn ($c) => $c->toArray(), $container->fileAggregates); 65 | $data[1] = array_map(fn ($c) => $c->toArray(), $container->classAggregates); 66 | $data[2] = array_map(fn ($c) => $c->toArray(), $container->instantiations); 67 | $data[3] = $container->skip_files; 68 | $data_to_write = 'is_available) { 84 | return false; 85 | } 86 | $path = self::PREFIX.$path; 87 | if (opcache_is_script_cached($path)) { 88 | $previous = set_error_handler( 89 | function(...$err) use (&$previous,&$cache_miss,$path) { 90 | if ($err[0] === E_WARNING && ( 91 | str_starts_with($err[1],'include('.$path) || 92 | str_starts_with($err[1],'include(): Failed opening \''.$path) 93 | )) { 94 | $cache_miss = true; 95 | } elseif(is_callable($previous)) { 96 | call_user_func($previous,...$err); 97 | } else { 98 | return false; 99 | } 100 | } 101 | ); 102 | (self::$includer ?? self::$includer =\Closure::bind(static function($file) { 103 | include $file; 104 | },null,null))($path); 105 | restore_error_handler(); 106 | } else { 107 | return false; 108 | } 109 | if ($cache_miss) { 110 | return false; 111 | } 112 | return true; 113 | } 114 | 115 | /** 116 | * @param string $virtual_class_name 117 | * @return bool 118 | */ 119 | public function loadVirtualClass(string $virtual_class_name): bool 120 | { 121 | $key = self::PREFIX.__DIR__.'/'. $virtual_class_name; 122 | if (opcache_is_script_cached($key)) { 123 | @include $key; 124 | } 125 | return class_exists($virtual_class_name,false); 126 | } 127 | 128 | public function includeVirtualClass(string $virtual_class_name, string $declaration): void 129 | { 130 | $key = self::PREFIX.__DIR__.'/'. $virtual_class_name; 131 | PharWrapperAdapter::include($key,'position = 0; 117 | } 118 | public function stream_read(int $count) 119 | { 120 | if (strlen(self::$data) < $count) { 121 | $this->position = $count; 122 | return self::$data; 123 | } 124 | $p = $this->position; 125 | $this->position += $count; 126 | return substr(self::$data,$p,$count); 127 | } 128 | 129 | public function stream_eof() 130 | { 131 | return $this->position >= strlen(self::$data); 132 | } 133 | public function url_stat() 134 | { 135 | return self::$data === '' ? false :[ 136 | 'size' => strlen(self::$data), 137 | 'mtime' => time()-360, 138 | ]; 139 | } 140 | public function stream_stat() 141 | { 142 | return $this->url_stat(); 143 | } 144 | 145 | public function stream_set_option() 146 | { 147 | return true; 148 | } 149 | }; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /generics/Internal/tokens/ClassAggregate.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected array $tokens = []; 17 | 18 | private bool $is_template = false; 19 | 20 | public function __construct( 21 | public readonly string $classname, 22 | public readonly string $namespace = '' 23 | ){} 24 | 25 | static public function fromArray(array $data): self 26 | { 27 | $instance = new self($data[self::CLASSNAME]); 28 | $instance->is_template = $data[self::IS_TEMPLATE]; 29 | foreach ($data[self::TOKENS] as $k => $t) { 30 | $instance->tokens[$k] = MethodHeaderAggregate::fromArray($t); 31 | } 32 | return $instance; 33 | } 34 | 35 | public function addMethodAggregate(MethodHeaderAggregate $method): void 36 | { 37 | $this->tokens[$method->offset] = $method; 38 | } 39 | 40 | public function setIsTemplate(): void 41 | { 42 | $this->is_template = true; 43 | } 44 | 45 | /** 46 | * Check if the class contains a template type 47 | * @return bool 48 | */ 49 | public function isTemplate(): bool 50 | { 51 | return $this->is_template; 52 | } 53 | 54 | /** 55 | * Check if the class contains template or generic parameters 56 | * @return bool 57 | */ 58 | public function hasGenerics(): bool 59 | { 60 | return $this->is_template || $this->tokens !== []; 61 | } 62 | 63 | public function toArray(): array 64 | { 65 | $array = [ 66 | self::IS_TEMPLATE => $this->is_template, 67 | self::CLASSNAME => $this->classname, 68 | ]; 69 | $array[self::TOKENS] = []; 70 | foreach ($this->tokens as $t){ 71 | $array[self::TOKENS][$t->offset] = $t->toArray(); 72 | } 73 | return $array; 74 | } 75 | 76 | /** 77 | * @return array[int,MethodHeaderAggregate] 78 | */ 79 | public function getTokens(): array 80 | { 81 | return $this->tokens; 82 | } 83 | 84 | public function getFQCN(): string 85 | { 86 | return ('' === $this->namespace) ? $this->classname : ($this->namespace.'\\'.$this->classname); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /generics/Internal/tokens/ConcreteInstantiationToken.php: -------------------------------------------------------------------------------- 1 | offset,$this->length,$this->type,$this->concrete_types,$this->concrete_name]; 21 | } 22 | } -------------------------------------------------------------------------------- /generics/Internal/tokens/FileAggregate.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public array $classAggregates = [], 21 | /** 22 | * @var array 23 | */ 24 | public array $instantiations = [] 25 | ) 26 | {} 27 | 28 | public function isEmpty(): bool 29 | { 30 | return $this->classAggregates === [] && $this->instantiations === []; 31 | } 32 | 33 | /** 34 | * Serialize data for opcache 35 | * @return array 36 | */ 37 | public function toArray(): array 38 | { 39 | return [ 40 | self::FILENAME => $this->path, 41 | self::CLASSES => array_map(fn(ClassAggregate $c)=>$c->toArray(), $this->classAggregates), 42 | self::INSTANTIATIONS => array_map(fn(ConcreteInstantiationToken $i)=>array_values((array)$i), $this->instantiations) 43 | ]; 44 | } 45 | 46 | /** 47 | * Restore data from cache 48 | * @param array $cache 49 | * @return self 50 | */ 51 | public static function fromArray(array $cache): self 52 | { 53 | $instantiations = $classes = []; 54 | foreach ($cache[self::INSTANTIATIONS] as $i) { 55 | $instantiations[] = new ConcreteInstantiationToken(...$i); 56 | } 57 | foreach ($cache[self::CLASSES] as $c) { 58 | $classes[] = ClassAggregate::fromArray($c); 59 | } 60 | return new self($cache[self::FILENAME],$classes,$instantiations); 61 | } 62 | } -------------------------------------------------------------------------------- /generics/Internal/tokens/MethodHeaderAggregate.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public array $parameters = []; 16 | public bool $wildcardReturn = false; 17 | 18 | /** 19 | * @param int $offset 20 | * @param int $length from the start of a method declaration till the opening { 21 | * @param string $name 22 | */ 23 | public function __construct( 24 | public readonly int $offset, 25 | public readonly int $length, 26 | public readonly string $name, 27 | public readonly string $headline, 28 | public readonly bool $void = false 29 | ){} 30 | 31 | /** 32 | * @param Parameter $parameter 33 | * @return void 34 | */ 35 | public function addParameter(Parameter $parameter): void 36 | { 37 | $this->parameters[] = $parameter; 38 | } 39 | 40 | /** 41 | * @return void 42 | */ 43 | public function setWildcardReturn(): void 44 | { 45 | $this->wildcardReturn = true; 46 | } 47 | 48 | public function toArray(): array 49 | { 50 | $p = []; 51 | foreach ($this->parameters as $param) { 52 | $p[] = array_values((array)$param); 53 | } 54 | return [ 55 | self::OFFSET => $this->offset, 56 | self::LENGTH => $this->length, 57 | self::NAME => $this->name, 58 | self::WILDCARD => $this->wildcardReturn, 59 | self::HEADLINE => $this->headline, 60 | self::PARAMETERS => $p, 61 | ]; 62 | } 63 | 64 | static public function fromArray(array $data): self 65 | { 66 | $instance = new self($data[self::OFFSET],$data[self::LENGTH],$data[self::NAME],$data[self::HEADLINE]); 67 | foreach ($data[self::PARAMETERS] as $p) { 68 | $instance->parameters[] = new Parameter(...$p); 69 | } 70 | $instance->wildcardReturn = $data[self::WILDCARD]; 71 | return $instance; 72 | } 73 | } -------------------------------------------------------------------------------- /generics/Internal/tokens/Parameter.php: -------------------------------------------------------------------------------- 1 | class->namespace !== '') { 51 | $namespace_clause = 'namespace '.$this->class->namespace.';'; 52 | } 53 | 54 | $code = $namespace_clause.'class '.self::makeConcreteName($this->class->classname,$concrete_types) 55 | .' extends '.$this->class->classname.'{'; 56 | foreach ($this->class->getTokens() as $token) { 57 | if ($token instanceof MethodHeaderAggregate) { 58 | $code .= $this->generateMethod($token, $concrete_types); 59 | } 60 | } 61 | $code .= '}'; 62 | return $code = self::strip($code); 63 | } 64 | 65 | /** 66 | * Generates a method in the inherited virtual class that is compatible with declaration 67 | * in the wildcard template class, adding validation of the types for the concrete parameters 68 | * 69 | * @param MethodHeaderAggregate $method 70 | * @param string[] $concrete_param_types 71 | * @return string 72 | */ 73 | private function generateMethod(MethodHeaderAggregate $method, array $concrete_param_types): string 74 | { 75 | $parameters = $typed_parameters = []; 76 | foreach ($method->parameters as $parameter) { 77 | if ($parameter->type === '') { 78 | if ($parameter->is_wildcard && $concrete_param_types !== []){ 79 | if ($concrete_param_types) { 80 | $type = array_shift($concrete_param_types); 81 | if (!in_array($type,self::BUILTIN_TYPES) && $type !== '' && $type[0] !== '\\') { 82 | $type = '\\'.$type; 83 | } 84 | $typed_parameters[] = $type.' $'.$parameter->name; 85 | } 86 | } else { 87 | $typed_parameters[] = '$'.$parameter->name; 88 | } 89 | } else { 90 | if ($parameter->concrete_types === []) { 91 | $typed_parameters[] = $parameter->type.' $'.$parameter->name; 92 | } else { 93 | $type = self::makeConcreteName($parameter->type,$parameter->concrete_types); 94 | $typed_parameters[] = str_replace('\\',self::NS,$type).' $'.$parameter->name; 95 | } 96 | } 97 | $parameters[] = '$'.$parameter->name; 98 | } 99 | $return = $method->void ? '' : 'return '; 100 | $code = $method->headline. 101 | '{try{'. 102 | $return.'(fn('.implode(',',$typed_parameters).')=>parent::'.$method->name.'(...func_get_args()))'. 103 | '('.implode(',',$parameters).');'. 104 | '}catch(\TypeError $e){throw \grikdotnet\generics\TypeError::fromTypeError($e);}'. 105 | '}'; 106 | return $code; 107 | } 108 | 109 | /** 110 | * Remove some of the redudant whitespaces from the class declaration 111 | * 112 | * @param string $sourceCode 113 | * @return string 114 | */ 115 | public static function strip(string $sourceCode): string 116 | { 117 | $tokens = token_get_all('offset) 30 | . ConcreteView::makeConcreteName($token->type,$token->concrete_types) 31 | . ($token instanceof Parameter ? ' $'.$token->name : '') 32 | . substr($source,$token->offset+$token->length); 33 | } 34 | return $source; 35 | } 36 | 37 | /** 38 | * Find tokens for concrete generic parameters, 39 | * e.g. function foo(#[\Generics\T(Bar)] $param) ..., 40 | * and arrow functions with concrete instantiations, 41 | * and sort them according to positions in file in reverse order 42 | * 43 | * @param FileAggregate $fileAggregate 44 | * @return Token[] 45 | */ 46 | private static function prepareTokens(FileAggregate $fileAggregate): array 47 | { 48 | $tokens = []; 49 | foreach ($fileAggregate->classAggregates as $classAggregate) { 50 | foreach ($classAggregate->getTokens() as $methodAggregate) { 51 | foreach ($methodAggregate->parameters as $parameter) { 52 | if ($parameter->concrete_types !== '') { 53 | $tokens[$parameter->offset] = $parameter; 54 | } 55 | } 56 | } 57 | } 58 | foreach ($fileAggregate->instantiations as $instantiation) { 59 | $tokens[$instantiation->offset] = $instantiation; 60 | } 61 | krsort($tokens, \SORT_NUMERIC); 62 | return $tokens; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /generics/ReturnT.php: -------------------------------------------------------------------------------- 1 | getTrace(); 15 | $pattern = '~Argument #(\d+ \(\$\S+\)) must be of type (\S+, \S+) given~'; 16 | if (!isset($trace[0]) || !preg_match($pattern,$e->message,$matches)) { 17 | return new self($e->message,$e->code); 18 | } 19 | 20 | array_shift($trace); 21 | if (isset($trace[0]["class"])){ 22 | $message = $trace[0]["class"].'::'; 23 | } else { 24 | $message = ''; 25 | } 26 | $message.= $trace[0]["function"].': Argument #'.$matches[1].' must be of type '.$matches[2].' given'; 27 | 28 | $genericTypeError = new self($message,$e->code); 29 | $genericTypeError->file = $e->file; 30 | $genericTypeError->line = $e->line; 31 | 32 | return self::setTrace($genericTypeError,$trace); 33 | } 34 | 35 | private static function setTrace(self $e, array $new_trace): self 36 | { 37 | $serialized = serialize($e); 38 | $start = strpos($serialized,"\0trace\";a"); 39 | $end = strpos($serialized,"s:15:\"\0Error\0previous",$start+5); 40 | $new_serialize = substr($serialized,0,$start+8).serialize($new_trace).substr($serialized,$end); 41 | return unserialize($new_serialize); 42 | } 43 | } --------------------------------------------------------------------------------