Files
espocrm/application/Espo/Core/Formula/Parser.php
bsiggel 127fa6503b chore: Update copyright year from 2025 to 2026 across core files
- Updated copyright headers in 3,055 core application files
- Changed 'Copyright (C) 2014-2025' to 'Copyright (C) 2014-2026'
- Added 123 new files from EspoCRM core updates
- Removed 4 deprecated files
- Total changes: 61,637 insertions, 54,283 deletions

This is a routine maintenance update for the new year 2026.
2026-02-07 16:05:21 +01:00

1536 lines
46 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Formula;
use Espo\Core\Formula\Exceptions\SyntaxError;
use Espo\Core\Formula\Parser\Ast\Attribute;
use Espo\Core\Formula\Parser\Ast\Node;
use Espo\Core\Formula\Parser\Ast\Value;
use Espo\Core\Formula\Parser\Ast\Variable;
use Espo\Core\Formula\Parser\Statement\IfRef;
use Espo\Core\Formula\Parser\Statement\StatementRef;
use Espo\Core\Formula\Parser\Statement\WhileRef;
use LogicException;
/**
* Parses a formula-script into AST.
*/
class Parser
{
/** @var array<int, string[]> */
private array $priorityList = [
['='],
['??'],
['||'],
['&&'],
['==', '!=', '>', '<', '>=', '<='],
['+', '-'],
['*', '/', '%'],
];
/** @var array<string, string> */
private array $operatorMap = [
'=' => 'assign',
'??' => 'comparison\\nullCoalescing',
'||' => 'logical\\or',
'&&' => 'logical\\and',
'+' => 'numeric\\summation',
'-' => 'numeric\\subtraction',
'*' => 'numeric\\multiplication',
'/' => 'numeric\\division',
'%' => 'numeric\\modulo',
'==' => 'comparison\\equals',
'!=' => 'comparison\\notEquals',
'>' => 'comparison\\greaterThan',
'<' => 'comparison\\lessThan',
'>=' => 'comparison\\greaterThanOrEquals',
'<=' => 'comparison\\lessThanOrEquals',
];
/** @var string[] */
private array $whiteSpaceCharList = [
"\r",
"\n",
"\t",
' ',
];
private string $variableNameRegExp = "/^[a-zA-Z0-9_\$]+$/";
private string $functionNameRegExp = "/^[a-zA-Z0-9_\\\\]+$/";
private string $attributeNameRegExp = "/^[a-zA-Z0-9.]+$/";
/**
* @throws SyntaxError
*/
public function parse(string $expression): Node|Attribute|Variable|Value
{
return $this->split($expression, true);
}
/**
* @throws SyntaxError
*/
private function applyOperator(string $operator, string $firstPart, string $secondPart): Node
{
if ($operator === '=') {
if (!strlen($firstPart)) {
throw new SyntaxError("Bad operator usage.");
}
if ($firstPart[0] == '$') {
return $this->applyOperatorVariableAssign($firstPart, $secondPart);
}
if ($secondPart === '') {
throw SyntaxError::create("Bad assignment usage.");
}
return new Node('setAttribute', [
new Value($firstPart),
$this->split($secondPart)
]);
}
$functionName = $this->operatorMap[$operator];
if ($functionName === '' || !preg_match($this->functionNameRegExp, $functionName)) {
throw new SyntaxError("Bad function name `$functionName`.");
}
return new Node($functionName, [
$this->split($firstPart),
$this->split($secondPart),
]);
}
private static function isNotAfterBackslash(string $string, int $i): bool
{
return
($string[$i - 1] ?? null) !== "\\" ||
(($string[$i - 2] ?? null) === "\\" && ($string[$i - 3] ?? null) !== "\\");
}
/**
* @param string $string An expression. Comments will be stripped by the method.
* @param string $modifiedString A modified expression with removed parentheses and braces inside strings.
* @param ?((StatementRef|IfRef|WhileRef)[]) $statementList Statements will be added if there are multiple.
* @throws SyntaxError
*/
private function processString(
string &$string,
string &$modifiedString,
?array &$statementList = null,
bool $intoOneLine = false
): bool {
$isString = false;
$isSingleQuote = false;
$isComment = false;
$isLineComment = false;
$parenthesisCounter = 0;
$braceCounter = 0;
$bracketCounter = 0;
$modifiedString = $string;
for ($i = 0; $i < strlen($string); $i++) {
$isStringStart = false;
$char = $string[$i];
$isLast = $i === strlen($string) - 1;
if (!$isLineComment && !$isComment) {
if ($string[$i] === "'" && self::isNotAfterBackslash($string, $i)) {
if (!$isString) {
$isString = true;
$isStringStart = true;
$isSingleQuote = true;
} else if ($isSingleQuote) {
$isString = false;
}
} else if ($string[$i] === "\"" && self::isNotAfterBackslash($string, $i)) {
if (!$isString) {
$isString = true;
$isStringStart = true;
$isSingleQuote = false;
} else if (!$isSingleQuote) {
$isString = false;
}
}
}
if ($isString) {
if (in_array($char, ['(', ')', '{', '}', '[', ']'])) {
$modifiedString[$i] = '_';
} else if (!$isStringStart) {
$modifiedString[$i] = ' ';
}
continue;
}
$isLineCommentEnding = $isLineComment && ($string[$i] === "\n" || $isLast);
$isCommentEnding = $isComment && $string[$i] === "*" && $string[$i + 1] === "/";
if ($isCommentEnding) {
$string[$i + 1] = ' ';
$modifiedString[$i + 1] = ' ';
}
if ($isLineComment || $isComment) {
$string[$i] = ' ';
$modifiedString[$i] = ' ';
}
if (!$isLineComment && !$isComment) {
if (!$isLast && $string[$i] === '/' && $string[$i + 1] === '/') {
$isLineComment = true;
$string[$i] = ' ';
$string[$i + 1] = ' ';
$modifiedString[$i] = ' ';
$modifiedString[$i + 1] = ' ';
}
if (!$isLineComment) {
if (!$isLast && $string[$i] === '/' && $string[$i + 1] === '*') {
$isComment = true;
$string[$i] = ' ';
$string[$i + 1] = ' ';
$modifiedString[$i] = ' ';
$modifiedString[$i + 1] = ' ';
}
}
if ($char === '(') {
$parenthesisCounter++;
} else if ($char === ')') {
$parenthesisCounter--;
} else if ($char === '{') {
$braceCounter++;
} else if ($char === '}') {
$braceCounter--;
} else if ($char === '[') {
$bracketCounter++;
} else if ($char === ']') {
$bracketCounter--;
}
}
if ($statementList !== null) {
$this->processStringIteration(
string: $string,
i: $i,
statementList: $statementList,
parenthesisCounter: $parenthesisCounter,
braceCounter: $braceCounter,
bracketCounter: $bracketCounter,
isLineComment: $isLineComment,
isComment: $isComment,
);
}
if ($intoOneLine) {
if (
$parenthesisCounter === 0 &&
$this->isWhiteSpace($char) &&
$char !== ' '
) {
$string[$i] = ' ';
}
}
if ($isLineCommentEnding) {
$isLineComment = false;
}
if ($isCommentEnding) {
$isComment = false;
}
/*if ($isLineComment) {
if ($string[$i] === "\n") {
$isLineComment = false;
}
}
if ($isComment) {
if ($string[$i - 1] === "*" && $string[$i] === "/") {
$isComment = false;
}
}*/
}
if ($statementList !== null) {
$lastStatement = end($statementList);
if (
$lastStatement instanceof StatementRef &&
count($statementList) === 1 &&
!$lastStatement->isEndedWithSemicolon()
) {
array_pop($statementList);
} else if (
$lastStatement instanceof StatementRef &&
!$lastStatement->isEndedWithSemicolon()
) {
$lastStatement->setEnd(strlen($string));
}
}
return $isString;
}
/**
* @param (StatementRef|IfRef|WhileRef)[] $statementList
* @throws SyntaxError
*/
private function processStringIteration(
string $string,
int &$i,
array &$statementList,
int $parenthesisCounter,
int $braceCounter,
int $bracketCounter,
bool $isLineComment,
bool $isComment,
): void {
$char = $string[$i];
$isLast = $i === strlen($string) - 1;
$lastStatement = count($statementList) ?
end($statementList) : null;
if (
$lastStatement instanceof StatementRef &&
!$lastStatement->isReady()
) {
if (
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$bracketCounter === 0
) {
if ($char === ';') {
$lastStatement->setEnd($i, true);
return;
}
if ($isLast) {
$lastStatement->setEnd($i + 1);
return;
}
}
}
if (
$lastStatement instanceof IfRef &&
!$lastStatement->isReady()
) {
$toContinue = $this->processStringIfStatement(
string: $string,
i: $i,
parenthesisCounter: $parenthesisCounter,
braceCounter: $braceCounter,
statement: $lastStatement,
);
if ($toContinue) {
return;
}
}
if (
$lastStatement instanceof WhileRef &&
!$lastStatement->isReady()
) {
$toContinue = $this->processStringWhileStatement(
$string,
$i,
$parenthesisCounter,
$braceCounter,
$lastStatement
);
if ($toContinue === null) {
// Not a `while` statement, but likely a `while` function.
array_pop($statementList);
$lastStatement = new StatementRef($lastStatement->getStart());
$statementList[] = $lastStatement;
if ($char === ';') {
$lastStatement->setEnd($i, true);
return;
}
}
if ($toContinue) {
return;
}
}
if (
(
$parenthesisCounter === 0 ||
$parenthesisCounter === 1 && $char === '('
) &&
$braceCounter === 0 &&
$bracketCounter === 0
) {
if ($isLineComment || $isComment) {
return;
}
$previousStatementEnd = $lastStatement ?
$lastStatement->getEnd() :
-1;
if (
$lastStatement &&
!$lastStatement->isReady()
) {
return;
}
if ($previousStatementEnd === null) {
throw SyntaxError::create("Incorrect statement usage.");
}
if ($this->isOnIf($string, $i)) {
$statementList[] = new IfRef();
$i += 1;
return;
}
if ($this->isOnWhile($string, $i)) {
$statementList[] = new WhileRef($i);
$i += 4;
return;
}
if (
!$this->isWhiteSpace($char) &&
$char !== ';' &&
$char !== '/'
) {
$statementList[] = new StatementRef($i);
}
}
}
private function processStringIfStatement(
string $string,
int &$i,
int $parenthesisCounter,
int $braceCounter,
IfRef $statement
): bool {
$char = $string[$i];
$isLast = $i === strlen($string) - 1;
if (
$char === '(' &&
!$isLast &&
$parenthesisCounter === 1 &&
$braceCounter === 0 &&
$statement->getState() === IfRef::STATE_EMPTY
) {
$statement->setConditionStart($i + 1);
return true;
}
if (
$char === ')' &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$statement->getState() === IfRef::STATE_CONDITION_STARTED
) {
$statement->setConditionEnd($i);
return true;
}
if (
$statement->getState() === IfRef::STATE_CONDITION_ENDED &&
!$isLast &&
$parenthesisCounter === 0 &&
$braceCounter === 1 &&
$char === '{'
) {
$statement->setThenStart($i + 1);
return true;
}
if (
$statement->getState() === IfRef::STATE_THEN_STARTED &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$char === '}'
) {
$statement->setThenEnd($i);
if ($isLast) {
$statement->setReady();
}
return true;
}
if (
$statement->getState() === IfRef::STATE_THEN_ENDED &&
$this->isWhiteSpace($char) &&
$isLast
) {
$statement->setReady();
// No need to call continue.
return false;
}
if (
$statement->getState() === IfRef::STATE_THEN_ENDED &&
!$this->isWhiteSpace($char) &&
!$this->isOnElse($string, $i)
) {
$statement->setReady();
// No need to call continue.
return false;
}
if (
$statement->getState() === IfRef::STATE_THEN_ENDED &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$this->isOnElse($string, $i)
) {
$statement->setElseMet($i + 4);
$i += 3;
return true;
}
if (
$statement->getState() === IfRef::STATE_ELSE_MET &&
!$isLast &&
$parenthesisCounter === 0 &&
$braceCounter === 1 &&
$char === '{'
) {
$statement->setElseStart($i + 1);
return true;
}
if (
$statement->getState() === IfRef::STATE_ELSE_MET &&
!$isLast &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$this->isWhiteSpace($string[$i - 1]) &&
$this->isOnIf($string, $i)
) {
$statement->setElseStart($i, true);
$i += 1;
return true;
}
if (
$statement->getState() === IfRef::STATE_ELSE_STARTED &&
$statement->hasInlineElse() &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$char === '}'
) {
$elseFound = false;
$j = $i + 1;
while ($j < strlen($string)) {
if ($this->isWhiteSpace($string[$j])) {
$j++;
continue;
}
$elseFound = $this->isOnElse($string, $j);
break;
}
if (!$elseFound) {
$statement->setElseEnd($i + 1);
$statement->setReady();
}
return true;
}
if (
$statement->getState() === IfRef::STATE_ELSE_STARTED &&
!$statement->hasInlineElse() &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$char === '}'
) {
$statement->setElseEnd($i);
$statement->setReady();
return true;
}
if (
$statement->getState() === IfRef::STATE_ELSE_MET &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$char === '}'
) {
$statement->setElseStart($statement->getElseKeywordEnd() + 1);
$statement->setElseEnd($i + 1);
$statement->setReady();
return true;
}
return false;
}
private function processStringWhileStatement(
string $string,
int $i,
int $parenthesisCounter,
int $braceCounter,
WhileRef $statement
): ?bool {
$char = $string[$i];
$isLast = $i === strlen($string) - 1;
if (
$char === '(' &&
!$isLast &&
$parenthesisCounter === 1 &&
$braceCounter === 0 &&
$statement->getState() === WhileRef::STATE_EMPTY
) {
$statement->setConditionStart($i + 1);
return true;
}
if (
$char === ')' &&
!$isLast &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$statement->getState() === WhileRef::STATE_CONDITION_STARTED
) {
$statement->setConditionEnd($i);
return true;
}
if (
$statement->getState() === WhileRef::STATE_CONDITION_ENDED &&
!$isLast &&
$parenthesisCounter === 0 &&
$braceCounter === 1 &&
$char === '{'
) {
$statement->setBodyStart($i + 1);
return true;
}
if (
$statement->getState() === WhileRef::STATE_CONDITION_STARTED &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$char === ')' &&
$isLast
) {
return null;
}
if (
$statement->getState() === WhileRef::STATE_CONDITION_ENDED &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
(
$isLast ||
!$this->isWhiteSpace($char)
)
) {
return null;
}
if (
$statement->getState() === WhileRef::STATE_BODY_STARTED &&
$parenthesisCounter === 0 &&
$braceCounter === 0 &&
$char === '}'
) {
$statement->setBodyEnd($i);
return true;
}
return false;
}
private function isOnIf(string $string, int $i): bool
{
$before = substr($string, $i - 1, 1);
$after = substr($string, $i + 2, 1);
return
substr($string, $i, 2) === 'if' &&
(
$i === 0 ||
$this->isWhiteSpace($before) ||
$before === ';'
) &&
(
$this->isWhiteSpace($after) ||
$after === '('
);
}
private function isOnElse(string $string, int $i): bool
{
return substr($string, $i, 4) === 'else' &&
$this->isWhiteSpaceCharOrBraceOpen(substr($string, $i + 4, 1)) &&
$this->isWhiteSpaceCharOrBraceClose(substr($string, $i - 1, 1));
}
private function isOnWhile(string $string, int $i): bool
{
$before = substr($string, $i - 1, 1);
$after = substr($string, $i + 5, 1);
return
substr($string, $i, 5) === 'while' &&
(
$i === 0 ||
$this->isWhiteSpace($before) ||
$before === ';'
) &&
(
$this->isWhiteSpace($after) ||
$after === '('
);
}
private function isWhiteSpaceCharOrBraceOpen(string $char): bool
{
return $char === '{' || in_array($char, $this->whiteSpaceCharList);
}
private function isWhiteSpaceCharOrBraceClose(string $char): bool
{
return $char === '}' || in_array($char, $this->whiteSpaceCharList);
}
private function isWhiteSpace(string $char): bool
{
return in_array($char, $this->whiteSpaceCharList);
}
/**
* @throws SyntaxError
*/
private function split(string $expression, bool $isRoot = false): Node|Attribute|Variable|Value
{
$expression = trim($expression);
$parenthesisCounter = 0;
$braceCounter = 0;
$bracketCounter = 0;
$hasExcessParenthesis = true;
$modifiedExpression = '';
$topLevelExpressionList = [];
$statementList = [];
$isStringNotClosed = $this->processString($expression, $modifiedExpression, $statementList, true);
if ($isStringNotClosed) {
throw SyntaxError::create('String is not closed.');
}
$expressionLength = strlen($modifiedExpression);
for ($i = 0; $i < $expressionLength; $i++) {
$value = $modifiedExpression[$i];
if ($value === '(') {
$parenthesisCounter++;
} else if ($value === ')') {
$parenthesisCounter--;
} else if ($value === '{') {
$braceCounter++;
} else if ($value === '}') {
$braceCounter--;
} else if ($value === '[') {
$bracketCounter++;
} else if ($value === ']') {
$bracketCounter--;
}
if ($parenthesisCounter === 0 && $i < $expressionLength - 1) {
$hasExcessParenthesis = false;
}
$topLevelExpressionList[] = $parenthesisCounter === 0 && $bracketCounter === 0;
}
if ($parenthesisCounter !== 0) {
throw SyntaxError::create(
'Incorrect parentheses usage in expression ' . $expression . '.',
'Incorrect parentheses.'
);
}
if ($braceCounter !== 0) {
throw SyntaxError::create(
'Incorrect braces usage in expression ' . $expression . '.',
'Incorrect braces.'
);
}
if ($bracketCounter !== 0) {
throw SyntaxError::create(
'Incorrect bracket usage in expression ' . $expression . '.',
'Incorrect brackets.'
);
}
if (
strlen($expression) > 1 &&
$expression[0] === '(' &&
$expression[strlen($expression) - 1] === ')' &&
$hasExcessParenthesis
) {
$expression = substr($expression, 1, strlen($expression) - 2);
return $this->split($expression, true);
}
if ($statementList !== null && count($statementList)) {
return $this->processStatementList($expression, $statementList, $isRoot);
}
$firstOperator = null;
$minIndex = null;
if (trim($expression) === '') {
return new Value(null);
}
foreach ($this->priorityList as $operationList) {
foreach ($operationList as $operator) {
$offset = -1;
while (true) {
$index = strrpos($modifiedExpression, $operator, $offset);
if ($index === false) {
break;
}
if (
$topLevelExpressionList[$index] &&
!$this->isAtAnotherOperator($index, $operator, $modifiedExpression)
) {
break;
}
$offset = -(strlen($expression) - $index) - 1;
}
if ($index === false) {
continue;
}
if ($operator === '+' || $operator === '-') {
$j = $index - 1;
while ($j >= 0) {
$char = $expression[$j];
if ($this->isWhiteSpace($char)) {
$j--;
continue;
}
if (array_key_exists($char, $this->operatorMap)) {
continue 2;
}
break;
}
}
$firstPart = substr($expression, 0, $index);
$secondPart = substr($expression, $index + strlen($operator));
$modifiedFirstPart = $modifiedSecondPart = '';
$isString = $this->processString($firstPart, $modifiedFirstPart);
$this->processString($secondPart, $modifiedSecondPart);
if (
substr_count($modifiedFirstPart, '(') === substr_count($modifiedFirstPart, ')') &&
substr_count($modifiedSecondPart, '(') === substr_count($modifiedSecondPart, ')') &&
!$isString
) {
if ($minIndex === null || $index > $minIndex) {
$minIndex = $index;
$firstOperator = $operator;
}
}
}
if ($firstOperator) {
break;
}
}
if ($firstOperator) {
/** @var int $minIndex */
$firstPart = substr($expression, 0, $minIndex);
$secondPart = substr($expression, $minIndex + strlen($firstOperator));
$firstPart = trim($firstPart);
$secondPart = trim($secondPart);
return $this->applyOperator($firstOperator, $firstPart, $secondPart);
}
$expression = trim($expression);
if ($expression[0] === '!') {
return new Node('logical\\not', [
$this->split(substr($expression, 1))
]);
}
if ($expression[0] === '-') {
return new Node('numeric\\subtraction', [
new Value(0),
$this->split(substr($expression, 1))
]);
}
if ($expression[0] === '+') {
return new Node('numeric\\summation', [
new Value(0),
$this->split(substr($expression, 1))
]);
}
if (
$expression[0] === "'" && $expression[strlen($expression) - 1] === "'" ||
$expression[0] === "\"" && $expression[strlen($expression) - 1] === "\""
) {
return new Value(self::prepareStringValue($expression));
}
if ($expression[0] === "$") {
return $this->splitVariable($expression);
}
if (is_numeric($expression)) {
$value = filter_var($expression, FILTER_VALIDATE_INT) !== false ?
(int) $expression :
(float) $expression;
return new Value($value);
}
if ($expression === 'true') {
return new Value(true);
}
if ($expression === 'false') {
return new Value(false);
}
if ($expression === 'null') {
return new Value(null);
}
if ($expression === 'break') {
return new Node('break', []);
}
if ($expression === 'continue') {
return new Node('continue', []);
}
if ($expression[strlen($expression) - 1] === ')') {
$firstOpeningBraceIndex = strpos($expression, '(');
if ($firstOpeningBraceIndex > 0) {
$functionName = trim(substr($expression, 0, $firstOpeningBraceIndex));
$functionContent = substr($expression, $firstOpeningBraceIndex + 1, -1);
$argumentList = $this->parseArgumentListFromFunctionContent($functionContent);
$argumentSplitList = [];
foreach ($argumentList as $argument) {
$argumentSplitList[] = $this->split($argument);
}
if ($functionName === '' || !preg_match($this->functionNameRegExp, $functionName)) {
throw new SyntaxError("Bad function name `$functionName`.");
}
return new Node($functionName, $argumentSplitList);
}
}
if (str_contains($expression, ' ')) {
throw SyntaxError::create("Could not parse.");
}
if (!preg_match($this->attributeNameRegExp, $expression)) {
throw SyntaxError::create("Attribute name `$expression` contains not allowed characters.");
}
if (str_ends_with($expression, '.')) {
throw SyntaxError::create("Attribute ends with dot.");
}
return new Attribute($expression);
}
private function isAtAnotherOperator(int $index, string $operator, string $expression): bool
{
$possibleRightOperator = null;
if (strlen($operator) === 1) {
if ($index < strlen($expression) - 1) {
$possibleRightOperator = trim($operator . $expression[$index + 1]);
}
}
if (
$possibleRightOperator &&
$possibleRightOperator != $operator &&
!empty($this->operatorMap[$possibleRightOperator])
) {
return true;
}
$possibleLeftOperator = null;
if (strlen($operator) === 1) {
if ($index > 0) {
$possibleLeftOperator = trim($expression[$index - 1] . $operator);
}
}
if (
$possibleLeftOperator &&
$possibleLeftOperator != $operator &&
!empty($this->operatorMap[$possibleLeftOperator])
) {
return true;
}
return false;
}
/**
* @param (StatementRef|IfRef|WhileRef)[] $statementList
* @throws SyntaxError
*/
private function processStatementList(
string $expression,
array $statementList,
bool $isRoot
): Node|Value|Attribute|Variable {
$parsedPartList = [];
foreach ($statementList as $statement) {
$parsedPart = null;
if ($statement instanceof StatementRef) {
$start = $statement->getStart();
$end = $statement->getEnd();
if ($end === null) {
throw new LogicException();
}
$part = self::sliceByStartEnd($expression, $start, $end);
$parsedPart = $this->split($part);
} else if ($statement instanceof IfRef) {
if (!$isRoot || !$statement->isReady()) {
throw SyntaxError::create(
'Incorrect if statement usage in expression ' . $expression . '.',
'Incorrect if statement.'
);
}
$conditionStart = $statement->getConditionStart();
$conditionEnd = $statement->getConditionEnd();
$thenStart = $statement->getThenStart();
$thenEnd = $statement->getThenEnd();
$elseStart = $statement->getElseStart();
$elseEnd = $statement->getElseEnd();
if (
$conditionStart === null ||
$conditionEnd === null ||
$thenStart === null ||
$thenEnd === null
) {
throw new LogicException();
}
$conditionPart = self::sliceByStartEnd($expression, $conditionStart, $conditionEnd);
$thenPart = self::sliceByStartEnd($expression, $thenStart, $thenEnd);
$elsePart = $elseStart !== null && $elseEnd !== null ?
self::sliceByStartEnd($expression, $elseStart, $elseEnd) : null;
$parsedPart = $statement->getElseKeywordEnd() ?
new Node('ifThenElse', [
$this->split($conditionPart),
$this->split($thenPart, true),
$this->split($elsePart ?? '', true)
]) :
new Node('ifThen', [
$this->split($conditionPart),
$this->split($thenPart, true)
]);
} else if ($statement instanceof WhileRef) {
if (!$isRoot || !$statement->isReady()) {
throw SyntaxError::create(
'Incorrect while statement usage in expression ' . $expression . '.',
'Incorrect while statement.'
);
}
$conditionStart = $statement->getConditionStart();
$conditionEnd = $statement->getConditionEnd();
$bodyStart = $statement->getBodyStart();
$bodyEnd = $statement->getBodyEnd();
if (
$conditionStart === null ||
$conditionEnd === null ||
$bodyStart === null ||
$bodyEnd === null
) {
throw new LogicException();
}
$conditionPart = self::sliceByStartEnd($expression, $conditionStart, $conditionEnd);
$bodyPart = self::sliceByStartEnd($expression, $bodyStart, $bodyEnd);
$parsedPart = new Node('while', [
$this->split($conditionPart),
$this->split($bodyPart, true)
]);
}
if (!$parsedPart) {
throw SyntaxError::create(
'Unknown syntax error in expression ' . $expression . '.',
'Unknown syntax error.'
);
}
$parsedPartList[] = $parsedPart;
}
if (count($parsedPartList) === 1) {
return $parsedPartList[0];
}
return new Node('bundle', $parsedPartList);
}
private static function sliceByStartEnd(string $expression, int $start, int $end): string
{
return trim(
substr(
$expression,
$start,
$end - $start
)
);
}
/**
* @return string[]
*/
private function parseArgumentListFromFunctionContent(string $functionContent): array
{
$functionContent = trim($functionContent);
$isString = false;
$isSingleQuote = false;
if ($functionContent === '') {
return [];
}
$commaIndexList = [];
$braceCounter = 0;
for ($i = 0; $i < strlen($functionContent); $i++) {
if ($functionContent[$i] === "'" && self::isNotAfterBackslash($functionContent, $i)) {
if (!$isString) {
$isString = true;
$isSingleQuote = true;
} else {
if ($isSingleQuote) {
$isString = false;
}
}
} else if ($functionContent[$i] === "\"" && self::isNotAfterBackslash($functionContent, $i)) {
if (!$isString) {
$isString = true;
$isSingleQuote = false;
} else {
if (!$isSingleQuote) {
$isString = false;
}
}
}
if (!$isString) {
if ($functionContent[$i] === '(') {
$braceCounter++;
} else if ($functionContent[$i] === ')') {
$braceCounter--;
}
}
if ($braceCounter === 0 && !$isString && $functionContent[$i] === ',') {
$commaIndexList[] = $i;
}
}
$commaIndexList[] = strlen($functionContent);
$argumentList = [];
for ($i = 0; $i < count($commaIndexList); $i++) {
if ($i > 0) {
$previousCommaIndex = $commaIndexList[$i - 1] + 1;
} else {
$previousCommaIndex = 0;
}
$argument = trim(
substr(
$functionContent,
$previousCommaIndex,
$commaIndexList[$i] - $previousCommaIndex
)
);
$argumentList[] = $argument;
}
return $argumentList;
}
static private function prepareStringValue(string $expression): string
{
$string = substr($expression, 1, strlen($expression) - 2);
$isDoubleQuote = $expression[0] === '"';
/** @var array{bool, string}[] $tokens */
$tokens = [];
$stripList = ["\\\\", "\\\"", "\\n", "\\t", "\\r"];
$replaceList = ["\\", "\"", "\n", "\t", "\r"];
if ($isDoubleQuote) {
$stripList[] = "\\\"";
$replaceList[] = "\"";
} else {
$stripList[] = "\\'";
$replaceList[] = "'";
}
$k = 0;
for ($i = 0; $i < strlen($string); $i++) {
$part = substr($string, $i, 2);
if (in_array($part, $stripList)) {
$len = strlen($part);
$before = substr($string, $k, $i - $k);
if (strlen($before)) {
$tokens[] = [false, $before];
}
$tokens[] = [true, $part];
$i += $len - 1;
$k = $i + 1;
}
if ($i >= strlen($string) - 1) {
$after = substr($string, $k);
if (strlen($after)) {
$tokens[] = [false, $after];
}
}
}
$result = '';
foreach ($tokens as $token) {
if (!$token[0]) {
$result .= $token[1];
continue;
}
$result .= str_replace($stripList, $replaceList, $token[1]);
}
return $result;
}
/**
* @throws SyntaxError
*/
private function applyOperatorVariableAssign(string $firstPart, string $secondPart): Node
{
$variable = substr($firstPart, 1);
$isArrayAppend = false;
$isKeyValue = false;
$keyPath = [];
if (str_ends_with($firstPart, '[]')) {
$variable = substr($firstPart, 1, -2);
$isArrayAppend = true;
} else if (str_ends_with($firstPart, ']') && str_contains($firstPart, '[')) {
$bracketPosition = strpos($firstPart, '[') ?: 0;
$variable = substr($firstPart, 1, $bracketPosition - 1);
$keyPart = trim(substr($firstPart, $bracketPosition));
$keyPath = array_map(fn ($it) => $this->split($it), $this->splitKeys($keyPart));
$isKeyValue = true;
}
if ($variable === '' || !preg_match($this->variableNameRegExp, $variable)) {
throw new SyntaxError("Bad variable name `$variable`.");
}
if ($isArrayAppend) {
return new Node('arrayAppend', [
new Value($variable),
$this->split($secondPart)
]);
}
if ($isKeyValue) {
return new Node('variableSetKeyValue', [
new Value($variable),
new Node('list', $keyPath),
$this->split($secondPart)
]);
}
return new Node('assign', [
new Value($variable),
$this->split($secondPart)
]);
}
/**
* @throws SyntaxError
*/
private function splitVariable(string $expression): Node|Variable
{
$value = substr($expression, 1);
$isIncrement = false;
$isDecrement = false;
$isKeyValue = false;
$keyPath = [];
if (str_ends_with($expression, '++')) {
$isIncrement = true;
$value = rtrim(substr($value, 0, -2));
}
if (str_ends_with($expression, '--')) {
$isDecrement = true;
$value = rtrim(substr($value, 0, -2));
} else if (str_ends_with($expression, ']') && str_contains($expression, '[')) {
$bracketPosition = strpos($expression, '[') ?: 0;
$value = substr($expression, 1, $bracketPosition - 1);
$keyPart = trim(substr($expression, $bracketPosition));
$keyPath = array_map(fn ($it) => $this->split($it), $this->splitKeys($keyPart));
$isKeyValue = true;
}
if ($value === '' || !preg_match($this->variableNameRegExp, $value)) {
throw new SyntaxError("Bad variable name `$value`.");
}
if ($isIncrement) {
return new Node('variableIncrement', [
new Value($value),
]);
}
if ($isDecrement) {
return new Node('variableDecrement', [
new Value($value),
]);
}
if ($isKeyValue) {
return new Node('variableGetValueByKey', [
new Value($value),
new Node('list', $keyPath),
]);
}
return new Variable($value);
}
/**
* @return string[]
* @throws SyntaxError
*/
private function splitKeys(string $expression): array
{
$modifiedExpression = '';
$this->processString($expression, $modifiedExpression, $statementList, true);
$expressionLength = strlen($modifiedExpression);
$parenthesisCounter = 0;
$bracketCounter = 0;
$output = [];
/** @var array{int, int}[] $indexPairs */
$indexPairs = [];
$startIndex = -1;
for ($i = 0; $i < $expressionLength; $i++) {
$value = $modifiedExpression[$i];
if ($value === '(') {
$parenthesisCounter++;
} else if ($value === ')') {
$parenthesisCounter--;
} else if ($value === '[') {
$bracketCounter++;
} else if ($value === ']') {
$bracketCounter--;
}
if (
$value === '[' &&
$parenthesisCounter === 0 &&
$bracketCounter === 1
) {
$startIndex = $i;
}
if (
$value === ']' &&
$parenthesisCounter === 0 &&
$bracketCounter === 0
) {
$indexPairs[] = [$startIndex + 1, $i];
$startIndex = -1;
}
}
foreach ($indexPairs as $i => $pair) {
if ($i > 0) {
if ($indexPairs[$i - 1][1] !== $pair[0] - 2) {
throw new SyntaxError("Nested brackets must have no gaps in between.");
}
}
$itemExpression = trim(substr($expression, $pair[0], $pair[1] - $pair[0]));
if ($itemExpression === '') {
throw new SyntaxError("No expression inside brackets.");
}
$output[] = $itemExpression;
}
return $output;
}
}