diff --git a/lib/Expression/Ast/CallableNode.php b/lib/Expression/Ast/CallableNode.php new file mode 100644 index 000000000..68c26de8b --- /dev/null +++ b/lib/Expression/Ast/CallableNode.php @@ -0,0 +1,21 @@ +callable; + } + + public function args(): ArgumentListNode + { + return $this->args; + } +} diff --git a/lib/Expression/Ast/LambdaNode.php b/lib/Expression/Ast/LambdaNode.php new file mode 100644 index 000000000..2b5570907 --- /dev/null +++ b/lib/Expression/Ast/LambdaNode.php @@ -0,0 +1,21 @@ +node; + } + + public function expression(): Node + { + return $this->expression; + } + +} diff --git a/lib/Expression/Func/UserFunction.php b/lib/Expression/Func/UserFunction.php new file mode 100644 index 000000000..b498e486d --- /dev/null +++ b/lib/Expression/Func/UserFunction.php @@ -0,0 +1,23 @@ +expr; + } + + public function __invoke(...$args): array + { + return $args; + } +} diff --git a/lib/Expression/Lexer.php b/lib/Expression/Lexer.php index 368cb2986..a670f8a30 100644 --- a/lib/Expression/Lexer.php +++ b/lib/Expression/Lexer.php @@ -25,6 +25,7 @@ final class Lexer '-' => Token::T_MINUS, '*' => Token::T_MULTIPLY, '%' => Token::T_PERCENTAGE, + '=>' => Token::T_LAMBDA, '/' => Token::T_DIVIDE, '>=' => Token::T_GTE, '<=' => Token::T_LTE, diff --git a/lib/Expression/NodeEvaluator/CallableEvaluator.php b/lib/Expression/NodeEvaluator/CallableEvaluator.php new file mode 100644 index 000000000..c371c2492 --- /dev/null +++ b/lib/Expression/NodeEvaluator/CallableEvaluator.php @@ -0,0 +1,34 @@ +evaluate($node, $params); + + if (!$value instanceof ScalarValue) { + throw new EvaluationError($node, sprintf( + 'Expected expression to resolve to a scalar value, got %s', + $value::class + )); + } + + return $value->value(); + }, $node->args()->nodes()); + + return $evaluator->evaluate($node->callable(), $args); + } +} diff --git a/lib/Expression/NodeEvaluator/FunctionEvaluator.php b/lib/Expression/NodeEvaluator/FunctionEvaluator.php index 4e2c9e450..78857bc31 100644 --- a/lib/Expression/NodeEvaluator/FunctionEvaluator.php +++ b/lib/Expression/NodeEvaluator/FunctionEvaluator.php @@ -3,12 +3,14 @@ namespace PhpBench\Expression\NodeEvaluator; use PhpBench\Expression\Ast\ArgumentListNode; +use PhpBench\Expression\Ast\CallableNode; use PhpBench\Expression\Ast\FunctionNode; use PhpBench\Expression\Ast\Node; use PhpBench\Expression\Ast\PhpValue; use PhpBench\Expression\Evaluator; use PhpBench\Expression\Exception\EvaluationError; use PhpBench\Expression\ExpressionFunctions; +use PhpBench\Expression\Func\UserFunction; use PhpBench\Expression\LazyExpr; use PhpBench\Expression\LazyFunction; use PhpBench\Expression\NodeEvaluator; @@ -51,6 +53,10 @@ public function doEvaluate(Evaluator $evaluator, FunctionNode $node, array $para try { $function = $this->functions->get($node->name()); + if ($function instanceof UserFunction) { + return $evaluator->evaluate(new CallableNode($function->lambda(), $node->args()), []); + } + if ($function instanceof LazyFunction) { $args = array_map(function (Node $node) use ($evaluator, $params) { return new LazyExpr($evaluator, $node, $params); diff --git a/lib/Expression/NodeEvaluator/LambdaEvaluator.php b/lib/Expression/NodeEvaluator/LambdaEvaluator.php new file mode 100644 index 000000000..ab0a25d67 --- /dev/null +++ b/lib/Expression/NodeEvaluator/LambdaEvaluator.php @@ -0,0 +1,36 @@ +name(); + }, $node->params()->nodes()); + + if (count($names) !== count($params)) { + throw new EvaluationError($node, sprintf('Expected %d arguments, got %d', + count($names), + count($params) + )); + } + + $args = array_combine($names, $params); + + return $evaluator->evaluate($node->expression(), $args); + } +} diff --git a/lib/Expression/NodePrinter/CallablePrinter.php b/lib/Expression/NodePrinter/CallablePrinter.php new file mode 100644 index 000000000..ff3fa125e --- /dev/null +++ b/lib/Expression/NodePrinter/CallablePrinter.php @@ -0,0 +1,24 @@ +print($node->callable()), + $printer->print($node->args()) + ); + } +} diff --git a/lib/Expression/NodePrinter/LambdaPrinter.php b/lib/Expression/NodePrinter/LambdaPrinter.php new file mode 100644 index 000000000..25a6f8342 --- /dev/null +++ b/lib/Expression/NodePrinter/LambdaPrinter.php @@ -0,0 +1,26 @@ + %s', + $printer->print($node->params()), + $printer->print($node->expression()) + ); + } +} diff --git a/lib/Expression/Parselet/CallableParselet.php b/lib/Expression/Parselet/CallableParselet.php new file mode 100644 index 000000000..462e90aba --- /dev/null +++ b/lib/Expression/Parselet/CallableParselet.php @@ -0,0 +1,45 @@ +chomp(Token::T_OPEN_PAREN); + $args = []; + if ($tokens->current()->type !== Token::T_CLOSE_PAREN) { + $args = $parser->parseList($tokens); + if (!$args instanceof ArgumentListNode) { + $args = new ArgumentListNode([$args]); + } + } + $tokens->chomp(Token::T_CLOSE_PAREN); + + if (!$callable instanceof ParenthesisNode) { + throw SyntaxError::forToken( + $tokens, + $tokens->current(), + 'Callable expression must be parenthesized' + ); + } + + return new CallableNode($callable->expression(), $args); + } +} diff --git a/lib/Expression/Parselet/LambdaParselet.php b/lib/Expression/Parselet/LambdaParselet.php new file mode 100644 index 000000000..ab630f4cf --- /dev/null +++ b/lib/Expression/Parselet/LambdaParselet.php @@ -0,0 +1,60 @@ +chomp(); + + if (!$argList instanceof ParenthesisNode) { + throw SyntaxError::forToken($tokens, $lambda, sprintf( + 'Left operand to lambda must be a parenthesized list of arguments, got: %s', + $left::class + )); + } + + $argList = $argList->expression(); + + if (!$argList instanceof ArgumentListNode) { + $argList = new ArgumentListNode([$argList]); + } + + $expression = $parser->parseExpression($tokens); + + return new LambdaNode(new ArgumentListNode(array_map(function (Node $node) use ($tokens, $lambda) { + if (!$node instanceof VariableNode) { + throw SyntaxError::forToken($tokens, $lambda, sprintf( + 'Invalid parameter of type: %s', + $node::class + )); + } + + return $node; + }, $argList->nodes())), $expression); + } + + public function precedence(): int + { + return Precedence::LAMBDA; + } +} diff --git a/lib/Expression/Parselet/PercentageParselet.php b/lib/Expression/Parselet/PercentageParselet.php index 372f88086..b002df3ba 100644 --- a/lib/Expression/Parselet/PercentageParselet.php +++ b/lib/Expression/Parselet/PercentageParselet.php @@ -4,6 +4,7 @@ use PhpBench\Expression\Ast\Node; use PhpBench\Expression\Ast\PercentageNode; +use PhpBench\Expression\Parser; use PhpBench\Expression\SuffixParselet; use PhpBench\Expression\Token; use PhpBench\Expression\Tokens; @@ -15,7 +16,7 @@ public function tokenType(): string return Token::T_PERCENTAGE; } - public function parse(Node $left, Tokens $tokens): Node + public function parse(Parser $parser, Node $left, Tokens $tokens): Node { $tokens->chomp(); diff --git a/lib/Expression/Parselet/ValueWithUnitParselet.php b/lib/Expression/Parselet/ValueWithUnitParselet.php index 5eb89223f..fc8af7049 100644 --- a/lib/Expression/Parselet/ValueWithUnitParselet.php +++ b/lib/Expression/Parselet/ValueWithUnitParselet.php @@ -6,6 +6,7 @@ use PhpBench\Expression\Ast\StringNode; use PhpBench\Expression\Ast\UnitNode; use PhpBench\Expression\Ast\ValueWithUnitNode; +use PhpBench\Expression\Parser; use PhpBench\Expression\SuffixParselet; use PhpBench\Expression\Token; use PhpBench\Expression\Tokens; @@ -17,7 +18,7 @@ public function tokenType(): string return Token::T_UNIT; } - public function parse(Node $left, Tokens $tokens): Node + public function parse(Parser $parser, Node $left, Tokens $tokens): Node { return new ValueWithUnitNode($left, new UnitNode(new StringNode($tokens->chomp()->value))); } diff --git a/lib/Expression/Parser.php b/lib/Expression/Parser.php index daa64870f..40ebb769f 100644 --- a/lib/Expression/Parser.php +++ b/lib/Expression/Parser.php @@ -75,7 +75,7 @@ public function parseExpression(Tokens $tokens, int $precedence = 0): Node $suffixParser = $this->suffixParselets->forTokenOrNull($tokens->current()); if ($suffixParser instanceof SuffixParselet) { - $left = $suffixParser->parse($left, $tokens); + $left = $suffixParser->parse($this, $left, $tokens); } while ($precedence < $this->infixPrecedence($tokens->current())) { diff --git a/lib/Expression/Precedence.php b/lib/Expression/Precedence.php index af60fdea8..8307c2406 100644 --- a/lib/Expression/Precedence.php +++ b/lib/Expression/Precedence.php @@ -4,6 +4,7 @@ final class Precedence { + public const LAMBDA = 200; public const ACCESS = 120; public const AS = 110; public const PRODUCT = 100; diff --git a/lib/Expression/SuffixParselet.php b/lib/Expression/SuffixParselet.php index 838764f09..422ba06d9 100644 --- a/lib/Expression/SuffixParselet.php +++ b/lib/Expression/SuffixParselet.php @@ -6,5 +6,5 @@ interface SuffixParselet extends Parselet { - public function parse(Node $left, Tokens $tokens): Node; + public function parse(Parser $parser, Node $left, Tokens $tokens): Node; } diff --git a/lib/Expression/Token.php b/lib/Expression/Token.php index 11f950a26..e71c4cdd9 100644 --- a/lib/Expression/Token.php +++ b/lib/Expression/Token.php @@ -42,6 +42,7 @@ final class Token public const T_STRING = 'string'; public const T_PARAMETER = 'parameter'; public const T_QUESTION = 'question'; + public const T_LAMBDA = 'lambda'; public function __construct(public string $type, public string $value, public int $offset) { diff --git a/lib/Extension/ExpressionExtension.php b/lib/Extension/ExpressionExtension.php index 17f8220ee..838d63a27 100644 --- a/lib/Extension/ExpressionExtension.php +++ b/lib/Extension/ExpressionExtension.php @@ -9,6 +9,7 @@ use PhpBench\Expression\Ast\ArithmeticOperatorNode; use PhpBench\Expression\Ast\DisplayAsNode; use PhpBench\Expression\Ast\FunctionNode; +use PhpBench\Expression\Ast\LambdaNode; use PhpBench\Expression\Ast\ParameterNode; use PhpBench\Expression\Ast\ParenthesisNode; use PhpBench\Expression\Ast\TolerableNode; @@ -41,16 +42,19 @@ use PhpBench\Expression\Func\SumFunction; use PhpBench\Expression\Func\TimeConvertFunction; use PhpBench\Expression\Func\TimeUnitFunction; +use PhpBench\Expression\Func\UserFunction; use PhpBench\Expression\Func\VarianceFunction; use PhpBench\Expression\Lexer; use PhpBench\Expression\NodeEvaluator; use PhpBench\Expression\NodeEvaluator\AccessEvaluator; use PhpBench\Expression\NodeEvaluator\ArgumentListEvaluator; use PhpBench\Expression\NodeEvaluator\ArithmeticOperatorEvaluator; +use PhpBench\Expression\NodeEvaluator\CallableEvaluator; use PhpBench\Expression\NodeEvaluator\ComparisonEvaluator; use PhpBench\Expression\NodeEvaluator\ConcatEvaluator; use PhpBench\Expression\NodeEvaluator\DisplayAsEvaluator; use PhpBench\Expression\NodeEvaluator\FunctionEvaluator; +use PhpBench\Expression\NodeEvaluator\LambdaEvaluator; use PhpBench\Expression\NodeEvaluator\ListEvaluator; use PhpBench\Expression\NodeEvaluator\LogicalOperatorEvaluator; use PhpBench\Expression\NodeEvaluator\NullSafeEvaluator; @@ -65,6 +69,7 @@ use PhpBench\Expression\NodePrinter\ArrayAccessPrinter; use PhpBench\Expression\NodePrinter\BinaryOperatorPrinter; use PhpBench\Expression\NodePrinter\BooleanPrinter; +use PhpBench\Expression\NodePrinter\CallablePrinter; use PhpBench\Expression\NodePrinter\ComparisonPrinter; use PhpBench\Expression\NodePrinter\ConcatenatedNodePrinter; use PhpBench\Expression\NodePrinter\ConcatPrinter; @@ -73,6 +78,7 @@ use PhpBench\Expression\NodePrinter\FunctionPrinter; use PhpBench\Expression\NodePrinter\HighlightingNodePrinter; use PhpBench\Expression\NodePrinter\LabelPrinter; +use PhpBench\Expression\NodePrinter\LambdaPrinter; use PhpBench\Expression\NodePrinter\ListPrinter; use PhpBench\Expression\NodePrinter\NullPrinter; use PhpBench\Expression\NodePrinter\NullSafePrinter; @@ -92,12 +98,14 @@ use PhpBench\Expression\Parselet\ArithmeticOperatorParselet; use PhpBench\Expression\Parselet\ArrayAccessParselet; use PhpBench\Expression\Parselet\BooleanParselet; +use PhpBench\Expression\Parselet\CallableParselet; use PhpBench\Expression\Parselet\ComparisonParselet; use PhpBench\Expression\Parselet\ConcatParselet; use PhpBench\Expression\Parselet\DisplayAsParselet; use PhpBench\Expression\Parselet\FloatParselet; use PhpBench\Expression\Parselet\FunctionParselet; use PhpBench\Expression\Parselet\IntegerParselet; +use PhpBench\Expression\Parselet\LambdaParselet; use PhpBench\Expression\Parselet\ListParselet; use PhpBench\Expression\Parselet\LogicalOperatorParselet; use PhpBench\Expression\Parselet\NullParselet; @@ -134,10 +142,12 @@ class ExpressionExtension implements ExtensionInterface final public const PARAM_THEME = 'expression.theme'; final public const PARAM_MEMORY_UNIT_PREFIX = 'expression.memory_unit_prefix'; final public const PARAM_STRIP_TAILING_ZEROS = 'expression.strip_tailing_zeros'; + final public const PARAM_USER_FUNCTIONS = 'expression.user_functions'; final public const SERVICE_PLAIN_PRINTER = 'expression.printer.plain'; final public const SERVICE_BARE_PRINTER = 'expression.printer.bare'; + final public const TAG_THEME = 'expression.theme'; final public const THEME_BASIC = 'basic'; @@ -193,9 +203,11 @@ public function load(Container $container): void new ArrayAccessParselet(), new PropertyAccessParselet(), new NullSafeParselet(), + new LambdaParselet(), ]), Parselets::fromSuffixParselets([ new ValueWithUnitParselet(), + new CallableParselet(), new PercentageParselet(), ]) ); @@ -218,6 +230,8 @@ public function load(Container $container): void new PhpValueEvaluator(), new AccessEvaluator(), new NullSafeEvaluator(), + new CallableEvaluator(), + new LambdaEvaluator(), ]); return $evaluators; @@ -315,6 +329,8 @@ public function load(Container $container): void new ArrayAccessPrinter(), new NullSafePrinter(), new DataFramePrinter(), + new CallablePrinter(), + new LambdaPrinter(), ]); }); @@ -345,7 +361,7 @@ public function load(Container $container): void }); $container->register(ExpressionFunctions::class, function (Container $container) { - return new ExpressionFunctions([ + $map = [ 'mode' => new ModeFunction(), 'mean' => new MeanFunction(), 'min' => new MinFunction(), @@ -367,7 +383,23 @@ public function load(Container $container): void 'sum' => new SumFunction(), 'frame' => new FrameFunction(), 'contains' => new ContainsFunction(), - ]); + ]; + + foreach ($container->getParameter(self::PARAM_USER_FUNCTIONS) as $name => $lamdba) { + $lexer = $container->get(Lexer::class); + $parser = $container->get(Parser::class); + $lambda = $parser->parse($lexer->lex($lamdba)); + if (!$lambda instanceof LambdaNode) { + throw new \RuntimeException(sprintf( + 'Expression for user function "%s" must be a lambda of form `(param1, param2) => param1 + param2`', + $name + )); + } + $map[$name] = new UserFunction($lambda); + } + + + return new ExpressionFunctions($map); }); $container->register(Lexer::class, function (Container $container) { @@ -400,6 +432,7 @@ public function configure(OptionsResolver $resolver): void self::PARAM_THEME => self::THEME_SOLARIZED, self::PARAM_MEMORY_UNIT_PREFIX => self::PREFIX_DECIMAL, self::PARAM_STRIP_TAILING_ZEROS => false, + self::PARAM_USER_FUNCTIONS => [], ]); $resolver->setAllowedTypes(self::PARAM_SYNTAX_HIGHLIGHTING, 'bool'); $resolver->setAllowedTypes(self::PARAM_THEME, 'string'); diff --git a/lib/Report/Generator/ExpressionGenerator.php b/lib/Report/Generator/ExpressionGenerator.php index b0c6f8caf..0e530e497 100644 --- a/lib/Report/Generator/ExpressionGenerator.php +++ b/lib/Report/Generator/ExpressionGenerator.php @@ -39,7 +39,11 @@ class ExpressionGenerator implements GeneratorInterface final public const PARAM_INCLUDE_BASELINE = 'include_baseline'; final public const PARAM_DERIVATIONS = 'derivations'; - public function __construct(private readonly ExpressionEvaluator $evaluator, private readonly SuiteCollectionTransformer $transformer, private readonly LoggerInterface $logger) + public function __construct( + private readonly ExpressionEvaluator $evaluator, + private readonly SuiteCollectionTransformer $transformer, + private readonly LoggerInterface $logger + ) { } @@ -82,6 +86,7 @@ public function configure(OptionsResolver $options): void $options->setAllowedTypes(self::PARAM_AGGREGATE, 'array'); $options->setAllowedTypes(self::PARAM_BREAK, 'array'); $options->setAllowedTypes(self::PARAM_INCLUDE_BASELINE, 'bool'); + $options->setNormalizer(self::PARAM_EXPRESSIONS, function (Options $options, array $expressions) use ($formatTime) { return array_merge([ 'tag' => 'first(suite_tag)', @@ -109,12 +114,30 @@ public function configure(OptionsResolver $options): void ), E_USER_DEPRECATED); } + $baselineExpr = function (string $primaryExpr, string $baselineExpr) { + return sprintf( + '%s ~ if(%s," " ~ %s," !!!")', + $primaryExpr, + 'first(has_baseline) = true', + $baselineExpr + ); + }; + return array_merge([ 'best' => $formatTime('min(result_time_avg)'), 'worst' => $formatTime('max(result_time_avg)'), - 'mode' => $formatTime('mode(result_time_avg)') . ' ~" "~ percent_diff(mode(baseline_time_avg), mode(result_time_avg), rstdev(result_time_avg))', - 'mem_peak' => '(first(baseline_mem_peak) as memory) ~ " " ~ percent_diff(first(baseline_mem_peak), first(result_mem_peak))', - 'rstdev' => 'rstdev(result_time_avg) ~ " " ~ percent_diff(rstdev(baseline_time_avg), rstdev(result_time_avg))', + 'mode' => $baselineExpr( + $formatTime('mode(result_time_avg)'), + 'percent_diff(mode(baseline_time_avg), mode(result_time_avg), rstdev(result_time_avg))', + ), + 'mem_peak' => $baselineExpr( + 'first(result_mem_peak) as memory', + 'percent_diff(first(baseline_mem_peak), first(result_mem_peak))' + ), + 'rstdev' => $baselineExpr( + 'rstdev(result_time_avg)', + 'percent_diff(rstdev(baseline_time_avg), rstdev(result_time_avg))', + ), ], $expressions); }); $options->setNormalizer(self::PARAM_COLS, function (Options $options, ?array $cols) { @@ -127,6 +150,7 @@ public function configure(OptionsResolver $options): void return array_keys($expressions); }); + SymfonyOptionsResolverCompat::setInfos($options, [ self::PARAM_TITLE => 'Title to use for report', self::PARAM_DESCRIPTION => 'Description to use for report', diff --git a/tests/Unit/Expression/Func/UserFunctionTest.php b/tests/Unit/Expression/Func/UserFunctionTest.php new file mode 100644 index 000000000..63fe32c7c --- /dev/null +++ b/tests/Unit/Expression/Func/UserFunctionTest.php @@ -0,0 +1,21 @@ +container([ + 'expression.user_functions' => [ + 'foobar' => '(foo) => if(foo,1+1,2+2)', + ], + ])->get(ExpressionBridge::class); + + self::assertEquals(2, $lang->evaluatePhpValue('foobar(true)', [])); + self::assertEquals(4, $lang->evaluatePhpValue('foobar(false)', [])); + } +} diff --git a/tests/Unit/Expression/Parselet/LambdaParseletTest.php b/tests/Unit/Expression/Parselet/LambdaParseletTest.php new file mode 100644 index 000000000..709653413 --- /dev/null +++ b/tests/Unit/Expression/Parselet/LambdaParseletTest.php @@ -0,0 +1,47 @@ + + */ + public static function provideParse(): Generator + { + yield [ + '(foo, bar) => foo ~ bar', + new LambdaNode( + new ArgumentListNode([new VariableNode('foo'), new VariableNode('bar')]), + new ConcatNode(new VariableNode('foo'), new VariableNode('bar')), + ), + ]; + } + + /** + * {@inheritDoc} + */ + public static function provideEvaluate(): Generator + { + yield [ + '((first, sec) => first + sec)(1, 2)', + [], + '3' + ]; + } + + /** + * {@inheritDoc} + */ + public static function providePrint(): Generator + { + yield from static::providePrintFromEvaluate(); + } +}