*/ private $priorityList = [ ['='], ['??'], ['||'], ['&&'], ['==', '!=', '>', '<', '>=', '<='], ['+', '-'], ['*', '/', '%'], ]; /** @var array */ private $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] == '$') { $variable = substr($firstPart, 1); if ($variable === '' || !preg_match($this->variableNameRegExp, $variable)) { throw new SyntaxError("Bad variable name `{$variable}`."); } return new Node('assign', [ new Value($variable), $this->split($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), ]); } /** * @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; $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] === "'" && ($i === 0 || $string[$i - 1] !== "\\")) { if (!$isString) { $isString = true; $isSingleQuote = true; $isStringStart = true; } else if ($isSingleQuote) { $isString = false; } } else if ($string[$i] === "\"" && ($i === 0 || $string[$i - 1] !== "\\")) { 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--; } } if ($statementList !== null) { $this->processStringIteration( $string, $i, $statementList, $parenthesisCounter, $braceCounter, $isLineComment, $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, 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 ) { if ($char === ';') { $lastStatement->setEnd($i, true); return; } if ($isLast) { $lastStatement->setEnd($i + 1); return; } } } if ( $lastStatement instanceof IfRef && !$lastStatement->isReady() ) { $toContinue = $this->processStringIfStatement( $string, $i, $parenthesisCounter, $braceCounter, $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 ) { 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; $hasExcessParenthesis = true; $modifiedExpression = ''; $expressionOutOfParenthesisList = []; $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--; } if ($parenthesisCounter === 0 && $i < $expressionLength - 1) { $hasExcessParenthesis = false; } $expressionOutOfParenthesisList[] = $parenthesisCounter === 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 ( strlen($expression) > 1 && $expression[0] === '(' && $expression[strlen($expression) - 1] === ')' && $hasExcessParenthesis ) { $expression = substr($expression, 1, strlen($expression) - 2); return $this->split($expression, true); } if (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) { $startFrom = 1; while (true) { $index = strpos($expression, $operator, $startFrom); if ($index === false) { break; } if ($expressionOutOfParenthesisList[$index]) { break; } $startFrom = $index + 1; } if ($index !== false) { $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]) ) { continue; } $possibleLeftOperator = null; if (strlen($operator) === 1) { if ($index > 0) { $possibleLeftOperator = trim($expression[$index - 1] . $operator); } } if ( $possibleLeftOperator && $possibleLeftOperator != $operator && !empty($this->operatorMap[$possibleLeftOperator]) ) { continue; } $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) { $minIndex = $index; $firstOperator = $operator; } else if ($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] === "\"" ) { $subExpression = substr($expression, 1, strlen($expression) - 2); return new Value($subExpression); } if ($expression[0] === "$") { $value = substr($expression, 1); if ($value === '' || !preg_match($this->variableNameRegExp, $value)) { throw new SyntaxError("Bad variable name `{$value}`."); } return new Variable($value); } 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); } /** * @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] === "'" && ($i === 0 || $functionContent[$i - 1] !== "\\")) { if (!$isString) { $isString = true; $isSingleQuote = true; } else { if ($isSingleQuote) { $isString = false; } } } else if ($functionContent[$i] === "\"" && ($i === 0 || $functionContent[$i - 1] !== "\\")) { 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; } }