diff --git a/composer.json b/composer.json index 86bd48e..7db117f 100755 --- a/composer.json +++ b/composer.json @@ -7,7 +7,8 @@ "keywords": ["language", "module", "xp"], "require" : { "xp-framework/core": "^12.0 | ^11.0 | ^10.0", - "xp-framework/compiler": "^9.0 | ^8.0", + "xp-framework/compiler": "^9.0", + "xp-framework/ast": "^11.8", "php" : ">=7.4.0" }, "require-dev" : { diff --git a/src/main/php/lang/ast/syntax/php/IsArrayStructure.class.php b/src/main/php/lang/ast/syntax/php/IsArrayStructure.class.php new file mode 100755 index 0000000..e098ebc --- /dev/null +++ b/src/main/php/lang/ast/syntax/php/IsArrayStructure.class.php @@ -0,0 +1,24 @@ +patterns= $patterns; + $this->rest= $rest; + } + + /** @return string */ + public function toString() { + return nameof($this).'('.($this->rest ? '>=' : '===').' '.Objects::stringOf($this->patterns).')'; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/php/IsBinding.class.php b/src/main/php/lang/ast/syntax/php/IsBinding.class.php new file mode 100755 index 0000000..3524c03 --- /dev/null +++ b/src/main/php/lang/ast/syntax/php/IsBinding.class.php @@ -0,0 +1,23 @@ +variable= $variable; + } + + /** @return string */ + public function toString() { + $restriction= $this->restriction ? ' & '.Objects::stringOf($this->restriction) : ''; + return nameof($this).'('.$this->variable->pointer.$restriction.')'; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/php/IsComparison.class.php b/src/main/php/lang/ast/syntax/php/IsComparison.class.php new file mode 100755 index 0000000..b312606 --- /dev/null +++ b/src/main/php/lang/ast/syntax/php/IsComparison.class.php @@ -0,0 +1,24 @@ +value= $value; + $this->operator= $operator; + } + + /** @return string */ + public function toString() { + return nameof($this).'('.$this->operator.' '.Objects::stringOf($this->value).')'; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/php/IsCompound.class.php b/src/main/php/lang/ast/syntax/php/IsCompound.class.php new file mode 100755 index 0000000..7991134 --- /dev/null +++ b/src/main/php/lang/ast/syntax/php/IsCompound.class.php @@ -0,0 +1,24 @@ +patterns= $patterns; + $this->operator= $operator; + } + + /** @return string */ + public function toString() { + return nameof($this).'('.$this->operator.' '.Objects::stringOf($this->patterns).')'; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/php/IsIdentical.class.php b/src/main/php/lang/ast/syntax/php/IsIdentical.class.php new file mode 100755 index 0000000..7c743c4 --- /dev/null +++ b/src/main/php/lang/ast/syntax/php/IsIdentical.class.php @@ -0,0 +1,22 @@ +value= $value; + } + + /** @return string */ + public function toString() { + return nameof($this).'('.Objects::stringOf($this->value).')'; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/php/IsObjectStructure.class.php b/src/main/php/lang/ast/syntax/php/IsObjectStructure.class.php new file mode 100755 index 0000000..8afdb0a --- /dev/null +++ b/src/main/php/lang/ast/syntax/php/IsObjectStructure.class.php @@ -0,0 +1,24 @@ +type= $type; + $this->patterns= $patterns; + } + + /** @return string */ + public function toString() { + return nameof($this).'('.Objects::stringOf($this->type).' '.Objects::stringOf($this->patterns).')'; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/php/IsOperator.class.php b/src/main/php/lang/ast/syntax/php/IsOperator.class.php index d463faf..7e34fc1 100755 --- a/src/main/php/lang/ast/syntax/php/IsOperator.class.php +++ b/src/main/php/lang/ast/syntax/php/IsOperator.class.php @@ -1,64 +1,273 @@ infix('is', 60, function($parse, $token, $left) { - $t= $this->type($parse, true); + $pattern= function($parse, $types) use(&$pattern) { + if ('(' === $parse->token->value) { + $parse->forward(); + $r= $pattern($parse, $types); + $parse->expecting(')', 'dnf'); + } else if ('?' === $parse->token->value) { + $r= $types->type0($parse, false); + } else if ('name' === $parse->token->kind) { + $r= $types->type0($parse, false); - $node= new InstanceOfExpression($left, $t ?: $this->expression($parse, 0)); - $node->kind= 'is'; - return $node; - }); + if ('(' === $parse->token->value) { + $r= new IsObjectStructure($r); + $parse->forward(); + if (')' !== $parse->token->value) do { + if (':' === $parse->token->value) { + $parse->forward(); + $variable= $parse->token; + $parse->expecting('(variable)', 'object structure'); + $member= $parse->token->value; + array_unshift($parse->queue, $parse->token); + $parse->token= $variable; + } else { + $member= $parse->token->value; + $parse->forward(); + $parse->expecting(':', 'object structure'); + } + $r->patterns[$member]= $pattern($parse, $types); + } while (',' === $parse->token->value && $parse->forward() | true); + $parse->expecting(')', 'object structure'); + } else if ('::' === $parse->token->value) { + $parse->forward(); + $r= new IsIdentical(new ScopeExpression($r->literal(), new Literal($parse->token->value))); + $parse->forward(); + } + } else if ('string' === $parse->token->kind || 'integer' === $parse->token->kind || 'decimal' === $parse->token->kind) { + $r= new IsIdentical(new Literal($parse->token->value)); + $parse->forward(); + } else if ('variable' === $parse->token->kind) { + $parse->forward(); + $r= new IsBinding(new Variable($parse->token->value)); + $parse->forward(); + + // See https://wiki.php.net/rfc/pattern-matching#applying_patterns_to_bound_variables + if ('&' === $parse->token->value) { + $parse->forward(); + $r->restriction= $pattern($parse, $types); + } + return $r; + } else if ('>' === $parse->token->value || '>=' === $parse->token->value || '<' === $parse->token->value || '<=' === $parse->token->value) { + $operator= $parse->token->value; + $parse->forward(); + $r= new IsComparison(new Literal($parse->token->value), $operator); + $parse->forward(); + } else if ('[' === $parse->token->value) { + $r= new IsArrayStructure(); + $parse->forward(); + if (']' !== $parse->token->value) do { + if ('...' === $parse->token->value) { + $r->rest= true; + $parse->forward(); + break; + } - $test= function($literal, $expr, $temp) { - static $is= [ - 'string' => true, - 'int' => true, - 'float' => true, - 'bool' => true, - 'array' => true, - 'object' => true, - 'callable' => true, - 'iterable' => true, - ]; - - if (isset($is[$literal])) { - return new InvokeExpression(new Literal('is_'.$literal), [$expr]); - } else if ('mixed' === $literal) { - return new Literal('true'); + $p= $pattern($parse, $types); + if ('=>' === $parse->token->value) { + $parse->forward(); + $r->patterns[$p->value->expression]= $pattern($parse, $types); + } else { + $r->patterns[]= $p; + } + } while (',' === $parse->token->value && $parse->forward() | true); + $parse->expecting(']', 'array structure'); + } else if ('^' === $parse->token->value) { + $parse->forward(); + $r= new IsIdentical($types->expression($parse, 0)); } else { - return new InstanceOfExpression($expr, $literal); + $parse->expecting('a type or literal', 'is'); + return null; } + + $operator= $parse->token->value; + if ('|' === $operator || '&' === $operator) { + $parse->forward(); + $n= $pattern($parse, $types); + + // Merge compound types with the same operators, keeping evaluation order + if ($n instanceof IsCompound && $operator === $n->operator) { + array_unshift($n->patterns, $r); + return $n; + } else { + return new IsCompound([$r, $n], $operator); + } + } + + return $r; }; - $emitter->transform('is', function($codegen, $node) use($test) { - $t= $node->type; - if ($t instanceof Node) { - return new InvokeExpression(new Literal('is'), [$node->type, $node->expression]); + $language->infix('is', 60, function($parse, $token, $left) use($pattern) { + return new PatternMatch($left, $pattern($parse, $this), $left->line); + }); + + $language->prefix('match', 0, function($parse, $token) use($pattern) { + $patterns= false; + $condition= null; + + if ('(' === $parse->token->value) { + $parse->forward(); + $condition= $this->expression($parse, 0); + $parse->expecting(')', 'match'); + + // See https://wiki.php.net/rfc/pattern-matching#match_is_placement + if ('is' === $parse->token->value) { + $parse->forward(); + $patterns= true; + } } - // Verify builtin primitives with is_XXX(), value types with instanceof, others using is() - if ($t instanceof IsFunction || $t instanceof IsArray || $t instanceof IsMap || $t instanceof IsUnion || $t instanceof IsIntersection) { - return new InvokeExpression(new Literal('is'), [new Literal('"'.$t->name().'"'), $node->expression]); - } else { - $literal= $t->literal(); - $temp= new Variable($codegen->symbol()); - if ('?' === $literal[0]) { - return new BinaryExpression( - new BinaryExpression(new Literal('null'), '===', new Braced(new Assignment($temp, '=', $node->expression))), - '||', - $test(substr($literal, 1), $temp, null) - ); + $default= null; + $cases= []; + $parse->expecting('{', 'match'); + while ('}' !== $parse->token->value) { + if ('default' === $parse->token->value) { + $parse->forward(); + $parse->expecting('=>', 'match'); + $default= $this->expression($parse, 0); + } else if ($patterns) { + $match= [new PatternMatch($condition, $pattern($parse, $this), $parse->token->line)]; + $parse->expecting('=>', 'match'); + $cases[]= new MatchCondition($match, $this->expression($parse, 0), $parse->token->line); } else { - return $test($literal, $node->expression, $temp); + $match= []; + do { + $match[]= $this->expression($parse, 0); + } while (',' === $parse->token->value && $parse->forward() | true); + $parse->expecting('=>', 'match'); + $cases[]= new MatchCondition($match, $this->expression($parse, 0), $parse->token->line); + } + + if (',' === $parse->token->value) { + $parse->forward(); } } + $parse->expecting('}', 'match'); + + return new MatchExpression($patterns ? null : $condition, $cases, $default, $token->line); + }); + + $match= function($codegen, $expression, $pattern) use(&$match) { + + // Basic type matching, literal comparison and variable binding + if ($pattern instanceof IsLiteral) { + $literal= $pattern->literal(); + if ('mixed' === $literal) { + return new Literal('true'); + } else if ('true' === $literal || 'false' === $literal || 'null' === $literal) { + return new BinaryExpression(new Literal($literal), '===', $expression); + } else { + return new InvokeExpression(new Literal('is_'.$literal), [$expression]); + } + } else if ($pattern instanceof IsValue) { + return new InstanceOfExpression($expression, $pattern); + } else if ($pattern instanceof IsArray || $pattern instanceof IsMap || $pattern instanceof IsFunction) { + return new InvokeExpression(new Literal('is'), [new Literal('"'.$pattern->name().'"'), $expression]); + } else if ($pattern instanceof IsIdentical) { + return new BinaryExpression($expression, '===', $pattern->value); + } else if ($pattern instanceof IsBinding) { + $bind= new Assignment($pattern->variable, '=', $expression); + $compound= new Braced(new BinaryExpression(new Braced($bind), '||', new Literal('true'))); + + // Assign to temporary variable, only actually bind if restriction matches + if ($pattern->restriction) { + $bind->expression= new Variable($codegen->symbol()); + $compound= new BinaryExpression( + $match($codegen, new Braced(new Assignment($bind->expression, '=', $expression)), $pattern->restriction), + '&&', + $compound + ); + } + + return $compound; + } + + // Ensure expressions are only evaluated once. + if ($expression instanceof Variable) { + $use= $init= $expression; + } else { + $use= new Variable($codegen->symbol()); + $init= new Braced(new Assignment($use, '=', $expression)); + } + + if ($pattern instanceof IsComparison) { + return new BinaryExpression( + new InvokeExpression(new Literal('is_numeric'), [$init]), + '&&', + new BinaryExpression($use, $pattern->operator, $pattern->value) + ); + } else if ($pattern instanceof IsNullable) { + return new BinaryExpression( + new BinaryExpression(new Literal('null'), '===', $init), + '||', + $match($codegen, $use, $pattern->element) + ); + } else if ($pattern instanceof IsCompound) { + $compound= $match($codegen, $init, $pattern->patterns[0]); + for ($i= 1, $s= sizeof($pattern->patterns), $op= $pattern->operator.$pattern->operator; $i < $s; $i++) { + $compound= new BinaryExpression($compound, $op, $match($codegen, $use, $pattern->patterns[$i])); + } + return new Braced($compound); + } else if ($pattern instanceof IsObjectStructure) { + $compound= new InstanceOfExpression($init, $pattern->type); + foreach ($pattern->patterns as $key => $p) { + $compound= new BinaryExpression($compound, '&&', $match( + $codegen, + new InstanceExpression($use, new Literal($key)), + $p + )); + } + return $compound; + } else if ($pattern instanceof IsArrayStructure) { + $compound= new BinaryExpression( + new InvokeExpression(new Literal('is_array'), [$init]), + '&&', + new BinaryExpression( + new InvokeExpression(new Literal('sizeof'), [$use]), + $pattern->rest ? '>=' : '===', + new Literal((string)sizeof($pattern->patterns)) + ) + ); + foreach ($pattern->patterns as $key => $p) { + $offset= new Literal((string)$key); + $compound= new BinaryExpression($compound, '&&', new BinaryExpression( + new InvokeExpression(new Literal('array_key_exists'), [$offset, $use]), + '&&', + $match($codegen, new OffsetExpression($use, $offset), $p), + )); + } + return $compound; + } + + // Should be unreachable + throw new IllegalStateException('Unsupported pattern '.$pattern->toString()); + }; + + $emitter->transform('is', function($codegen, $node) use($match) { + return $match($codegen, $node->expression, $node->pattern); }); } } \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/php/PatternMatch.class.php b/src/main/php/lang/ast/syntax/php/PatternMatch.class.php new file mode 100755 index 0000000..62ac237 --- /dev/null +++ b/src/main/php/lang/ast/syntax/php/PatternMatch.class.php @@ -0,0 +1,14 @@ +expression= $expression; + $this->pattern= $pattern; + $this->line= $line; + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/ArrayStructureTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/ArrayStructureTest.class.php new file mode 100755 index 0000000..32c1603 --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/ArrayStructureTest.class.php @@ -0,0 +1,66 @@ + 1, "two" => 2] is ["one" => 1, "two" => 2]', true]; + yield ['["two" => 2, "one" => 1] is ["one" => 1, "two" => 2]', true]; + yield ['[1, 2] is ["one" => 1, "two" => 2]', false]; + yield ['null is ["one" => 1, "two" => 2]', false]; + + yield ['["one" => 1] is ["one" => 1, ...]', true]; + yield ['["one" => 1, "two" => 2] is ["one" => 1, ...]', true]; + yield ['["two" => 2] is ["one" => 1, ...]', false]; + yield ['["two" => 2] is ["one" => null]', false]; + + yield ['[2] is [0 => 2]', true]; + yield ['[2] is ["0" => 2]', true]; + yield ['[1] is [0 => 2]', false]; + yield ['[1] is ["0" => 2]', false]; + + yield ['[1, 2] is [1, 2|3]', true]; + yield ['[1, 3] is [1, 2|3]', true]; + yield ['[1, 2, 3] is [1, 2|3]', false]; + yield ['[1, 2] is [1, >=0 & <=10]', true]; + + yield ['[1, 2] is [1, int|string]', true]; + yield ['[1, "2"] is [1, int|string]', true]; + yield ['[1, null] is [1, int|string]', false]; + } + + #[Test, Values(from: 'fixtures')] + public function test($expr, $expected) { + Assert::equals($expected, $this->run('class %T { + public function run() { + return '.$expr.'; + } + }')); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/CompoundTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/CompoundTest.class.php new file mode 100755 index 0000000..c4af735 --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/CompoundTest.class.php @@ -0,0 +1,87 @@ +0 & <10', true]; + yield ['0 is >0 & <10', false]; + + yield ['1 is int|float', true]; + yield ['1.5 is int|float', true]; + yield ['"test" is int|float', false]; + + yield ['1 is int & 1', true]; + yield ['2 is int & (1|2)', true]; + yield ['3 is int & float', false]; + yield ['3 is 1|2|3', true]; + yield ['"test" is string & "success"', false]; + + yield ['"test" is "success"|"failure"', false]; + yield ['"success" is "success"|"failure"', true]; + yield ['"success" is "success"|"failure"|null', true]; + yield ['"success" is "success"|"failure"|"running"', true]; + + yield ['[] is ""|array', true]; + yield ['[] is array|""', true]; + yield ['[] is array|null', true]; + yield ['[] is null|array', true]; + yield ['[] is [] & [...]', true]; + + yield ['$this is array|Traversable', true]; + yield ['$this is IteratorAggregate|Runnable', true]; + yield ['new Date() is IteratorAggregate|Runnable', false]; + + yield ['$this is IteratorAggregate&Runnable', true]; + yield ['$this is (IteratorAggregate&Runnable)', true]; + yield ['null is IteratorAggregate&Runnable', false]; + yield ['new Date() is IteratorAggregate&Runnable', false]; + + yield ['$this is null|(IteratorAggregate&Runnable)', true]; + yield ['null is null|(IteratorAggregate&Runnable)', true]; + yield ['$this is (IteratorAggregate&Runnable)|null', true]; + yield ['null is (IteratorAggregate&Runnable)|null', true]; + yield ['null is IteratorAggregate&(Runnable|null)', false]; + + yield ['1 is null | >0 & <10', true]; + yield ['null is null | >0 & <10', true]; + yield ['0 is null | >0 & <10', false]; + } + + #[Test, Values(from: 'fixtures')] + public function test($expr, $expected) { + Assert::equals($expected, $this->run(' + use util\\Date, lang\\Runnable, lang\\ast\\syntax\\php\\unittest\\Point; + + class %T implements Runnable, IteratorAggregate { + + public function getIterator(): Traversable { + yield true; + } + + public function run() { + return '.$expr.'; + } + }')); + } + + #[Test] + public function expression_evaluated_once() { + Assert::equals([true, 1], $this->run('class %T { + public function run() { + $evaluated= 0; + $expr= function() use(&$evaluated) { + $evaluated++; + return 1; + }; + + $result= $expr() is >0 & <2; + return [$result, $evaluated]; + } + }')); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/IsOperatorTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/IsOperatorTest.class.php index 9b28b66..ddc6e74 100755 --- a/src/test/php/lang/ast/syntax/php/unittest/IsOperatorTest.class.php +++ b/src/test/php/lang/ast/syntax/php/unittest/IsOperatorTest.class.php @@ -7,38 +7,54 @@ class IsOperatorTest extends EmittingTest { #[Test] public function is_mixed_type() { - $r= $this->run('class %T { + Assert::true($this->run('class %T { public function run() { return $this is mixed; } - }'); - - Assert::true($r); + }')); } #[Test] public function precedence() { - $r= $this->run('class %T { + Assert::equals('string ', $this->run('class %T { public function run() { $arg= "Test"; return $arg is string ? sprintf("string <%s>", $arg) : typeof($arg)->literal(); } - }'); - - Assert::equals('string ', $r); + }')); } #[Test, Values([[1, 'int'], ['test', 'string']])] public function with_match_statement($arg, $expected) { - $r= $this->run('class %T { + Assert::equals($expected, $this->run('class %T { public function run(string|int $arg) { return match { $arg is string => "string", $arg is int => "int", }; } - }', $arg); + }', $arg)); + } + + #[Test, Values([[1, 'int'], ['test', 'string']])] + public function match_is_variant($arg, $expected) { + Assert::equals($expected, $this->run('class %T { + public function run(string|int $arg) { + return match ($arg) is { + string => "string", + int => "int", + }; + } + }', $arg)); + } - Assert::equals($expected, $r); + #[Test, Values([[1, true], ["one", true], [null, false]])] + public function as_closure($arg, $expected) { + Assert::equals($expected, $this->run('class %T { + public function run($arg) { + $f= fn($arg) => $arg is int|string; + return $f($arg); + } + }', $arg)); } } \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/LiteralTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/LiteralTest.class.php new file mode 100755 index 0000000..ebeb566 --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/LiteralTest.class.php @@ -0,0 +1,39 @@ +run('class %T { + const ZERO= 0; + + public function run() { + return '.$expr.'; + } + }')); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/NumericComparisonTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/NumericComparisonTest.class.php new file mode 100755 index 0000000..4d351b6 --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/NumericComparisonTest.class.php @@ -0,0 +1,44 @@ +0', true]; + yield ['1 is >=1', true]; + yield ['1 is <=1', true]; + yield ['0 is <1', true]; + yield ['1 is <1', false]; + + yield ['1.0 is >0.0', true]; + yield ['1.0 is >=1.0', true]; + yield ['1.0 is <=1.0', true]; + yield ['0.0 is <1.0', true]; + yield ['1.0 is <1.0', false]; + + yield ['0 is 0', true]; + + yield ['"1" is >0', true]; + yield ['"1e2" is >=100', true]; + yield ['"3.141" is >3 & <4', true]; + + yield ['null is >0', false]; + yield ['[] is >0', false]; + yield ['"test" is >0', false]; + yield ['((object)[]) is >0', false]; + } + + #[Test, Values(from: 'fixtures')] + public function test($expr, $expected) { + Assert::equals($expected, $this->run('class %T { + public function run() { + return '.$expr.'; + } + }')); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/ObjectStructureTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/ObjectStructureTest.class.php new file mode 100755 index 0000000..afda43b --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/ObjectStructureTest.class.php @@ -0,0 +1,40 @@ +=0)', true]; + yield ['new Point(1, 2) is Point(x: 1, y: 2|3)', true]; + yield ['new Point(1, 2) is Point(x: 1, y: mixed)', true]; + + yield ['new Point(1, 2) is Point(x: 1)&Value', true]; + yield ['new Point(1, 2) is Point(x: 1)|null', true]; + } + + #[Test, Values(from: 'fixtures')] + public function test($expr, $expected) { + Assert::equals($expected, $this->run('use lang\\Value, lang\\ast\\syntax\\php\\unittest\\Point; class %T { + public function run() { + return '.$expr.'; + } + }')); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/Point.class.php b/src/test/php/lang/ast/syntax/php/unittest/Point.class.php new file mode 100755 index 0000000..37df003 --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/Point.class.php @@ -0,0 +1,38 @@ +x= $x; + $this->y= $y; + $this->z= $z; + } + + /** @return string */ + public function hashCode() { + return 'P'.$this->x.','.$this->y.','.$this->z; + } + + /** @return string */ + public function toString() { + return namoef($this).'('.$this->x.', '.$this->y.', '.$this->z.')'; + } + + /** + * Comparison + * + * @param var $value + * @return int + */ + public function compareTo($value) { + return $value instanceof self + ? Objects::compare([$this->x, $this->y, $this->z], [$value->x, $value->y, $value->z]) + : 1 + ; + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/TypeMatchingTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/TypeMatchingTest.class.php index a35f70e..411f9ef 100755 --- a/src/test/php/lang/ast/syntax/php/unittest/TypeMatchingTest.class.php +++ b/src/test/php/lang/ast/syntax/php/unittest/TypeMatchingTest.class.php @@ -3,6 +3,7 @@ use lang\ast\unittest\emit\EmittingTest; use test\{Assert, Test, Values}; +/** @see https://wiki.php.net/rfc/pattern-matching#type_pattern */ class TypeMatchingTest extends EmittingTest { /** @return iterable */ @@ -73,20 +74,12 @@ private function fixtures() { yield ['new Date() is Date', true]; yield ['$this is IteratorAggregate', true]; + yield ['$this is Runnable', true]; yield ['$this is Date', false]; - yield ['$this is IteratorAggregate&Runnable', true]; - yield ['new Date() is IteratorAggregate&Runnable', false]; - yield ['null is IteratorAggregate&Runnable', false]; - yield ['$this is self', true]; yield ['new Date() is self', false]; yield ['null is self', false]; - - yield ['1 is int|float', true]; - yield ['1.5 is int|float', true]; - yield ['"test" is int|float', false]; - yield ['$this is array|IteratorAggregate', true]; } #[Test, Values(from: 'fixtures')] @@ -109,4 +102,20 @@ public function run() { } }')); } + + #[Test] + public function expression_evaluated_once() { + Assert::equals([true, 1], $this->run('class %T { + public function run() { + $evaluated= 0; + $expr= function() use(&$evaluated) { + $evaluated++; + return 1; + }; + + $result= $expr() is ?int; + return [$result, $evaluated]; + } + }')); + } } \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/VariableBindingTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/VariableBindingTest.class.php new file mode 100755 index 0000000..3c02f5e --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/VariableBindingTest.class.php @@ -0,0 +1,118 @@ + 0) ? [$x, $y, $z] : null', [1, 2, 3]]; + yield ['new Point(1, 2, 0) is Point(:$x, :$y, :$z & > 0) ? [$x, $y, $z] : null', null]; + + yield ['[1, 2, 3] is [1, 2, $z] ? $z : null', 3]; + yield ['[0, 2, 3] is [1, 2, $z] ? $z : null', null]; + yield ['[1, 2, 3] is [$x, $y, $z] ? [$x, $y, $z] : null', [1, 2, 3]]; + yield ['[0, 1, 2] is [$x, $y, $z] ? [$x, $y, $z] : null', [0, 1, 2]]; + + yield ['[1, [1]] is [1, $rest] ? $rest : null', [1]]; + yield ['[1, [2], "three"] is [1, mixed, $rest] ? $rest : null', 'three']; + + yield ['["one" => 1, "two" => 2] is ["one" => 1, "two" => $t] ? $t : null', 2]; + yield ['["one" => 0, "two" => 2] is ["one" => 1, "two" => $t] ? $t : null', null]; + } + + #[Test, Values(from: 'fixtures')] + public function test($expr, $expected) { + Assert::equals($expected, $this->run('use lang\\Value, lang\\ast\\syntax\\php\\unittest\\Point; class %T { + public function run() { + return '.$expr.'; + } + }')); + } + + #[Test, Values(['get apple', 'pick up apple', 'pick apple up'])] + public function compound_binding($input) { + Assert::equals('apple', $this->run('class %T { + public function run($command) { + return match ($command) is { + ["get", $object]|["pick", "up", $object]|["pick", $object, "up"] => $object, + default => null, + }; + } + }', explode(' ', $input))); + } + + #[Test, Values([['go north', 'north'], ['go south', 'south'], ['go home', null]])] + public function bind_with_subpattern($input, $expected) { + Assert::equals($expected, $this->run('class %T { + public function run($command) { + return match ($command) is { + ["go", $direction & ("north"|"south"|"east"|"west")] => $direction, + default => null, + }; + } + }', explode(' ', $input))); + } + + #[Test, Expect(class: Errors::class, message: '/Expected "\]", have "\|"/')] + public function binding_may_not_be_ored() { + $this->run('class %T { + public function run() { + [] is [$variable | 0]; + } + }'); + } + + #[Test, Expect(class: NullPointerException::class, message: '/Undefined variable(.+)z/')] + public function delayed_binding() { + Assert::equals([false, null], $this->run('use lang\\ast\\syntax\\php\\unittest\\Point; class %T { + public function run() { + new Point(1, -1) is Point(x: 0, :$z); + return $z; + } + }')); + } + + #[Test] + public function nested_list_destructuring() { + Assert::equals([1, 2, 3], $this->run('class %T { + public function run() { + return [[1, 2], 3] is [[$a, $b], $c] ? [$a, $b, $c] : null; + } + }')); + } + + #[Test] + public function nested_map_destructuring() { + Assert::equals('^8.0', $this->run('class %T { + public function run() { + return ["require" => ["php" => "^8.0"]] is ["require" => ["php" => $version]] ? $version : null; + } + }')); + } + + #[Test, Values(['[] is [$var]', '["test"] is [$var & int]'])] + public function variable_unset_when_unmatched($expr) { + Assert::false($this->run('class %T { + public function run() { + '.$expr.'; + return isset($var); + } + }')); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/syntax/php/unittest/VariablePinningTest.class.php b/src/test/php/lang/ast/syntax/php/unittest/VariablePinningTest.class.php new file mode 100755 index 0000000..76a8fa6 --- /dev/null +++ b/src/test/php/lang/ast/syntax/php/unittest/VariablePinningTest.class.php @@ -0,0 +1,35 @@ +run('use lang\\Value, lang\\ast\\syntax\\php\\unittest\\Point; class %T { + const ZERO= 0; + + public function run() { + '.$expr.'; + } + }')); + } +} \ No newline at end of file