Skip to content
oxygensuitePublic

About

A lightweight Abstract Syntax Tree (AST) implementation for PHP, enabling structured parsing, analysis, and transformation of PHP code.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

Repository files navigation

oxygensuite/php-ast

A lightweight formula engine for PHP built on an Abstract Syntax Tree (AST). Parse, evaluate, and transform expressions with support for variables, functions, wildcards, and operator precedence.

Requirements

  • PHP 8.4+
  • ext-ctype
  • ext-mbstring

Installation

composer require oxygensuite/php-ast

Quick Start

use OxygenSuite\PhpAst\Formulas\FormulaEvaluator;

$evaluator = new FormulaEvaluator();

$data = [
    'lines' => [
        ['quantity' => 2, 'unit_price' => 10.0, 'discount' => 0],
        ['quantity' => 3, 'unit_price' => 15.0, 'discount' => 5],
        ['quantity' => 1, 'unit_price' =>  8.0, 'discount' => 0],
    ],
];

// Variable access (dot-notation)
$evaluator->evaluate('lines.0.quantity', $data); // 2

// Arithmetic
$evaluator->evaluate('lines.0.quantity * lines.0.unit_price', $data); // 20.0

// Wildcards
$evaluator->evaluate('SUM(lines.*.quantity)', $data); // 6

// Functions
$evaluator->evaluate('MAP(lines; quantity * unit_price - discount)', $data); // [20, 40, 8]

Variables

Variables use dot-notation for nested property access. Types are preserved (string, int, float, bool, null, array).

Syntax Resolves to
uid $data['uid']
lines.0.quantity $data['lines'][0]['quantity']
a.b.c $data['a']['b']['c']

Variables can be used directly in expressions:

$evaluator->evaluate('lines.0.quantity * lines.0.unit_price', $data);
$evaluator->evaluate('(base_price + tax) * quantity', $data);

Wildcards

The * wildcard extracts values across all items in an array.

// Single-level: lines.*.quantity → PLUCK(lines; "quantity")
$evaluator->evaluate('lines.*.quantity', $data); // [2, 3, 1]

// Multi-level: lines.*.taxes.*.amount → extracts all tax amounts from all lines
$evaluator->evaluate('SUM(lines.*.taxes.*.amount)', $data);

Operators

Listed from highest to lowest precedence:

Precedence Operators Description
1 ** Exponentiation
2 * / % Multiplication, division, modulo
3 + - Addition, subtraction; + joins text
4 == != < > <= >= <=> Comparison
5 && Logical AND
6 || Logical OR
7 ?? ?: Null coalescing, elvis

Parentheses override precedence: (a + b) * c.

Variables keep their values' types, so text compares and combines as text: customer.name == "ACME", street || city. + adds numbers, including a number written as text when the other side is a number ("1" + 5 is 6), and joins text otherwise, so codes keep their digits ("001" + "12" is "00112"). &&, || and ?: read a value as true or false the way IF() does: text is false only when it is "", "0" or "false".

Functions

All functions use semicolons (;) as argument separators.

Aggregation

Function Description Example
SUM(values) Sum of numeric values SUM(lines.*.quantity)
AVG(values) Average AVG(lines.*.unit_price)
MIN(values) Minimum value MIN(lines.*.unit_price)
MAX(values) Maximum value MAX(lines.*.quantity)
COUNT(values) Number of elements, 1 for a single value, 0 for a missing one COUNT(lines.*.quantity)

Rounding

Function Description Example
ROUND(value; precision) Round to precision decimals ROUND(10.567; 2) → 10.57
CEIL(value) Round up CEIL(10.1) → 11
FLOOR(value) Round down FLOOR(10.9) → 10

Array Manipulation

Function Description Example
PLUCK(array; "key") Extract column from array of objects PLUCK(lines; "quantity")
FLAT(array; depth) Flatten nested arrays (default depth: 1) FLAT([[1,2],[3,[4,5]]]; 2)
MAP(array; expr) Apply expression per item MAP(lines; quantity * unit_price)
FILTER(array; condition) Filter items by condition FILTER(lines; unit_price > 10)
GROUP(array; "key") Group items by key GROUP(lines; "tax_category")
SORT(array) Sort array SORT(lines.*.quantity)
JOIN(array; separator) Join into string JOIN(lines.*.quantity; ",")
CONCAT(value; …) Join values as text, lists item by item CONCAT(series; "-"; number)
TEXTJOIN(separator; value; …) Join the non-blank values with a separator TEXTJOIN(" "; street; number)
FIRST(array) First element FIRST(lines)
LAST(array) Last element LAST(lines)
LEN(text) Number of characters LEN(header.invoice_number)

Conditional

