diff --git a/src/main/php/lang.base.php b/src/main/php/lang.base.php index f8a28f8c7a..2351c1ca55 100755 --- a/src/main/php/lang.base.php +++ b/src/main/php/lang.base.php @@ -311,7 +311,7 @@ function newinstance($spec, $args, $def= null) { } if ($generic) { - \lang\XPClass::detailsForClass($name); + \lang\XPClass::detailsForClass(new \ReflectionClass($type.$n)); xp::$meta[$name]['class'][DETAIL_GENERIC]= $generic; } diff --git a/src/main/php/lang/XPClass.class.php b/src/main/php/lang/XPClass.class.php index 04248460db..3e7e47965c 100755 --- a/src/main/php/lang/XPClass.class.php +++ b/src/main/php/lang/XPClass.class.php @@ -50,10 +50,12 @@ * @test xp://net.xp_framework.unittest.reflection.ClassCastingTest */ class XPClass extends Type { + private static $ATTRIBUTES; private $_class; private $_reflect= null; static function __static() { + self::$ATTRIBUTES= class_exists(\ReflectionAttribute::class, false); // Workaround for missing detail information about return types in // builtin classes. @@ -545,7 +547,7 @@ public function getDeclaredInterfaces() { * @return string */ public function getComment() { - if (!($details= self::detailsForClass($this->name))) return null; + if (!($details= self::detailsForClass($this->reflect()))) return null; return $details['class'][DETAIL_COMMENT]; } @@ -575,12 +577,16 @@ public function getModifiers(): int { * @return bool */ public function hasAnnotation($name, $key= null): bool { - $details= self::detailsForClass($this->name); - - return $details && ($key - ? array_key_exists($key, $details['class'][DETAIL_ANNOTATIONS][$name] ?? []) - : array_key_exists($name, $details['class'][DETAIL_ANNOTATIONS] ?? []) - ); + $name= strtr($name, '.', '\\'); + $details= self::detailsForClass($this->reflect()); + $r= $details['class'][DETAIL_ANNOTATIONS] ?? []; + self::mergeAttributes($r, $this->_reflect); + + if ($key) { + return is_array($r[$name] ?? null) && array_key_exists($key, $r[$name]); + } else { + return array_key_exists($name, $r); + } } /** @@ -592,24 +598,27 @@ public function hasAnnotation($name, $key= null): bool { * @throws lang.ElementNotFoundException */ public function getAnnotation($name, $key= null) { - $details= self::detailsForClass($this->name); - if (!$details || !($key - ? array_key_exists($key, $details['class'][DETAIL_ANNOTATIONS][$name] ?? []) - : array_key_exists($name, $details['class'][DETAIL_ANNOTATIONS] ?? []) - )) { - throw new ElementNotFoundException('Annotation "'.$name.($key ? '.'.$key : '').'" does not exist'); + $name= strtr($name, '.', '\\'); + $details= self::detailsForClass($this->reflect()); + $r= $details['class'][DETAIL_ANNOTATIONS] ?? []; + self::mergeAttributes($r, $this->_reflect); + + if ($key) { + if (is_array($r[$name] ?? null) && array_key_exists($key, $r[$name])) return $r[$name][$key]; + } else { + if (array_key_exists($name, $r)) return $r[$name]; } - return ($key - ? $details['class'][DETAIL_ANNOTATIONS][$name][$key] - : $details['class'][DETAIL_ANNOTATIONS][$name] - ); + throw new ElementNotFoundException('Annotation "'.$name.($key ? '.'.$key : '').'" does not exist'); } /** Retrieve whether a method has annotations */ public function hasAnnotations(): bool { - $details= self::detailsForClass($this->name); - return $details ? !empty($details['class'][DETAIL_ANNOTATIONS]) : false; + $details= self::detailsForClass($this->reflect()); + $r= $details['class'][DETAIL_ANNOTATIONS] ?? []; + self::mergeAttributes($r, $this->_reflect); + + return !empty($r); } /** @@ -618,8 +627,11 @@ public function hasAnnotations(): bool { * @return array annotations */ public function getAnnotations() { - $details= self::detailsForClass($this->name); - return $details ? $details['class'][DETAIL_ANNOTATIONS] : []; + $details= self::detailsForClass($this->reflect()); + $r= $details['class'][DETAIL_ANNOTATIONS] ?? []; + self::mergeAttributes($r, $this->_reflect); + + return $r; } /** Retrieve the class loader a class was loaded with */ @@ -641,25 +653,53 @@ protected static function _classLoaderFor($name) { } return null; // Internal class, e.g. } + + /** + * Merge attributes + * + * @param &[:var] $details + * @param php.ReflectionClass|php.ReflectionMethod|php.ReflectionProperty $reflect + */ + public static function mergeAttributes(&$details, $reflect) { + if (!self::$ATTRIBUTES) return; + + foreach ($reflect->getAttributes() as $attribute) { + $args= $attribute->getArguments(); + if (empty($args)) { + $value= null; + } else if (1 === sizeof($args)) { + $value= $args[0]; + } else { + $value= $args; + } + + // Only resolve uppercase attributes + $name= $attribute->getName(); + $p= strrpos($name, '\\'); + $l= false === $p || $name[$p + 1] <= 'Z' ? $name : substr($name, $p + 1); + $details[$l] ?? $details[$l]= $value; + } + } /** * Retrieve details for a specified class. Note: Results from this * method are cached! * - * @param string class fully qualified class name + * @param php.ReflectionClass $class * @return array or NULL to indicate no details are available */ - public static function detailsForClass($class) { + public static function detailsForClass(\ReflectionClass $class) { static $parser= null; - if (isset(\xp::$meta[$class])) return \xp::$meta[$class]; + $name= self::nameOf($class->name); + if (isset(\xp::$meta[$name])) return \xp::$meta[$name]; // Retrieve class' sourcecode - $cl= self::_classLoaderFor($class); - if (!$cl || !($bytes= $cl->loadClassBytes($class))) return null; + $cl= self::_classLoaderFor($name); + if (!$cl || !($bytes= $cl->loadClassBytes($name))) return []; $parser ?? $parser= new \lang\reflect\ClassParser(); - return \xp::$meta[$class]= $parser->parseDetails($bytes, $class); + return \xp::$meta[$name]= $parser->parseDetails($bytes, $name); } /** @@ -671,13 +711,15 @@ public static function detailsForClass($class) { * @return array or NULL if not available */ public static function detailsForMethod($class, $method) { - $details= self::detailsForClass(self::nameOf($class->name)); + $details= self::detailsForClass($class); if (isset($details[1][$method])) return $details[1][$method]; - foreach ($class->getTraitNames() as $trait) { - $details= self::detailsForClass(self::nameOf($trait)); + + foreach ($class->getTraits() as $trait) { + $details= self::detailsForClass($trait); if (isset($details[1][$method])) return $details[1][$method]; } - return null; + + return []; } /** @@ -689,13 +731,15 @@ public static function detailsForMethod($class, $method) { * @return array or NULL if not available */ public static function detailsForField($class, $field) { - $details= self::detailsForClass(self::nameOf($class->name)); + $details= self::detailsForClass($class); if (isset($details[0][$field])) return $details[0][$field]; - foreach ($class->getTraitNames() as $trait) { - $details= self::detailsForClass(self::nameOf($trait)); + + foreach ($class->getTraits() as $trait) { + $details= self::detailsForClass($trait); if (isset($details[0][$field])) return $details[0][$field]; } - return null; + + return []; } /** @@ -748,7 +792,7 @@ public function isGenericDefinition(): bool { * @throws lang.IllegalStateException if this class is not a generic */ public function genericDefinition() { - if (!($details= self::detailsForClass($this->name))) return null; + if (!($details= self::detailsForClass($this->reflect()))) return null; if (!isset($details['class'][DETAIL_GENERIC])) { throw new IllegalStateException('Class '.$this->name.' is not generic'); } @@ -762,7 +806,7 @@ public function genericDefinition() { * @throws lang.IllegalStateException if this class is not a generic */ public function genericArguments() { - if (!($details= self::detailsForClass($this->name))) return null; + if (!($details= self::detailsForClass($this->reflect()))) return null; if (!isset($details['class'][DETAIL_GENERIC])) { throw new IllegalStateException('Class '.$this->name.' is not generic'); } @@ -778,7 +822,7 @@ public function genericArguments() { /** Returns whether this class is generic */ public function isGeneric(): bool { - if (!($details= self::detailsForClass($this->name))) return false; + if (!($details= self::detailsForClass($this->reflect()))) return false; return isset($details['class'][DETAIL_GENERIC]); } diff --git a/src/main/php/lang/reflect/ClassParser.class.php b/src/main/php/lang/reflect/ClassParser.class.php index 6fbb5f78b6..625b6ae6f4 100755 --- a/src/main/php/lang/reflect/ClassParser.class.php +++ b/src/main/php/lang/reflect/ClassParser.class.php @@ -491,9 +491,38 @@ public function parseDetails($bytes, $context= '') { $comment= $tokens[$i][1]; break; + case T_SL: + if (T_NS_SEPARATOR === $tokens[$i + 1][0]) { + $attribute= ''; + } else { + $attribute= $tokens[++$i][1]; + } + while (T_NS_SEPARATOR === $tokens[++$i][0]) { + $attribute.= '\\'.$tokens[++$i][1]; + } + + if ('\\' === $attribute[0]) { + $attribute= substr($attribute, 1); + } else if (isset($imports[$attribute])) { + $attribute= strtr($imports[$attribute], '.', '\\'); + } else if ($namespace) { + $attribute= $namespace.'\\'.$attribute; + } + $parsed= ''; + break; + + case T_SR: + if ('' !== $parsed) { + $j= 1; + $annotations[0][$attribute]= $this->valueOf(token_get_all('_reflect->getDeclaringClass(), $this->_reflect->getName()); + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + if ($key) { - $a= $details[DETAIL_ANNOTATIONS][$name] ?? null; - return is_array($a) && array_key_exists($key, $a); + return is_array($r[$name] ?? null) && array_key_exists($key, $r[$name]); } else { - return array_key_exists($name, $details[DETAIL_ANNOTATIONS] ?? []); + return array_key_exists($name, $r); } } @@ -108,12 +111,15 @@ public function hasAnnotation($name, $key= null): bool { * @throws lang.ElementNotFoundException */ public function getAnnotation($name, $key= null) { + $name= strtr($name, '.', '\\'); $details= XPClass::detailsForField($this->_reflect->getDeclaringClass(), $this->_reflect->getName()); + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + if ($key) { - $a= $details[DETAIL_ANNOTATIONS][$name] ?? null; - if (is_array($a) && array_key_exists($key, $a)) return $a[$key]; + if (is_array($r[$name] ?? null) && array_key_exists($key, $r[$name])) return $r[$name][$key]; } else { - if (array_key_exists($name, $details[DETAIL_ANNOTATIONS] ?? [])) return $details[DETAIL_ANNOTATIONS][$name]; + if (array_key_exists($name, $r)) return $r[$name]; } throw new ElementNotFoundException('Annotation "'.$name.($key ? '.'.$key : '').'" does not exist'); @@ -122,7 +128,9 @@ public function getAnnotation($name, $key= null) { /** Retrieve whether this field has annotations */ public function hasAnnotations(): bool { $details= XPClass::detailsForField($this->_reflect->getDeclaringClass(), $this->_reflect->getName()); - return $details ? !empty($details[DETAIL_ANNOTATIONS]) : false; + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + return !empty($r); } /** @@ -132,7 +140,9 @@ public function hasAnnotations(): bool { */ public function getAnnotations() { $details= XPClass::detailsForField($this->_reflect->getDeclaringClass(), $this->_reflect->getName()); - return $details ? $details[DETAIL_ANNOTATIONS] : []; + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + return $r; } /** diff --git a/src/main/php/lang/reflect/Parameter.class.php b/src/main/php/lang/reflect/Parameter.class.php index b6d43d42b1..4352496737 100755 --- a/src/main/php/lang/reflect/Parameter.class.php +++ b/src/main/php/lang/reflect/Parameter.class.php @@ -193,10 +193,16 @@ public function getDefaultValue() { * @return bool */ public function hasAnnotation($name, $key= null) { + $name= strtr($name, '.', '\\'); $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_details[1]); $r= $details[DETAIL_TARGET_ANNO]['$'.$this->_reflect->getName()] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); - return $key ? array_key_exists($key, $r[$name] ?? []) : array_key_exists($name, $r); + if ($key) { + return is_array($r[$name] ?? null) && array_key_exists($key, $r[$name]); + } else { + return array_key_exists($name, $r); + } } /** @@ -208,11 +214,13 @@ public function hasAnnotation($name, $key= null) { * @throws lang.ElementNotFoundException */ public function getAnnotation($name, $key= null) { + $name= strtr($name, '.', '\\'); $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_details[1]); $r= $details[DETAIL_TARGET_ANNO]['$'.$this->_reflect->getName()] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); if ($key) { - if (array_key_exists($key, $r[$name] ?? [])) return $r[$name][$key]; + if (is_array($r[$name] ?? null) && array_key_exists($key, $r[$name])) return $r[$name][$key]; } else { if (array_key_exists($name, $r)) return $r[$name]; } @@ -227,7 +235,9 @@ public function getAnnotation($name, $key= null) { */ public function hasAnnotations() { $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_details[1]); - return !empty($details[DETAIL_TARGET_ANNO]['$'.$this->_reflect->getName()] ?? []); + $r= $details[DETAIL_TARGET_ANNO]['$'.$this->_reflect->getName()] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + return !empty($r); } /** @@ -237,7 +247,9 @@ public function hasAnnotations() { */ public function getAnnotations() { $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_details[1]); - return $details[DETAIL_TARGET_ANNO]['$'.$this->_reflect->getName()] ?? []; + $r= $details[DETAIL_TARGET_ANNO]['$'.$this->_reflect->getName()] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + return $r; } /** diff --git a/src/main/php/lang/reflect/Routine.class.php b/src/main/php/lang/reflect/Routine.class.php index 6d9324da23..24fa90d798 100755 --- a/src/main/php/lang/reflect/Routine.class.php +++ b/src/main/php/lang/reflect/Routine.class.php @@ -259,12 +259,15 @@ public function getComment() { * @return bool */ public function hasAnnotation($name, $key= null): bool { + $name= strtr($name, '.', '\\'); $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_reflect->getName()); + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + if ($key) { - $a= $details[DETAIL_ANNOTATIONS][$name] ?? null; - return is_array($a) && array_key_exists($key, $a); + return is_array($r[$name] ?? null) && array_key_exists($key, $r[$name]); } else { - return array_key_exists($name, $details[DETAIL_ANNOTATIONS] ?? []); + return array_key_exists($name, $r); } } @@ -277,12 +280,15 @@ public function hasAnnotation($name, $key= null): bool { * @throws lang.ElementNotFoundException */ public function getAnnotation($name, $key= null) { + $name= strtr($name, '.', '\\'); $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_reflect->getName()); + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + if ($key) { - $a= $details[DETAIL_ANNOTATIONS][$name] ?? null; - if (is_array($a) && array_key_exists($key, $a)) return $a[$key]; + if (is_array($r[$name] ?? null) && array_key_exists($key, $r[$name])) return $r[$name][$key]; } else { - if (array_key_exists($name, $details[DETAIL_ANNOTATIONS] ?? [])) return $details[DETAIL_ANNOTATIONS][$name]; + if (array_key_exists($name, $r)) return $r[$name]; } throw new ElementNotFoundException('Annotation "'.$name.($key ? '.'.$key : '').'" does not exist'); @@ -291,7 +297,9 @@ public function getAnnotation($name, $key= null) { /** Retrieve whether a method has annotations */ public function hasAnnotations(): bool { $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_reflect->getName()); - return $details ? !empty($details[DETAIL_ANNOTATIONS]) : false; + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + return !empty($r); } /** @@ -301,7 +309,9 @@ public function hasAnnotations(): bool { */ public function getAnnotations() { $details= XPClass::detailsForMethod($this->_reflect->getDeclaringClass(), $this->_reflect->getName()); - return $details ? $details[DETAIL_ANNOTATIONS] : []; + $r= $details[DETAIL_ANNOTATIONS] ?? []; + XPClass::mergeAttributes($r, $this->_reflect); + return $r; } /** diff --git a/src/test/config/unittest/core.ini b/src/test/config/unittest/core.ini index 033f693ed0..19441d8ed9 100644 --- a/src/test/config/unittest/core.ini +++ b/src/test/config/unittest/core.ini @@ -21,6 +21,9 @@ class="net.xp_framework.unittest.annotations.ClassMemberParsingTest" [broken-annotations] class="net.xp_framework.unittest.annotations.BrokenAnnotationTest" +[php8-attributes] +class="net.xp_framework.unittest.annotations.AttributesTest" + [references] class="net.xp_framework.unittest.core.ReferencesTest" diff --git a/src/test/php/net/xp_framework/unittest/annotations/AttributesTest.class.php b/src/test/php/net/xp_framework/unittest/annotations/AttributesTest.class.php new file mode 100755 index 0000000000..6616b6bf42 --- /dev/null +++ b/src/test/php/net/xp_framework/unittest/annotations/AttributesTest.class.php @@ -0,0 +1,189 @@ +=8.0.0-dev'))] +class AttributesTest extends TestCase { + private $cl; + + /** @return void */ + public function setUp() { + $this->cl= DynamicClassLoader::instanceFor(self::class); + } + + /** + * Declares a type from source + * + * @param string $source + * @param ?string $namespace Optional namespace name + * @return lang.XPClass + */ + private function type($source, $namespace= null) { + static $u= 0; + + $name= '__A'.(++$u); + if (null === $namespace) { + $this->cl->setClassBytes($name, sprintf($source, $name)); + return $this->cl->loadClass($name); + } else { + $qualified= $namespace.'.'.$name; + $this->cl->setClassBytes($qualified, 'namespace '.$namespace.';'.sprintf($source, $name)); + return $this->cl->loadClass($qualified); + } + } + + /** + * Assertion helper + * + * @param var $expected + * @param lang.XPClass|lang.reflect.Method|lang.reflect.Field|lang.reflect.Parameter $annotated + * @throws unittest.AssertionFailedError + */ + private function assertAnnotations($expected, $annotated) { + if (null === $expected) { + $this->assertFalse($annotated->hasAnnotations(), 'hasAnnotations'); + $this->assertEquals([], $annotated->getAnnotations()); + $this->assertFalse($annotated->hasAnnotation('Test'), 'hasAnnotation("Test")'); + try { + $annotated->getAnnotation('Test'); + $this->fail('Exception not raised', null, ElementNotFoundException::class); + } catch (ElementNotFoundException $expected) { + // OK + } + } else { + $this->assertTrue($annotated->hasAnnotations(), 'hasAnnotations'); + $this->assertEquals($expected, $annotated->getAnnotations()); + foreach ($expected as $name => $value) { + $this->assertTrue($annotated->hasAnnotation($name), 'hasAnnotation("'.$name.'")'); + $this->assertEquals($value, $annotated->getAnnotation($name)); + } + } + } + + #[@test] + public function on_class() { + $t= $this->type('<> class %s { }'); + $this->assertAnnotations(['Test' => null], $t); + } + + #[@test] + public function on_field() { + $t= $this->type('class %s { <> public $fixture; }'); + $this->assertAnnotations(['Test' => null], $t->getField('fixture')); + } + + #[@test] + public function on_method() { + $t= $this->type('class %s { <> public function fixture() { } }'); + $this->assertAnnotations(['Test' => null], $t->getMethod('fixture')); + } + + #[@test] + public function on_parameter() { + $t= $this->type('class %s { public function fixture(<> $param) { } }'); + $this->assertAnnotations(['Test' => null], $t->getMethod('fixture')->getParameter(0)); + } + + #[@test] + public function no_class_annotations() { + $t= $this->type('class %s { }'); + $this->assertAnnotations(null, $t); + } + + #[@test] + public function no_field_annotations() { + $t= $this->type('class %s { public $fixture; }'); + $this->assertAnnotations(null, $t->getField('fixture')); + } + + #[@test] + public function no_method_annotations() { + $t= $this->type('class %s { public function fixture() { } }'); + $this->assertAnnotations(null, $t->getMethod('fixture')); + } + + #[@test] + public function no_parameter_annotations() { + $t= $this->type('class %s { public function fixture($param) { } }'); + $this->assertAnnotations(null, $t->getMethod('fixture')->getParameter(0)); + } + + #[@test] + public function inside_namespace() { + $t= $this->type('<> class %s { }', 'com\\example'); + $this->assertAnnotations(['com\\example\\Test' => null], $t); + } + + #[@test] + public function resolved_against_imports_inside_namespace() { + $t= $this->type('use unittest\\Test; <> class %s { }', 'com\\example'); + $this->assertAnnotations(['unittest\\Test' => null], $t); + } + + #[@test] + public function qualified() { + $t= $this->type('<> class %s { }', 'unittest'); + $this->assertAnnotations(['unittest\\annotations\\Test' => null], $t); + } + + #[@test] + public function fully_qualified() { + $t= $this->type('<<\\unittest\\annotations\\Test>> class %s { }', 'com\\example'); + $this->assertAnnotations(['unittest\\annotations\\Test' => null], $t); + } + + #[@test] + public function lowercase_annotations_are_not_resolved() { + $t= $this->type('<> class %s { }', 'com\\example'); + $this->assertAnnotations(['test' => null], $t); + } + + #[@test] + public function with_value() { + $t= $this->type('<> class %s { }'); + $this->assertAnnotations(['Author' => 'Test'], $t); + } + + #[@test] + public function with_values() { + $t= $this->type('<> class %s { }'); + $this->assertAnnotations(['Product' => ['PHP', '8.0.0']], $t); + } + + #[@test, @values([ + # 'Values', + # 'unittest\\Values', + # '\\unittest\\Values', + #])] + public function with_non_static_value_inline($annotation) { + $t= $this->type(' + use net\\xp_framework\\unittest\\annotations\\Name; + use unittest\\{Test, Values, Expect}; + use lang\\ElementNotFoundException; + + class %s { + + <> + <<'.$annotation.'( + #[ + # new Name("A"), + # new Name("B"), + #] + )>> + < $e instanceof ElementNotFoundException && strstr($e->getMessage(), "fail"); + )>> + public function fixture($value) { + // TBI + } + } + '); + + $m= $t->getMethod('fixture'); + $this->assertNull($m->getAnnotation('unittest.Test')); + $this->assertEquals([new Name('A'), new Name('B')], $m->getAnnotation('unittest.Values')); + $this->assertTrue($m->getAnnotation('unittest.Expect')(new ElementNotFoundException('Test failed'))); + } +}