Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
247043a
Add proper AST and lexer
nicoSWD Apr 28, 2026
c453d74
Refactor
nicoSWD Apr 28, 2026
14072d8
Refactor
nicoSWD Apr 28, 2026
e535ad4
Refactor
nicoSWD Apr 29, 2026
3bfaec2
Add highlighter
nicoSWD Apr 29, 2026
4eafbbd
Update README.md
nicoSWD Apr 29, 2026
a7064a2
Add check for duplicate regex modifiers
nicoSWD Apr 29, 2026
be6d13e
Add support for string concatenation with "+" and addition for numeri…
nicoSWD Apr 29, 2026
aef7318
Add support for doing math operations
nicoSWD Apr 29, 2026
551589e
Add support for returning actual results instead of just true/false
nicoSWD Apr 29, 2026
e2da5f5
Simplify highlighter
nicoSWD Apr 29, 2026
a3a1c17
Rename classes
nicoSWD Apr 29, 2026
87552f0
Add support for unary minus and logical NOT operators
nicoSWD Apr 29, 2026
8104eb8
Fix constructor consistency
nicoSWD Apr 29, 2026
4292137
Replace match expressions in AstEvaluator with Interpreter pattern
nicoSWD Apr 29, 2026
087e628
Fix: eliminate duplicated node evaluation logic in AstEvaluator
nicoSWD Apr 29, 2026
a010f6b
Add .DS_Store to .gitignore
nicoSWD Apr 29, 2026
88a49fd
Fix: replace fragile $afterValue boolean with stack-based token inspe…
nicoSWD Apr 29, 2026
56f665e
Fix string escape sequences not being unescaped in TokenEncapsedString
nicoSWD Apr 29, 2026
e71fd6a
Fix division by zero not handled in Evaluator
nicoSWD Apr 29, 2026
e3ce3fb
Remove dead getNativeValue() method from AST value nodes
nicoSWD Apr 29, 2026
e4f2029
Simplify token system: replace 27 token classes with GenericToken + T…
nicoSWD Apr 29, 2026
e921b7d
refactor: eliminate TokenStream God object and clean up dependency tree
nicoSWD Apr 29, 2026
4197959
Consolidate InternalFunction and InternalMethod into single InternalC…
nicoSWD Apr 29, 2026
5b0ff3b
Remove dead code
nicoSWD Apr 29, 2026
90a59aa
Fix AST cache never used (double parse) in Rule.php
nicoSWD Apr 29, 2026
3a52e46
Fix infinite recursion in Rule.php property hook
nicoSWD Apr 29, 2026
c1253dd
Fix wrong data structure in TokenCollection: replace SplObjectStorage…
nicoSWD Apr 29, 2026
7cb2cfd
Remove intersection types and empty marker interfaces
nicoSWD Apr 29, 2026
732f980
Fix: Eliminate new instance creation on every function call in Functi…
nicoSWD Apr 29, 2026
bd6f5fc
Fix Lexer mutable state / reentrancy issue
nicoSWD Apr 29, 2026
ed00138
refactor: eliminate God constructor in RuleEngine by moving dependenc…
nicoSWD Apr 29, 2026
c406cfe
Refactor fragile method prefix fallback loop in ObjectMethodCaller
nicoSWD Apr 29, 2026
d475ea0
Remove dead code: Expression directory, EvaluableExpression, and rela…
nicoSWD Apr 29, 2026
4dc4592
Consolidate Token and TokenKind enums to reduce duplication
nicoSWD Apr 29, 2026
f0bd63a
Remove unused TokenIterator methods (getStack, getVariable, getFuncti…
nicoSWD Apr 29, 2026
bf8a026
refactor: convert Lexer::tokenize() from array-based to generator-bas…
nicoSWD Apr 29, 2026
2668928
refactor: remove TokenIteratorFactory, remove peekRaw(), drop readonl…
nicoSWD Apr 29, 2026
dfc43c1
Fix anti-pattern: eliminate double evaluation in isValid/getError
nicoSWD Apr 29, 2026
5fc7f1a
Fix anti-pattern: rename TokenizerInterface abstract class to Tokenizer
nicoSWD Apr 29, 2026
7ceba46
Refactor: rename Tokenizer namespace to Lexer
nicoSWD Apr 29, 2026
ab8618a
Refactor: Replace old Parser with recursive descent parser and consol…
nicoSWD Apr 29, 2026
e445912
Pass raw PHP values to callables instead of wrapping in tokens
nicoSWD Apr 29, 2026
aa7f2aa
Use raw PHP values
nicoSWD Apr 29, 2026
74aa923
Update README.md
nicoSWD Apr 29, 2026
780e5f5
Cleanup
nicoSWD Apr 29, 2026
e360b3c
Fix md format
nicoSWD Apr 29, 2026
cd38980
Update coding standard from PSR-2 to PSR-12
nicoSWD Apr 29, 2026
f3cf95f
Update composer dependencies to latest versions
nicoSWD Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/tests/log
/composer.lock
/coverage.clover
/.phpunit.result.cache
/.phpunit.*
/vendor/
/.idea
/.composer
.DS_Store
2 changes: 1 addition & 1 deletion .styleci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
preset: psr2
preset: psr12
risky: true
version: 8.4
finder:
Expand Down
170 changes: 113 additions & 57 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
[![Code Quality][Master quality image]][Master quality]
[![StyleCI](https://styleci.io/repos/39503126/shield?branch=master&style=flat)](https://styleci.io/repos/39503126)

You're looking at a standalone PHP library to parse and evaluate text based rules with a Javascript-like syntax. This project was born out of the necessity to evaluate hundreds of rules that were originally written and evaluated in JavaScript, and now needed to be evaluated on the server-side, using PHP.
A standalone PHP library to parse and evaluate text-based rules using a JavaScript-like syntax. This project was born out of the need to evaluate hundreds of rules originally written in JavaScript on the server side, using PHP.

This library has initially been used to change and configure the behavior of certain "Workflows" (without changing actual code) in an intranet application, but it may serve a purpose elsewhere.
The library was initially used to configure the behavior of "Workflows" in an intranet application without changing actual code, but it may serve a purpose elsewhere.

## Install

Expand Down Expand Up @@ -44,7 +44,7 @@ $variables = [
'coupon_code' => (string) $_POST['coupon_code'],
];

$rule = new Rule('coupon_code.test(/^summer20[0-9]{2}$/) == true', $variables);
$rule = new Rule('coupon_code.test(/^summer20[0-9]{2}$/)', $variables);
var_dump($rule->isTrue()); // bool(true)
```

Expand All @@ -56,6 +56,60 @@ $rule = new Rule('points >= 50 && points <= 100', $variables);
var_dump($rule->isTrue()); // bool(true)
```

Perform arithmetic operations
```php
$variables = ['price' => 100, 'quantity' => 3];

$rule = new Rule('price * quantity > 250', $variables);
var_dump($rule->isTrue()); // bool(true)
```

Arithmetic operators follow standard precedence rules: `*`, `/`, `%` bind tighter than `+`, `-`. Parentheses can be used to override precedence.
```php
$rule = new Rule('2 + 3 * 4 == 14');
var_dump($rule->isTrue()); // bool(true) - multiplication before addition

$rule = new Rule('(2 + 3) * 4 == 20');
var_dump($rule->isTrue()); // bool(true) - parentheses override precedence
```

Unary minus and logical NOT operators are also supported:
```php
$rule = new Rule('-5 * 3 == -15');
var_dump($rule->isTrue()); // bool(true) - unary minus binds tighter than multiplication

$rule = new Rule('--5 == 5');
var_dump($rule->isTrue()); // bool(true) - double negation

$rule = new Rule('!false');
var_dump($rule->isTrue()); // bool(true) - logical NOT

$rule = new Rule('!(1 == 2)');
var_dump($rule->isTrue()); // bool(true) - NOT with parenthesized comparison

$rule = new Rule('!foo', ['foo' => false]);
var_dump($rule->isTrue()); // bool(true) - NOT with variable
```

Get the actual computed result of an expression (not just true/false)
```php
$rule = new Rule('5 * 3');
var_dump($rule->result()); // int(15)

$rule = new Rule('"hello " + "world"');
var_dump($rule->result()); // string("hello world")

$rule = new Rule('parseInt("42")');
var_dump($rule->result()); // int(42)

$rule = new Rule('price * quantity', ['price' => 100, 'quantity' => 3]);
var_dump($rule->result()); // int(300)

// Comparison and logical expressions still return bool
$rule = new Rule('foo > 5', ['foo' => 10]);
var_dump($rule->result()); // bool(true)
```

Call methods on objects from within rules
```php
class User
Expand All @@ -73,54 +127,62 @@ $variables = [
$rule = new Rule('user.points() > 300', $variables);
var_dump($rule->isTrue()); // bool(true)
```

For security reasons, PHP's magic methods like `__construct` and `__destruct` cannot be
called from within rules. However, `__call` will be invoked automatically if available,
unless the called method is defined.
unless the called method is defined.

## Built-in Methods

Name | Example
----------- | ------------------------
charAt | `"foo".charAt(2) === "o"`
concat | `"foo".concat("bar", "baz") === "foobarbaz"`
endsWith | `"foo".endsWith("oo") === true`
startsWith | `"foo".startsWith("fo") === true`
indexOf | `"foo".indexOf("oo") === 1`
join | `["foo", "bar"].join(",") === "foo,bar"`
replace | `"foo".replace("oo", "aa") === "faa"`
split | `"foo-bar".split("-") === ["foo", "bar"]`
substr | `"foo".substr(1) === "oo"`
test | `"foo".test(/oo$/) === true`
toLowerCase | `"FOO".toLowerCase() === "foo"`
toUpperCase | `"foo".toUpperCase() === "FOO"`
| Name | Example |
| ----------- | ------------------------ |
| charAt | `"foo".charAt(2) === "o"` |
| concat | `"foo".concat("bar", "baz") === "foobarbaz"` |
| endsWith | `"foo".endsWith("oo")` |
| startsWith | `"foo".startsWith("fo")` |
| indexOf | `"foo".indexOf("oo") === 1` |
| join | `["foo", "bar"].join(",") === "foo,bar"` |
| replace | `"foo".replace("oo", "aa") === "faa"` |
| split | `"foo-bar".split("-") === ["foo", "bar"]` |
| substr | `"foo".substr(1) === "oo"` |
| test | `"foo".test(/oo$/)` |
| toLowerCase | `"FOO".toLowerCase() === "foo"` |
| toUpperCase | `"foo".toUpperCase() === "FOO"` |

## Built-in Functions

Name | Example
----------- | ------------------------
parseInt | `parseInt("22aa") === 22`
parseFloat | `parseFloat("3.1") === 3.1`
| Name | Example |
| ----------- | ------------------------ |
| parseInt | `parseInt("22aa") === 22` |
| parseFloat | `parseFloat("3.1") === 3.1` |

## Supported Operators

Type | Description | Operator
----------- | ------------------------ | ----------
Comparison | greater than | >
Comparison | greater than or equal to | >=
Comparison | less than | <
Comparison | less or equal to | <=
Comparison | equal to | ==
Comparison | not equal to | !=
Comparison | identical | ===
Comparison | not identical | !==
Containment | contains | in
Containment | does not contain | not in
Logical | and | &&
Logical | or | \|\|
| Type | Description | Operator |
| ----------- | ------------------------ | ---------- |
| Comparison | greater than | > |
| Comparison | greater than or equal to | >= |
| Comparison | less than | < |
| Comparison | less or equal to | <= |
| Comparison | equal to | == |
| Comparison | not equal to | != |
| Comparison | identical | === |
| Comparison | not identical | !== |
| Containment | contains | in |
| Containment | does not contain | not in |
| Logical | and | && |
| Logical | or | \|\| |
| Arithmetic | addition | + |
| Arithmetic | subtraction | - |
| Arithmetic | multiplication | * |
| Arithmetic | division | / |
| Arithmetic | modulo | % |
| Unary | negation | - |
| Unary | logical NOT | ! |

## Error Handling

Both, `$rule->isTrue()` and `$rule->isFalse()` will throw an exception if the syntax is invalid. These calls can either be placed inside a `try` / `catch` block, or it can be checked prior using `$rule->isValid()`.
Both `$rule->isTrue()` and `$rule->isFalse()` will throw an exception if the syntax is invalid. These calls can either be placed inside a `try` / `catch` block, or validity can be checked beforehand using `$rule->isValid()`.

```php
$ruleStr = '
Expand All @@ -143,18 +205,19 @@ Or alternatively:

```php
if (!$rule->isValid()) {
echo $rule->getError();
echo $rule->error;
}
```

Both will output: `Unexpected token "(" at position 25 on line 3`
Both will output: `Unexpected "(" at position 28`

## Syntax Highlighting

A custom syntax highlighter is also provided.

```php
use nicoSWD\Rule;
use nicoSWD\Rule\Highlighter\Highlighter;
use nicoSWD\Rule\TokenStream\Token\TokenType;

$ruleStr = '
// This is true
Expand All @@ -170,7 +233,7 @@ $ruleStr = '
bar > 6
)';

$highlighter = new Rule\Highlighter\Highlighter(new Rule\Tokenizer());
$highlighter = new Highlighter();

// Optional custom styles
$highlighter->setStyle(
Expand All @@ -185,11 +248,6 @@ Outputs:

![Syntax preview](https://s3.amazonaws.com/f.cl.ly/items/0y1b0s0J2v2v1u3O1F3M/Screen%20Shot%202015-08-05%20at%2012.15.21.png)

## Notes

- Parentheses can be nested, and will be evaluated from right to left.
- Only value/variable comparison expressions with optional logical ANDs/ORs, are supported.

## Security

If you discover any security related issues, please email security@nic0.me instead of using the issue tracker.
Expand All @@ -202,26 +260,24 @@ $ composer test

## Contributing

Pull requests are very welcome! If they include tests, even better. This project follows PSR-2 coding standards, please make sure your pull requests do too.
Pull requests are very welcome! If they include tests, even better. This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standards, please make sure your pull requests do too.

## To Do

- Support for object properties (foo.length)
- Support for returning actual results, other than true or false
- Support for array / string dereferencing: "foo"[1]
- Don't force boolean comparison for tokens that are already booleans. `my_func() && 2 > 1` should work
- Change regex and implementation for method calls. ".split(" should not be the token
- Add / implement missing methods
- Add "typeof" construct
- Do math (?)
- Allow string concatenating with "+"
- Invalid regex modifiers should not result in an unknown token
- Duplicate regex modifiers should throw an error
- ~~Support for returning actual results, other than true or false~~
- ~~Don't force boolean comparison for tokens that are already booleans. `my_func() && 2 > 1` should work~~
- ~~Allow string concatenating with "+"~~
- ~~Duplicate regex modifiers should throw an error~~
- ~~Add support for function calls~~
- ~~Support for regular expressions~~
- ~~Fix build on PHP 7 / Nightly~~
- ~~Allow variables in arrays~~
- ~~Verify function and method name spelling (.tOuPpErCAse() is currently valid)~~
- ~~Change regex and implementation for method calls~~
- ~~Add / implement missing methods~~
- ~~Invalid regex modifiers should not result in an unknown token~~
- ...

## License
Expand Down
8 changes: 5 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@
}
},
"require": {
"php": ">=8.4"
"php": ">=8.5",
"ext-ctype": "*"
},
"require-dev": {
"phpunit/phpunit": "^12.3",
"mockery/mockery": "^1.6"
"phpunit/phpunit": "^13.1",
"mockery/mockery": "^1.6",
"squizlabs/php_codesniffer": "^4.0"
},
"scripts": {
"test": "vendor/bin/phpunit"
Expand Down
15 changes: 15 additions & 0 deletions phpunit.xml.dist.bak
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="src/autoload.php">
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<testsuites>
<testsuite name="Unit tests">
<directory>tests/</directory>
</testsuite>
</testsuites>
</phpunit>
32 changes: 32 additions & 0 deletions src/AST/AdditionNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/**
* @license http://opensource.org/licenses/mit-license.php MIT
* @link https://github.com/nicoSWD
* @author Nicolas Oelgart <hello@nico.es>
*/

declare(strict_types=1);

namespace nicoSWD\Rule\AST;

final class AdditionNode extends Node
{
public function __construct(
public readonly Node $left,
public readonly Node $right,
) {
}

public function evaluate(EvaluationContext $context): mixed
{
$left = $this->left->evaluate($context);
$right = $this->right->evaluate($context);

if (is_string($left) || is_string($right)) {
return (string) $left . (string) $right;
}

return $left + $right;
}
}
34 changes: 34 additions & 0 deletions src/AST/ArrayNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/**
* @license http://opensource.org/licenses/mit-license.php MIT
* @link https://github.com/nicoSWD
* @author Nicolas Oelgart <hello@nico.es>
*/

declare(strict_types=1);

namespace nicoSWD\Rule\AST;

final class ArrayNode extends Node
{
/** @param Node[] $items */
public function __construct(
public readonly array $items,
) {
}

/**
* @throws \RuntimeException
*/
public function evaluate(EvaluationContext $context): array
{
$items = [];

foreach ($this->items as $item) {
$items[] = $item->evaluate($context);
}

return $items;
}
}
Loading