Function Description Example
IF(cond; true_val; false_val) Conditional IF(quantity > 10; "bulk"; "single")
VALUE(array; "key") Get value by key VALUE(line; "quantity")
EMPTY(value) Missing, blank or an empty list EMPTY(customer.name)
EXISTS(value) Not empty: zero counts as a value EXISTS(header.series)

Composing Functions

Functions can be nested and combined with arithmetic:

// Sum of (quantity * unit_price) per line
$evaluator->evaluate('SUM(MAP(lines; quantity * unit_price))', $data);

// Filter then sum
$evaluator->evaluate('SUM(PLUCK(FILTER(lines; tax_category == 1); "net_amount"))', $data);

// Grouped aggregation
$evaluator->evaluate('GROUP(lines; "tax_category")', $data);
// → [1 => [...], 2 => [...], 3 => [...]]

// Arithmetic on aggregated results
$evaluator->evaluate('SUM(lines.*.net_amount) + SUM(lines.*.vat_amount)', $data);

Context Providers

For injecting external values (e.g., configuration, session data) into formula evaluation:

use OxygenSuite\PhpAst\ContextProvider\ContextProvider;

class MyContext implements ContextProvider
{
    public function getContextValue(string $key): mixed
    {
        return match ($key) {
            'tax_rate' => 0.24,
            'currency' => 'EUR',
            default => null,
        };
    }
}

$evaluator = new FormulaEvaluator(new MyContext());
$evaluator->evaluate('unit_price * tax_rate', $data); // uses tax_rate from context

Context values take priority over data array values.

Wildcard Resolvers

A context provider that also implements WildcardResolver decides which items a wildcard path reads, for example the current line of a loop over lines. It gets each wildcard path as the formula writes it, and whether a function reads it as its list (SUM(lines.*.net_amount), COUNT(lines.*.taxes)), and returns the path to read instead, or null to read every item:

use OxygenSuite\PhpAst\ContextProvider\ContextProvider;
use OxygenSuite\PhpAst\ContextProvider\WildcardResolver;

class CurrentLine implements ContextProvider, WildcardResolver
{
    public function __construct(private int $line) {}

    public function getContextValue(string $key): mixed
    {
        return null;
    }

    public function resolveWildcardPath(string $path, bool $asList): ?string
    {
        // The current line's values, except where a function sums or counts all lines
        return $asList ? null : preg_replace('/^lines\.\*/', "lines.{$this->line}", $path);
    }
}

$evaluator = new FormulaEvaluator(new CurrentLine(2));
$evaluator->evaluate('lines.*.net_amount / SUM(lines.*.net_amount)', $data); // line 2's share of the total

A function's #[FunctionDefinition] says which argument it reads as a list (listArgument), whether only when it is the only argument (listArgumentAlone, as for SUM, which otherwise adds up its arguments), and whether its result keeps the keys of that list (keepsKeys, as for FILTER and MAP).

Custom Functions

Simple Function

Implement the Formula interface:

use OxygenSuite\PhpAst\AST\ASTEvaluator;
use OxygenSuite\PhpAst\Formulas\Formula;

readonly class DoubleFunction implements Formula
{
    public function execute(array $arguments, array $data, ASTEvaluator $evaluator): float|int|string|array
    {
        return ($arguments[0] ?? 0) * 2;
    }
}

AST-Aware Function

For functions that need per-item evaluation (like MAP/FILTER), implement ASTFormula instead. It receives raw AST nodes so you can evaluate expressions against each item:

use OxygenSuite\PhpAst\AST\ASTEvaluator;
use OxygenSuite\PhpAst\Formulas\ASTFormula;

readonly class MyTransform implements ASTFormula
{
    public function execute(array $astNodes, array $data, ASTEvaluator $evaluator): mixed
    {
        [$itemsNode, $callbackNode] = $astNodes;
        $items = $itemsNode->accept($evaluator, $data);

        return array_map(
            fn($item) => $callbackNode->accept($evaluator, $item),
            $items,
        );
    }
}

Caching

Parsed ASTs are automatically cached per evaluator instance (default: 1000 entries). Repeated evaluation of the same formula string skips parsing entirely.

$evaluator->getCacheStats(); // ['hits' => 42, 'misses' => 10, 'size' => 10, 'hitRate' => 0.807]
$evaluator->clearCache();

Error Handling

Scenario Behavior
Missing variable Returns null
Division by zero Returns 0
Unknown function Throws ParseException
Invalid expression Throws ParseException
Arithmetic on text Throws RuntimeException
Recursion depth > 32 Throws RuntimeException

License

MIT

About

A lightweight Abstract Syntax Tree (AST) implementation for PHP, enabling structured parsing, analysis, and transformation of PHP code.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages