diff --git a/composer.json b/composer.json index 66cf5680..1ca12cae 100755 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "keywords": ["module", "xp"], "require" : { "xp-framework/core": "^10.0 | ^9.0 | ^8.0 | ^7.0", - "xp-framework/ast": "^7.0", + "xp-framework/ast": "^7.1", "php" : ">=7.0.0" }, "require-dev" : { diff --git a/src/main/php/lang/ast/Result.class.php b/src/main/php/lang/ast/Result.class.php index 166d192c..310d7b74 100755 --- a/src/main/php/lang/ast/Result.class.php +++ b/src/main/php/lang/ast/Result.class.php @@ -1,5 +1,7 @@ codegen->symbol(); } + + /** + * Looks up a given type + * + * @param string $type + * @return lang.ast.emit.Type + */ + public function lookup($type) { + if ('self' === $type || 'static' === $type || $type === $this->type[0]->name) { + return new Declaration($this->type[0], $this); + } else if ('parent' === $type) { + return $this->lookup($this->type[0]->parent); + } else { + return new Reflection($type); + } + } } \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/Declaration.class.php b/src/main/php/lang/ast/emit/Declaration.class.php new file mode 100755 index 00000000..b8384f93 --- /dev/null +++ b/src/main/php/lang/ast/emit/Declaration.class.php @@ -0,0 +1,36 @@ +type= $type; + $this->result= $result; + } + + /** @return string */ + public function name() { return ltrim($this->type->name, '\\'); } + + /** + * Returns whether a given member is an enum case + * + * @param string $member + * @return bool + */ + public function rewriteEnumCase($member) { + if (!self::$ENUMS && 'enum' === $this->type->kind) { + return ($this->type->body[$member] ?? null) instanceof EnumCase; + } else if ('class' === $this->type->kind && '\\lang\\Enum' === $this->type->parent) { + return ($this->type->body['$'.$member] ?? null) instanceof Property; + } + return false; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index 7afbf52b..1c4198e2 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -40,21 +40,26 @@ protected function declaration($name) { * - Binary expression where left- and right hand side are literals * * @see https://wiki.php.net/rfc/const_scalar_exprs + * @param lang.ast.Result $result * @param lang.ast.Node $node * @return bool */ - protected function isConstant($node) { + protected function isConstant($result, $node) { if ($node instanceof Literal) { return true; } else if ($node instanceof ArrayLiteral) { foreach ($node->values as $node) { - if (!$this->isConstant($node)) return false; + if (!$this->isConstant($result, $node)) return false; } return true; } else if ($node instanceof ScopeExpression) { - return $node->member instanceof Literal; + return ( + $node->member instanceof Literal && + is_string($node->type) && + !$result->lookup($node->type)->rewriteEnumCase($node->member->expression) + ); } else if ($node instanceof BinaryExpression) { - return $this->isConstant($node->left) && $this->isConstant($node->right); + return $this->isConstant($result, $node->left) && $this->isConstant($result, $node->right); } return false; } @@ -188,7 +193,7 @@ protected function emitStatic($result, $static) { foreach ($static->initializations as $variable => $initial) { $result->out->write('static $'.$variable); if ($initial) { - if ($this->isConstant($initial)) { + if ($this->isConstant($result, $initial)) { $result->out->write('='); $this->emitOne($result, $initial); } else { @@ -284,7 +289,7 @@ protected function emitParameter($result, $parameter) { $result->out->write(($parameter->reference ? '&' : '').'$'.$parameter->name); } if ($parameter->default) { - if ($this->isConstant($parameter->default)) { + if ($this->isConstant($result, $parameter->default)) { $result->out->write('='); $this->emitOne($result, $parameter->default); } else { @@ -349,7 +354,76 @@ protected function emitLambda($result, $lambda) { $this->emitOne($result, $lambda->body); } + protected function emitEnumCase($result, $case) { + $result->out->write('public static $'.$case->name.';'); + } + + protected function emitEnum($result, $enum) { + array_unshift($result->type, $enum); + array_unshift($result->meta, []); + $result->locals= [[], []]; + + $result->out->write('final class '.$this->declaration($enum->name).' implements \\'.($enum->base ? 'BackedEnum' : 'UnitEnum')); + $enum->implements && $result->out->write(', '.implode(', ', $enum->implements)); + $result->out->write('{'); + + $cases= []; + foreach ($enum->body as $member) { + if ($member->is('enumcase')) $cases[]= $member; + $this->emitOne($result, $member); + } + + // Constructors + if ($enum->base) { + $result->out->write('public $name, $value;'); + $result->out->write('private static $values= [];'); + $result->out->write('private function __construct($name, $value) { + $this->name= $name; + $this->value= $value; + self::$values[$value]= $this; + }'); + $result->out->write('public static function tryFrom($value) { + return self::$values[$value] ?? null; + }'); + $result->out->write('public static function from($value) { + if ($r= self::$values[$value] ?? null) return $r; + throw new \Error(\util\Objects::stringOf($value)." is not a valid backing value for enum \"".self::class."\""); + }'); + } else { + $result->out->write('public $name;'); + $result->out->write('private function __construct($name) { + $this->name= $name; + }'); + } + + // Enum cases + $result->out->write('public static function cases() { return ['); + foreach ($cases as $case) { + $result->out->write('self::$'.$case->name.', '); + } + $result->out->write(']; }'); + + // Initializations + $result->out->write('static function __init() {'); + if ($enum->base) { + foreach ($cases as $case) { + $result->out->write('self::$'.$case->name.'= new self("'.$case->name.'", '); + $this->emitOne($result, $case->expression); + $result->out->write(');'); + } + } else { + foreach ($cases as $case) { + $result->out->write('self::$'.$case->name.'= new self("'.$case->name.'");'); + } + } + $this->emitInitializations($result, $result->locals[0]); + $this->emitMeta($result, $enum->name, $enum->annotations, $enum->comment); + $result->out->write('}} '.$enum->name.'::__init();'); + array_shift($result->type); + } + protected function emitClass($result, $class) { + array_unshift($result->type, $class); array_unshift($result->meta, []); $result->locals= [[], []]; @@ -373,6 +447,7 @@ protected function emitClass($result, $class) { $this->emitInitializations($result, $result->locals[0]); $this->emitMeta($result, $class->name, $class->annotations, $class->comment); $result->out->write('}} '.$class->name.'::__init();'); + array_shift($result->type); } /** Stores lowercased, unnamespaced name in annotations for BC reasons! */ @@ -516,7 +591,7 @@ protected function emitProperty($result, $property) { $result->out->write(implode(' ', $property->modifiers).' '.$this->propertyType($property->type).' $'.$property->name); if (isset($property->expression)) { - if ($this->isConstant($property->expression)) { + if ($this->isConstant($result, $property->expression)) { $result->out->write('='); $this->emitOne($result, $property->expression); } else if (in_array('static', $property->modifiers)) { @@ -569,7 +644,7 @@ protected function emitMethod($result, $method) { ]; } - if (isset($param->default) && !$this->isConstant($param->default)) { + if (isset($param->default) && !$this->isConstant($result, $param->default)) { $meta[DETAIL_TARGET_ANNO][$param->name]['default']= [$param->default]; } } @@ -909,6 +984,8 @@ protected function emitScope($result, $scope) { $result->out->write(')?'.$t.'::'); $this->emitOne($result, $scope->member); $result->out->write(':null'); + } else if ($scope->member instanceof Literal && $result->lookup($scope->type)->rewriteEnumCase($scope->member->expression)) { + $result->out->write($scope->type.'::$'.$scope->member->expression); } else { $result->out->write($scope->type.'::'); $this->emitOne($result, $scope->member); diff --git a/src/main/php/lang/ast/emit/PHP81.class.php b/src/main/php/lang/ast/emit/PHP81.class.php new file mode 100755 index 00000000..6d8d5693 --- /dev/null +++ b/src/main/php/lang/ast/emit/PHP81.class.php @@ -0,0 +1,158 @@ + literal mappings */ + public function __construct() { + $this->literals= [ + IsArray::class => function($t) { return 'array'; }, + IsMap::class => function($t) { return 'array'; }, + IsFunction::class => function($t) { return 'callable'; }, + IsValue::class => function($t) { return $t->literal(); }, + IsNullable::class => function($t) { $l= $this->literal($t->element); return null === $l ? null : '?'.$l; }, + IsUnion::class => function($t) { + $u= ''; + foreach ($t->components as $component) { + if (null === ($l= $this->literal($component))) return null; + $u.= '|'.$l; + } + return substr($u, 1); + }, + IsLiteral::class => function($t) { return $t->literal(); } + ]; + } + + protected function emitArguments($result, $arguments) { + $i= 0; + foreach ($arguments as $name => $argument) { + if ($i++) $result->out->write(','); + if (is_string($name)) $result->out->write($name.':'); + $this->emitOne($result, $argument); + } + } + + protected function emitEnumCase($result, $case) { + + // TODO: Once enum PR is merged, remove this conditional and refactor the + // code into a `RewriteEnums` trait to be included for all other versions + if (Type::$ENUMS) { + $result->out->write('case '.$case->name); + if ($case->expression) { + $result->out->write('='); + $this->emitOne($result, $case->expression); + } + $result->out->write(';'); + } else { + parent::emitEnumCase($result, $case); + } + } + + protected function emitEnum($result, $enum) { + + // TODO: Once enum PR is merged, remove this conditional and refactor the + // code into a `RewriteEnums` trait to be included for all other versions + if (Type::$ENUMS) { + array_unshift($result->type, $enum); + array_unshift($result->meta, []); + $result->locals= [[], []]; + + $result->out->write('enum '.$this->declaration($enum->name)); + $enum->base && $result->out->write(':'.$enum->base); + $enum->implements && $result->out->write(' implements '.implode(', ', $enum->implements)); + $result->out->write('{'); + + foreach ($enum->body as $member) { + $this->emitOne($result, $member); + } + + // Initializations + $result->out->write('static function __init() {'); + $this->emitInitializations($result, $result->locals[0]); + $this->emitMeta($result, $enum->name, $enum->annotations, $enum->comment); + $result->out->write('}} '.$enum->name.'::__init();'); + array_shift($result->type); + } else { + parent::emitEnum($result, $enum); + } + } + + protected function emitNew($result, $new) { + if ($new->type instanceof Node) { + $result->out->write('new ('); + $this->emitOne($result, $new->type); + $result->out->write(')('); + } else { + $result->out->write('new '.$new->type.'('); + } + + $this->emitArguments($result, $new->arguments); + $result->out->write(')'); + } + + protected function emitThrowExpression($result, $throw) { + $result->out->write('throw '); + $this->emitOne($result, $throw->expression); + } + + protected function emitCatch($result, $catch) { + $capture= $catch->variable ? ' $'.$catch->variable : ''; + if (empty($catch->types)) { + $result->out->write('catch(\\Throwable'.$capture.') {'); + } else { + $result->out->write('catch('.implode('|', $catch->types).$capture.') {'); + } + $this->emitAll($result, $catch->body); + $result->out->write('}'); + } + + protected function emitNullsafeInstance($result, $instance) { + $this->emitOne($result, $instance->expression); + $result->out->write('?->'); + + if ('literal' === $instance->member->kind) { + $result->out->write($instance->member->expression); + } else { + $result->out->write('{'); + $this->emitOne($result, $instance->member); + $result->out->write('}'); + } + } + + protected function emitMatch($result, $match) { + if (null === $match->expression) { + $result->out->write('match (true) {'); + } else { + $result->out->write('match ('); + $this->emitOne($result, $match->expression); + $result->out->write(') {'); + } + + foreach ($match->cases as $case) { + $b= 0; + foreach ($case->expressions as $expression) { + $b && $result->out->write(','); + $this->emitOne($result, $expression); + $b++; + } + $result->out->write('=>'); + $this->emitAsExpression($result, $case->body); + $result->out->write(','); + } + + if ($match->default) { + $result->out->write('default=>'); + $this->emitAsExpression($result, $match->default); + } + + $result->out->write('}'); + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/Reflection.class.php b/src/main/php/lang/ast/emit/Reflection.class.php new file mode 100755 index 00000000..f28568d9 --- /dev/null +++ b/src/main/php/lang/ast/emit/Reflection.class.php @@ -0,0 +1,40 @@ +reflect= new \ReflectionClass($type); + } catch (\ReflectionException $e) { + throw new ClassNotFoundException($type); + } + } + + /** @return string */ + public function name() { return $this->reflect->name; } + + /** + * Returns whether a given member is an enum case + * + * @param string $member + * @return bool + */ + public function rewriteEnumCase($member) { + if ($this->reflect->isSubclassOf(Enum::class)) { + return $this->reflect->getStaticPropertyValue($member, null) instanceof Enum; + } else if (!self::$ENUMS && self::$UNITENUM && $this->reflect->isSubclassOf(\UnitEnum::class)) { + $value= $this->reflect->getConstant($member) ?: $this->reflect->getStaticPropertyValue($member, null); + return $value instanceof \UnitEnum; + } + return false; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/Type.class.php b/src/main/php/lang/ast/emit/Type.class.php new file mode 100755 index 00000000..211763d7 --- /dev/null +++ b/src/main/php/lang/ast/emit/Type.class.php @@ -0,0 +1,22 @@ + Errors::class, 'withMessage' => 'Variadic parameters cannot be promoted'])] + #[Test, Expect(class: Errors::class, withMessage: 'Variadic parameters cannot be promoted')] public function variadic_parameters_cannot_be_promoted() { $this->type('class { public function __construct(private string... $in) { } diff --git a/src/test/php/lang/ast/unittest/emit/ControlStructuresTest.class.php b/src/test/php/lang/ast/unittest/emit/ControlStructuresTest.class.php index e803f0f2..fbd76d23 100755 --- a/src/test/php/lang/ast/unittest/emit/ControlStructuresTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/ControlStructuresTest.class.php @@ -136,7 +136,7 @@ public function run($arg) { Assert::equals('10+ items', $r); } - #[Test, Expect(['class' => Throwable::class, 'withMessage' => '/Unhandled match value of type .+/'])] + #[Test, Expect(class: Throwable::class, withMessage: '/Unhandled match value of type .+/')] public function unhandled_match() { $this->run('class { public function run($arg) { @@ -149,7 +149,7 @@ public function run($arg) { }', SEEK_END); } - #[Test, Expect(['class' => Throwable::class, 'withMessage' => '/Unknown seek mode .+/'])] + #[Test, Expect(class: Throwable::class, withMessage: '/Unknown seek mode .+/')] public function match_with_throw_expression() { $this->run('class { public function run($arg) { diff --git a/src/test/php/lang/ast/unittest/emit/EnumTest.class.php b/src/test/php/lang/ast/unittest/emit/EnumTest.class.php new file mode 100755 index 00000000..99630822 --- /dev/null +++ b/src/test/php/lang/ast/unittest/emit/EnumTest.class.php @@ -0,0 +1,249 @@ + function_exists("enum_exists"))')] +class EnumTest extends EmittingTest { + + #[Test] + public function enum_type() { + Assert::true($this->type('enum { }')->isEnum()); + } + + #[Test] + public function name_property() { + $t= $this->type('enum { + case Hearts; + case Diamonds; + case Clubs; + case Spades; + + public static function run() { + return self::Hearts->name; + } + }'); + + Assert::equals('Hearts', $t->getMethod('run')->invoke(null)); + } + + #[Test] + public function cases_method_for_unit_enums() { + $t= $this->type('enum { + case Hearts; + case Diamonds; + case Clubs; + case Spades; + }'); + + Assert::equals( + ['Hearts', 'Diamonds', 'Clubs', 'Spades'], + array_map(function($suit) { return $suit->name; }, $t->getMethod('cases')->invoke(null)) + ); + } + + #[Test] + public function cases_method_for_backed_enums() { + $t= $this->type('enum : string { + case Hearts = "♥"; + case Diamonds = "♦"; + case Clubs = "♣"; + case Spades = "♠"; + }'); + + Assert::equals( + ['Hearts', 'Diamonds', 'Clubs', 'Spades'], + array_map(function($suit) { return $suit->name; }, $t->getMethod('cases')->invoke(null)) + ); + } + + #[Test] + public function cases_method_does_not_yield_constants() { + $t= $this->type('enum { + case Hearts; + case Diamonds; + case Clubs; + case Spades; + + const COLORS = ["red", "black"]; + }'); + + Assert::equals( + ['Hearts', 'Diamonds', 'Clubs', 'Spades'], + array_map(function($suit) { return $suit->name; }, $t->getMethod('cases')->invoke(null)) + ); + } + + #[Test] + public function used_as_parameter_default() { + $t= $this->type('enum { + case ASC; + case DESC; + + public static function run($order= self::ASC) { + return $order->name; + } + }'); + + Assert::equals('ASC', $t->getMethod('run')->invoke(null)); + } + + #[Test] + public function overwritten_parameter_default_value() { + $t= $this->type('enum { + case ASC; + case DESC; + + public static function run($order= self::ASC) { + return $order->name; + } + }'); + + Assert::equals('DESC', $t->getMethod('run')->invoke(null, [Enum::valueOf($t, 'DESC')])); + } + + #[Test] + public function value_property_of_backed_enum() { + $t= $this->type('enum : string { + case ASC = "asc"; + case DESC = "desc"; + + public static function run() { + return self::DESC->value; + } + }'); + + Assert::equals('desc', $t->getMethod('run')->invoke(null)); + } + + #[Test, Values([[0, 'NO'], [1, 'YES']])] + public function backed_enum_from_int($arg, $expected) { + $t= $this->type('enum : int { + case NO = 0; + case YES = 1; + }'); + + Assert::equals($expected, $t->getMethod('from')->invoke(null, [$arg])->name); + } + + #[Test, Values([['asc', 'ASC'], ['desc', 'DESC']])] + public function backed_enum_from_string($arg, $expected) { + $t= $this->type('enum : string { + case ASC = "asc"; + case DESC = "desc"; + }'); + + Assert::equals($expected, $t->getMethod('from')->invoke(null, [$arg])->name); + } + + #[Test] + public function backed_enum_from_nonexistant() { + $t= $this->type('use lang\IllegalStateException; enum : string { + case ASC = "asc"; + case DESC = "desc"; + + public static function run() { + try { + self::from("illegal"); + throw new IllegalStateException("No exception raised"); + } catch (\Error $expected) { + return $expected->getMessage(); + } + } + }'); + + Assert::equals( + '"illegal" is not a valid backing value for enum "'.$t->literal().'"', + $t->getMethod('run')->invoke(null) + ); + } + + #[Test, Values([['asc', 'ASC'], ['desc', 'DESC'], ['illegal', null]])] + public function backed_enum_tryFrom($arg, $expected) { + $t= $this->type('enum : string { + case ASC = "asc"; + case DESC = "desc"; + + public static function run($arg) { + return self::tryFrom($arg)?->name; + } + }'); + + Assert::equals($expected, $t->getMethod('run')->invoke(null, [$arg])); + } + + #[Test] + public function declare_method_on_enum() { + $t= $this->type('enum { + case Hearts; + case Diamonds; + case Clubs; + case Spades; + + public function color() { + return match ($this) { + self::Hearts, self::Diamonds => "red", + self::Clubs, self::Spaces => "black", + }; + } + + public static function run() { + return self::Hearts->color(); + } + }'); + + Assert::equals('red', $t->getMethod('run')->invoke(null)); + } + + #[Test] + public function enum_implementing_interface() { + $t= $this->type('use lang\Closeable; enum implements Closeable { + case File; + case Stream; + + public function close() { } + }'); + + Assert::true($t->isSubclassOf('lang.Closeable')); + } + + #[Test] + public function enum_annotations() { + $t= $this->type('#[Test] enum { }'); + Assert::equals(['test' => null], $t->getAnnotations()); + } + + #[Test, Ignore('XP core reflection does not support constant annotations')] + public function enum_member_annotations() { + $t= $this->type('enum { #[Test] case ONE; }'); + Assert::equals(['test' => null], $t->getConstant('ONE')->getAnnotations()); + } + + #[Test] + public function enum_values() { + $t= $this->type('enum { + case Hearts; + case Diamonds; + case Clubs; + case Spades; + }'); + + Assert::equals( + ['Hearts', 'Diamonds', 'Clubs', 'Spades'], + array_map(function($suit) { return $suit->name; }, Enum::valuesOf($t)) + ); + } + + #[Test] + public function enum_value() { + $t= $this->type('enum { + case Hearts; + case Diamonds; + case Clubs; + case Spades; + }'); + + Assert::equals('Hearts', Enum::valueOf($t, 'Hearts')->name); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/unittest/emit/LambdasTest.class.php b/src/test/php/lang/ast/unittest/emit/LambdasTest.class.php index f10fb253..4db484e8 100755 --- a/src/test/php/lang/ast/unittest/emit/LambdasTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/LambdasTest.class.php @@ -178,17 +178,12 @@ public function run() { Assert::equals(2, $r()); } - #[Test] + #[Test, Expect(Errors::class)] public function no_longer_supports_hacklang_variant() { - try { - $this->run('class { - public function run() { - $func= ($arg) ==> { return 1; }; - } - }'); - $this->fail('No errors raised', null, Errors::class); - } catch (Errors $expected) { - \xp::gc(); - } + $this->run('class { + public function run() { + $func= ($arg) ==> { return 1; }; + } + }'); } } \ No newline at end of file diff --git a/src/test/php/lang/ast/unittest/emit/MembersTest.class.php b/src/test/php/lang/ast/unittest/emit/MembersTest.class.php index 6807e5ef..beca43f1 100755 --- a/src/test/php/lang/ast/unittest/emit/MembersTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/MembersTest.class.php @@ -171,6 +171,19 @@ public function run() { Assert::equals('MON', $r); } + #[Test] + public function allow_constant_syntax_for_members() { + $r= $this->run('use lang\{Enum, CommandLine}; class extends Enum { + public static $MON, $TUE, $WED, $THU, $FRI, $SAT, $SUN; + + public function run() { + return [self::MON->name(), ::TUE->name(), CommandLine::WINDOWS->name()]; + } + }'); + + Assert::equals(['MON', 'TUE', 'WINDOWS'], $r); + } + #[Test] public function method_with_static() { $r= $this->run('class { diff --git a/src/test/php/lang/ast/unittest/loader/CompilingClassLoaderTest.class.php b/src/test/php/lang/ast/unittest/loader/CompilingClassLoaderTest.class.php index af1613a8..0cde12f5 100755 --- a/src/test/php/lang/ast/unittest/loader/CompilingClassLoaderTest.class.php +++ b/src/test/php/lang/ast/unittest/loader/CompilingClassLoaderTest.class.php @@ -101,7 +101,7 @@ public function load_uri() { Assert::equals('Tests', $class->getSimpleName()); } - #[Test, Expect(['class' => ClassFormatException::class, 'withMessage' => 'Compiler error: Expected "{", have "(end)"'])] + #[Test, Expect(class: ClassFormatException::class, withMessage: 'Compiler error: Expected "{", have "(end)"')] public function load_class_with_syntax_errors() { $this->compile(['Errors' => "loadClass($types['Errors']); }); }