2026-03-12 06:34:56 +07:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
|
|
|
|
|
use Symfony\Component\ExpressionLanguage\ParsedExpression;
|
|
|
|
|
|
|
|
|
|
class RuleExpressionService
|
|
|
|
|
{
|
|
|
|
|
protected ExpressionLanguage $language;
|
|
|
|
|
|
|
|
|
|
/** @var array<string, ParsedExpression> */
|
|
|
|
|
protected array $parsedCache = [];
|
|
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
|
|
|
|
$this->language = new ExpressionLanguage();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function evaluate(string $expr, array $context = [])
|
|
|
|
|
{
|
|
|
|
|
$expr = trim($expr);
|
|
|
|
|
if ($expr === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$names = array_keys($context);
|
|
|
|
|
sort($names);
|
|
|
|
|
$cacheKey = md5($expr . '|' . implode(',', $names));
|
|
|
|
|
|
|
|
|
|
if (!isset($this->parsedCache[$cacheKey])) {
|
|
|
|
|
$this->parsedCache[$cacheKey] = $this->language->parse($expr, $names);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->language->evaluate($this->parsedCache[$cacheKey], $context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function evaluateBoolean(?string $expr, array $context = []): bool
|
|
|
|
|
{
|
|
|
|
|
if ($expr === null || trim($expr) === '') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (bool) $this->evaluate($expr, $context);
|
|
|
|
|
}
|
2026-03-12 16:55:03 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compile DSL expression to engine-compatible JSON structure.
|
|
|
|
|
*
|
2026-03-16 07:24:50 +07:00
|
|
|
* Supported DSL (canonical):
|
|
|
|
|
* - if(condition; then-actions; else-actions)
|
|
|
|
|
* - sex('F'|'M') -> patient["Sex"] == 'F'
|
|
|
|
|
* - priority('R'|'S'|'U') -> order["Priority"] == 'S'
|
|
|
|
|
* - age > 18 -> age > 18
|
|
|
|
|
* - requested('CODE') -> requested('CODE') (resolved at runtime)
|
2026-03-16 16:39:54 +07:00
|
|
|
* - result_set('TestSiteCode', value)
|
|
|
|
|
* - result_set(value) (deprecated, uses current context TestSiteID)
|
2026-03-16 07:24:50 +07:00
|
|
|
* - test_insert('CODE')
|
|
|
|
|
* - test_delete('CODE')
|
|
|
|
|
* - comment_insert('text')
|
|
|
|
|
* - nothing
|
|
|
|
|
* - Multi-action: result_set(0.5):test_insert('CODE'):comment_insert('text')
|
|
|
|
|
*
|
|
|
|
|
* Backward compatible aliases:
|
|
|
|
|
* - set_result -> result_set
|
|
|
|
|
* - insert -> test_insert
|
|
|
|
|
* - add_comment -> comment_insert
|
2026-03-12 16:55:03 +07:00
|
|
|
*
|
|
|
|
|
* @param string $expr The raw DSL expression
|
2026-03-16 07:24:50 +07:00
|
|
|
* @return array The compiled structure with actions
|
2026-03-12 16:55:03 +07:00
|
|
|
* @throws \InvalidArgumentException If DSL is invalid
|
|
|
|
|
*/
|
|
|
|
|
public function compile(string $expr): array
|
|
|
|
|
{
|
|
|
|
|
$expr = trim($expr);
|
|
|
|
|
if ($expr === '') {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
$inner = $this->extractIfInner($expr);
|
|
|
|
|
if ($inner === null) {
|
|
|
|
|
// Allow callers to pass without if(...) wrapper
|
|
|
|
|
if (substr_count($expr, ';') >= 2) {
|
|
|
|
|
$inner = $expr;
|
|
|
|
|
} else {
|
|
|
|
|
throw new \InvalidArgumentException('Invalid DSL: expected "if(condition; then; else)" format');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$parts = $this->splitTopLevel($inner, ';', 3);
|
|
|
|
|
if (count($parts) !== 3) {
|
|
|
|
|
// Fallback: legacy ternary syntax
|
|
|
|
|
$ternary = $this->convertTopLevelTernaryToSemicolon($inner);
|
|
|
|
|
if ($ternary !== null) {
|
|
|
|
|
$parts = $this->splitTopLevel($ternary, ';', 3);
|
|
|
|
|
}
|
2026-03-12 16:55:03 +07:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
if (count($parts) !== 3) {
|
|
|
|
|
throw new \InvalidArgumentException('Invalid DSL: expected exactly 3 parts "condition; then; else"');
|
2026-03-12 16:55:03 +07:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
$condition = trim($parts[0]);
|
|
|
|
|
$thenAction = trim($parts[1]);
|
|
|
|
|
$elseAction = trim($parts[2]);
|
2026-03-12 16:55:03 +07:00
|
|
|
|
|
|
|
|
// Compile condition
|
|
|
|
|
$compiledCondition = $this->compileCondition($condition);
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
// Compile actions (supports multi-action with : separator)
|
|
|
|
|
$thenActions = $this->compileMultiAction($thenAction);
|
|
|
|
|
$elseActions = $this->compileMultiAction($elseAction);
|
2026-03-12 16:55:03 +07:00
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
// Build valueExpr for backward compatibility
|
|
|
|
|
$thenValueExpr = $this->buildValueExpr($thenActions);
|
|
|
|
|
$elseValueExpr = $this->buildValueExpr($elseActions);
|
2026-03-12 16:55:03 +07:00
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
$valueExpr = "({$compiledCondition}) ? {$thenValueExpr} : {$elseValueExpr}";
|
2026-03-12 16:55:03 +07:00
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'conditionExpr' => $compiledCondition,
|
|
|
|
|
'valueExpr' => $valueExpr,
|
2026-03-16 07:24:50 +07:00
|
|
|
'then' => $thenActions,
|
|
|
|
|
'else' => $elseActions,
|
2026-03-12 16:55:03 +07:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compile DSL condition to ExpressionLanguage expression
|
2026-03-16 07:24:50 +07:00
|
|
|
* Supports: sex(), priority(), age, requested(), &&, ||, (), comparison operators
|
2026-03-12 16:55:03 +07:00
|
|
|
*/
|
|
|
|
|
private function compileCondition(string $condition): string
|
|
|
|
|
{
|
|
|
|
|
$condition = trim($condition);
|
2026-03-16 07:24:50 +07:00
|
|
|
if ($condition === '') {
|
|
|
|
|
return 'true';
|
2026-03-12 16:55:03 +07:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
$tokens = $this->tokenize($condition);
|
|
|
|
|
$out = '';
|
|
|
|
|
$count = count($tokens);
|
2026-03-12 16:55:03 +07:00
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
for ($i = 0; $i < $count; $i++) {
|
|
|
|
|
$t = $tokens[$i];
|
|
|
|
|
$type = $t['type'];
|
|
|
|
|
$val = $t['value'];
|
2026-03-12 16:55:03 +07:00
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
if ($type === 'op' && $val === '&&') {
|
|
|
|
|
$out .= ' and ';
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($type === 'op' && $val === '||') {
|
|
|
|
|
$out .= ' or ';
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($type === 'ident') {
|
|
|
|
|
$lower = strtolower($val);
|
|
|
|
|
|
|
|
|
|
// sex('M')
|
|
|
|
|
if ($lower === 'sex') {
|
|
|
|
|
$arg = $this->parseSingleStringArg($tokens, $i + 1);
|
|
|
|
|
if ($arg !== null) {
|
|
|
|
|
$out .= 'patient["Sex"] == "' . addslashes($arg['value']) . '"';
|
|
|
|
|
$i = $arg['endIndex'];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// priority('S')
|
|
|
|
|
if ($lower === 'priority') {
|
|
|
|
|
$arg = $this->parseSingleStringArg($tokens, $i + 1);
|
|
|
|
|
if ($arg !== null) {
|
|
|
|
|
$out .= 'order["Priority"] == "' . addslashes($arg['value']) . '"';
|
|
|
|
|
$i = $arg['endIndex'];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// requested('CODE')
|
|
|
|
|
if ($lower === 'requested') {
|
|
|
|
|
$arg = $this->parseSingleStringArg($tokens, $i + 1);
|
|
|
|
|
if ($arg !== null) {
|
|
|
|
|
$out .= 'requested("' . addslashes($arg['value']) . '")';
|
|
|
|
|
$i = $arg['endIndex'];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// age >= 18
|
|
|
|
|
if ($lower === 'age') {
|
|
|
|
|
$op = $tokens[$i + 1] ?? null;
|
|
|
|
|
$num = $tokens[$i + 2] ?? null;
|
|
|
|
|
if (($op['type'] ?? '') === 'op' && in_array($op['value'], ['<', '>', '<=', '>=', '==', '!='], true) && ($num['type'] ?? '') === 'number') {
|
|
|
|
|
$out .= 'age ' . $op['value'] . ' ' . $num['value'];
|
|
|
|
|
$i += 2;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$out .= $val;
|
2026-03-12 16:55:03 +07:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
return trim($out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compile multi-action string (separated by :)
|
|
|
|
|
* Returns array of action objects
|
|
|
|
|
*/
|
|
|
|
|
private function compileMultiAction(string $actionStr): array
|
|
|
|
|
{
|
|
|
|
|
$actionStr = trim($actionStr);
|
|
|
|
|
|
|
|
|
|
// Split by : separator (top-level only)
|
|
|
|
|
$actions = [];
|
|
|
|
|
|
|
|
|
|
$parts = $this->splitTopLevel($actionStr, ':');
|
|
|
|
|
|
|
|
|
|
foreach ($parts as $part) {
|
|
|
|
|
$action = $this->compileSingleAction(trim($part));
|
|
|
|
|
if ($action !== null) {
|
|
|
|
|
$actions[] = $action;
|
|
|
|
|
}
|
2026-03-12 16:55:03 +07:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
return $actions;
|
2026-03-12 16:55:03 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-16 07:24:50 +07:00
|
|
|
* Compile a single action
|
2026-03-12 16:55:03 +07:00
|
|
|
*/
|
2026-03-16 07:24:50 +07:00
|
|
|
private function compileSingleAction(string $action): ?array
|
2026-03-12 16:55:03 +07:00
|
|
|
{
|
|
|
|
|
$action = trim($action);
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
// nothing - no operation
|
|
|
|
|
if (strcasecmp($action, 'nothing') === 0) {
|
|
|
|
|
return [
|
|
|
|
|
'type' => 'NO_OP',
|
|
|
|
|
'value' => null,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 16:39:54 +07:00
|
|
|
// result_set(value) or result_set('CODE', value) [aliases: set_result]
|
|
|
|
|
if (preg_match('/^(result_set|set_result)\s*\(\s*(.+)\s*\)$/is', $action, $m)) {
|
|
|
|
|
$args = $this->splitTopLevel($m[2], ',', 2);
|
|
|
|
|
if (empty($args)) {
|
|
|
|
|
throw new \InvalidArgumentException('result_set requires a value expression');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$testCode = null;
|
|
|
|
|
if (count($args) === 2) {
|
|
|
|
|
$testCode = $this->unquoteStringArgument($args[0]);
|
|
|
|
|
if ($testCode === null) {
|
|
|
|
|
throw new \InvalidArgumentException('result_set test code must be a quoted string');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$value = trim($args[count($args) - 1]);
|
2026-03-12 16:55:03 +07:00
|
|
|
|
|
|
|
|
// Check if it's a number
|
|
|
|
|
if (is_numeric($value)) {
|
|
|
|
|
return [
|
2026-03-16 07:24:50 +07:00
|
|
|
'type' => 'RESULT_SET',
|
2026-03-12 16:55:03 +07:00
|
|
|
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
|
|
|
|
|
'valueExpr' => $value,
|
2026-03-16 16:39:54 +07:00
|
|
|
'testCode' => $testCode,
|
2026-03-12 16:55:03 +07:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
// Check if it's a quoted string (single or double quotes)
|
2026-03-12 16:55:03 +07:00
|
|
|
if (preg_match('/^["\'](.+)["\']$/s', $value, $vm)) {
|
|
|
|
|
return [
|
2026-03-16 07:24:50 +07:00
|
|
|
'type' => 'RESULT_SET',
|
2026-03-12 16:55:03 +07:00
|
|
|
'value' => $vm[1],
|
|
|
|
|
'valueExpr' => '"' . addslashes($vm[1]) . '"',
|
2026-03-16 16:39:54 +07:00
|
|
|
'testCode' => $testCode,
|
2026-03-12 16:55:03 +07:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Complex expression
|
|
|
|
|
return [
|
2026-03-16 07:24:50 +07:00
|
|
|
'type' => 'RESULT_SET',
|
2026-03-12 16:55:03 +07:00
|
|
|
'valueExpr' => $value,
|
2026-03-16 16:39:54 +07:00
|
|
|
'testCode' => $testCode,
|
2026-03-12 16:55:03 +07:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:24:50 +07:00
|
|
|
// test_insert('CODE') [aliases: insert]
|
|
|
|
|
if (preg_match('/^(test_insert|insert)\s*\(\s*["\']([^"\']+)["\']\s*\)$/i', $action, $m)) {
|
|
|
|
|
return [
|
|
|
|
|
'type' => 'TEST_INSERT',
|
|
|
|
|
'testCode' => $m[2],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// test_delete('CODE')
|
|
|
|
|
if (preg_match('/^test_delete\s*\(\s*["\']([^"\']+)["\']\s*\)$/i', $action, $m)) {
|
|
|
|
|
return [
|
|
|
|
|
'type' => 'TEST_DELETE',
|
|
|
|
|
'testCode' => $m[1],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// comment_insert('text') [aliases: add_comment]
|
|
|
|
|
if (preg_match('/^(comment_insert|add_comment)\s*\(\s*["\'](.+?)["\']\s*\)$/is', $action, $m)) {
|
|
|
|
|
return [
|
|
|
|
|
'type' => 'COMMENT_INSERT',
|
|
|
|
|
'comment' => $m[2],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 16:55:03 +07:00
|
|
|
throw new \InvalidArgumentException('Unknown action: ' . $action);
|
|
|
|
|
}
|
2026-03-16 07:24:50 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build valueExpr string from array of actions (for backward compatibility)
|
|
|
|
|
*/
|
|
|
|
|
private function buildValueExpr(array $actions): string
|
|
|
|
|
{
|
|
|
|
|
if (empty($actions)) {
|
|
|
|
|
return 'null';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use first SET_RESULT action's value, or null
|
|
|
|
|
foreach ($actions as $action) {
|
|
|
|
|
if (($action['type'] ?? '') === 'RESULT_SET' || ($action['type'] ?? '') === 'SET_RESULT') {
|
|
|
|
|
if (isset($action['valueExpr'])) {
|
|
|
|
|
return $action['valueExpr'];
|
|
|
|
|
}
|
|
|
|
|
if (isset($action['value'])) {
|
|
|
|
|
if (is_string($action['value'])) {
|
|
|
|
|
return '"' . addslashes($action['value']) . '"';
|
|
|
|
|
}
|
|
|
|
|
return json_encode($action['value']);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'null';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse and split multi-rule expressions (comma-separated)
|
|
|
|
|
* Returns array of individual rule expressions
|
|
|
|
|
*/
|
|
|
|
|
public function parseMultiRule(string $expr): array
|
|
|
|
|
{
|
|
|
|
|
$expr = trim($expr);
|
|
|
|
|
if ($expr === '') {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$rules = [];
|
|
|
|
|
$depth = 0;
|
|
|
|
|
$current = '';
|
|
|
|
|
$len = strlen($expr);
|
|
|
|
|
|
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
|
|
|
$char = $expr[$i];
|
|
|
|
|
|
|
|
|
|
if ($char === '(') {
|
|
|
|
|
$depth++;
|
|
|
|
|
} elseif ($char === ')') {
|
|
|
|
|
$depth--;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($char === ',' && $depth === 0) {
|
|
|
|
|
$trimmed = trim($current);
|
|
|
|
|
if ($trimmed !== '') {
|
|
|
|
|
$rules[] = $trimmed;
|
|
|
|
|
}
|
|
|
|
|
$current = '';
|
|
|
|
|
} else {
|
|
|
|
|
$current .= $char;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add last rule
|
|
|
|
|
$trimmed = trim($current);
|
|
|
|
|
if ($trimmed !== '') {
|
|
|
|
|
$rules[] = $trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $rules;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function extractIfInner(string $expr): ?string
|
|
|
|
|
{
|
|
|
|
|
$expr = trim($expr);
|
|
|
|
|
if (!preg_match('/^if\s*\(/i', $expr)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$pos = stripos($expr, '(');
|
|
|
|
|
if ($pos === false) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$start = $pos + 1;
|
|
|
|
|
$end = $this->findMatchingParen($expr, $pos);
|
|
|
|
|
if ($end === null) {
|
|
|
|
|
throw new \InvalidArgumentException('Invalid DSL: unbalanced parentheses in if(...)');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only allow trailing whitespace after the closing paren
|
|
|
|
|
if (trim(substr($expr, $end + 1)) !== '') {
|
|
|
|
|
throw new \InvalidArgumentException('Invalid DSL: unexpected trailing characters after if(...)');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trim(substr($expr, $start, $end - $start));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function findMatchingParen(string $s, int $openIndex): ?int
|
|
|
|
|
{
|
|
|
|
|
$len = strlen($s);
|
|
|
|
|
$depth = 0;
|
|
|
|
|
$inSingle = false;
|
|
|
|
|
$inDouble = false;
|
|
|
|
|
|
|
|
|
|
for ($i = $openIndex; $i < $len; $i++) {
|
|
|
|
|
$ch = $s[$i];
|
|
|
|
|
|
|
|
|
|
if ($ch === '\\') {
|
|
|
|
|
$i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!$inDouble && $ch === "'") {
|
|
|
|
|
$inSingle = !$inSingle;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!$inSingle && $ch === '"') {
|
|
|
|
|
$inDouble = !$inDouble;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($inSingle || $inDouble) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($ch === '(') {
|
|
|
|
|
$depth++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($ch === ')') {
|
|
|
|
|
$depth--;
|
|
|
|
|
if ($depth === 0) {
|
|
|
|
|
return $i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Split string by a delimiter only when not inside quotes/parentheses.
|
|
|
|
|
* If $limit is provided, splits into at most $limit parts.
|
|
|
|
|
*/
|
|
|
|
|
private function splitTopLevel(string $s, string $delimiter, ?int $limit = null): array
|
|
|
|
|
{
|
|
|
|
|
$s = (string) $s;
|
|
|
|
|
$len = strlen($s);
|
|
|
|
|
$depth = 0;
|
|
|
|
|
$inSingle = false;
|
|
|
|
|
$inDouble = false;
|
|
|
|
|
$parts = [];
|
|
|
|
|
$buf = '';
|
|
|
|
|
|
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
|
|
|
$ch = $s[$i];
|
|
|
|
|
|
|
|
|
|
if ($ch === '\\') {
|
|
|
|
|
$buf .= $ch;
|
|
|
|
|
if ($i + 1 < $len) {
|
|
|
|
|
$buf .= $s[$i + 1];
|
|
|
|
|
$i++;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$inDouble && $ch === "'") {
|
|
|
|
|
$inSingle = !$inSingle;
|
|
|
|
|
$buf .= $ch;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!$inSingle && $ch === '"') {
|
|
|
|
|
$inDouble = !$inDouble;
|
|
|
|
|
$buf .= $ch;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$inSingle && !$inDouble) {
|
|
|
|
|
if ($ch === '(') {
|
|
|
|
|
$depth++;
|
|
|
|
|
} elseif ($ch === ')') {
|
|
|
|
|
$depth = max(0, $depth - 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($depth === 0 && $ch === $delimiter) {
|
|
|
|
|
$parts[] = trim($buf);
|
|
|
|
|
$buf = '';
|
|
|
|
|
if ($limit !== null && count($parts) >= ($limit - 1)) {
|
|
|
|
|
$buf .= substr($s, $i + 1);
|
|
|
|
|
$i = $len;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$buf .= $ch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$parts[] = trim($buf);
|
|
|
|
|
$parts = array_values(array_filter($parts, static fn ($p) => $p !== ''));
|
|
|
|
|
return $parts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert a top-level ternary (condition ? then : else) into semicolon form.
|
|
|
|
|
*/
|
|
|
|
|
private function convertTopLevelTernaryToSemicolon(string $s): ?string
|
|
|
|
|
{
|
|
|
|
|
$len = strlen($s);
|
|
|
|
|
$depth = 0;
|
|
|
|
|
$inSingle = false;
|
|
|
|
|
$inDouble = false;
|
|
|
|
|
$qPos = null;
|
|
|
|
|
$cPos = null;
|
|
|
|
|
|
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
|
|
|
$ch = $s[$i];
|
|
|
|
|
if ($ch === '\\') {
|
|
|
|
|
$i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!$inDouble && $ch === "'") {
|
|
|
|
|
$inSingle = !$inSingle;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!$inSingle && $ch === '"') {
|
|
|
|
|
$inDouble = !$inDouble;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($inSingle || $inDouble) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($ch === '(') {
|
|
|
|
|
$depth++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($ch === ')') {
|
|
|
|
|
$depth = max(0, $depth - 1);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($depth === 0 && $ch === '?' && $qPos === null) {
|
|
|
|
|
$qPos = $i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($depth === 0 && $ch === ':' && $qPos !== null) {
|
|
|
|
|
$cPos = $i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($qPos === null || $cPos === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$cond = trim(substr($s, 0, $qPos));
|
|
|
|
|
$then = trim(substr($s, $qPos + 1, $cPos - $qPos - 1));
|
|
|
|
|
$else = trim(substr($s, $cPos + 1));
|
|
|
|
|
|
|
|
|
|
if ($cond === '' || $then === '' || $else === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $cond . '; ' . $then . '; ' . $else;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tokenize a condition for lightweight transforms.
|
|
|
|
|
* Returns tokens with keys: type (ident|number|string|op|punct|other), value.
|
|
|
|
|
*/
|
|
|
|
|
private function tokenize(string $s): array
|
|
|
|
|
{
|
|
|
|
|
$len = strlen($s);
|
|
|
|
|
$tokens = [];
|
|
|
|
|
|
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
|
|
|
$ch = $s[$i];
|
|
|
|
|
|
|
|
|
|
if (ctype_space($ch)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$two = ($i + 1 < $len) ? $ch . $s[$i + 1] : '';
|
|
|
|
|
if (in_array($two, ['&&', '||', '>=', '<=', '==', '!='], true)) {
|
|
|
|
|
$tokens[] = ['type' => 'op', 'value' => $two];
|
|
|
|
|
$i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (in_array($ch, ['>', '<', '(', ')', '[', ']', ',', '+', '-', '*', '/', '%', '!', '.', ':', '?'], true)) {
|
|
|
|
|
$tokens[] = ['type' => ($ch === '>' || $ch === '<' || $ch === '!' ? 'op' : 'punct'), 'value' => $ch];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($ch === '"' || $ch === "'") {
|
|
|
|
|
$quote = $ch;
|
|
|
|
|
$buf = $quote;
|
|
|
|
|
$i++;
|
|
|
|
|
for (; $i < $len; $i++) {
|
|
|
|
|
$c = $s[$i];
|
|
|
|
|
$buf .= $c;
|
|
|
|
|
if ($c === '\\' && $i + 1 < $len) {
|
|
|
|
|
$buf .= $s[$i + 1];
|
|
|
|
|
$i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($c === $quote) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
$tokens[] = ['type' => 'string', 'value' => $buf];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ctype_digit($ch)) {
|
|
|
|
|
$buf = $ch;
|
|
|
|
|
while ($i + 1 < $len && (ctype_digit($s[$i + 1]) || $s[$i + 1] === '.')) {
|
|
|
|
|
$buf .= $s[$i + 1];
|
|
|
|
|
$i++;
|
|
|
|
|
}
|
|
|
|
|
$tokens[] = ['type' => 'number', 'value' => $buf];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ctype_alpha($ch) || $ch === '_') {
|
|
|
|
|
$buf = $ch;
|
|
|
|
|
while ($i + 1 < $len && (ctype_alnum($s[$i + 1]) || $s[$i + 1] === '_')) {
|
|
|
|
|
$buf .= $s[$i + 1];
|
|
|
|
|
$i++;
|
|
|
|
|
}
|
|
|
|
|
$tokens[] = ['type' => 'ident', 'value' => $buf];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tokens[] = ['type' => 'other', 'value' => $ch];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $tokens;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse a single quoted string argument from tokens starting at $start.
|
|
|
|
|
* Expects: '(' string ')'. Returns ['value' => string, 'endIndex' => int] or null.
|
|
|
|
|
*/
|
|
|
|
|
private function parseSingleStringArg(array $tokens, int $start): ?array
|
|
|
|
|
{
|
|
|
|
|
$t0 = $tokens[$start] ?? null;
|
|
|
|
|
$t1 = $tokens[$start + 1] ?? null;
|
|
|
|
|
$t2 = $tokens[$start + 2] ?? null;
|
|
|
|
|
if (($t0['value'] ?? null) !== '(') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
if (($t1['type'] ?? null) !== 'string') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
if (($t2['value'] ?? null) !== ')') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$raw = $t1['value'];
|
|
|
|
|
$quote = $raw[0] ?? '';
|
|
|
|
|
if ($quote !== '"' && $quote !== "'") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
$val = substr($raw, 1, -1);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'value' => $val,
|
|
|
|
|
'endIndex' => $start + 2,
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-03-16 16:39:54 +07:00
|
|
|
|
|
|
|
|
private function unquoteStringArgument(string $value): ?string
|
|
|
|
|
{
|
|
|
|
|
$value = trim($value);
|
|
|
|
|
if ($value === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$quote = $value[0];
|
|
|
|
|
if ($quote !== '"' && $quote !== "'") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (substr($value, -1) !== $quote) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$content = substr($value, 1, -1);
|
|
|
|
|
return stripcslashes($content);
|
|
|
|
|
}
|
2026-03-12 06:34:56 +07:00
|
|
|
}
|