aboutsummaryrefslogtreecommitdiff
path: root/lib/exTpl
diff options
context:
space:
mode:
authorPhilipp Schüttlöffel <schuettloeffel@zqs.uni-hannover.de>2024-09-24 10:53:31 +0200
committerPhilipp Schüttlöffel <schuettloeffel@zqs.uni-hannover.de>2024-09-24 10:53:31 +0200
commit4459dd7917f4d1c34f40bb68f0e991e9c3d53e4c (patch)
tree5c07151ae61276d334e88f6309c30d439a85c12e /lib/exTpl
parentda0022e5c1abbf9825ae76debaabdff7e8623bb4 (diff)
parent97a188592c679890a25c37ab78463add76a52ff7 (diff)
Merge branch 'main' into issue-3911issue-3911
Diffstat (limited to 'lib/exTpl')
-rw-r--r--lib/exTpl/ArithExpression.php29
-rw-r--r--lib/exTpl/ArrayNode.php37
-rw-r--r--lib/exTpl/BinaryExpression.php27
-rw-r--r--lib/exTpl/BooleanExpression.php31
-rw-r--r--lib/exTpl/ConditionExpression.php38
-rw-r--r--lib/exTpl/ConditionNode.php59
-rw-r--r--lib/exTpl/ConstantExpression.php31
-rw-r--r--lib/exTpl/Context.php77
-rw-r--r--lib/exTpl/Expression.php27
-rw-r--r--lib/exTpl/ExpressionNode.php37
-rw-r--r--lib/exTpl/FunctionExpression.php45
-rw-r--r--lib/exTpl/IndexExpression.php22
-rw-r--r--lib/exTpl/IteratorNode.php55
-rw-r--r--lib/exTpl/MinusExpression.php19
-rw-r--r--lib/exTpl/Node.php28
-rw-r--r--lib/exTpl/NotExpression.php19
-rw-r--r--lib/exTpl/RawExpression.php19
-rw-r--r--lib/exTpl/Scanner.php87
-rw-r--r--lib/exTpl/SymbolExpression.php39
-rw-r--r--lib/exTpl/Template.php547
-rw-r--r--lib/exTpl/TemplateParserException.php19
-rw-r--r--lib/exTpl/TextNode.php31
-rw-r--r--lib/exTpl/UnaryExpression.php21
23 files changed, 1344 insertions, 0 deletions
diff --git a/lib/exTpl/ArithExpression.php b/lib/exTpl/ArithExpression.php
new file mode 100644
index 0000000..4888b73
--- /dev/null
+++ b/lib/exTpl/ArithExpression.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * ArithExpression represents an arithmetic operator.
+ */
+class ArithExpression extends BinaryExpression
+{
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ $left = $this->left->value($context);
+ $right = $this->right->value($context);
+
+ return match ($this->operator) {
+ '+' => $left + $right,
+ '-' => $left - $right,
+ '*' => $left * $right,
+ '/' => $left / $right,
+ '%' => $left % $right,
+ '~' => $left . $right,
+ };
+ }
+}
diff --git a/lib/exTpl/ArrayNode.php b/lib/exTpl/ArrayNode.php
new file mode 100644
index 0000000..3981598
--- /dev/null
+++ b/lib/exTpl/ArrayNode.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * ArrayNode represents a sequence of arbitrary nodes.
+ */
+class ArrayNode implements Node
+{
+ protected array $nodes = [];
+
+ /**
+ * Adds a child node to this sequence node.
+ *
+ * @param Node $node child node to add
+ */
+ public function addChild(Node $node): void
+ {
+ $this->nodes[] = $node;
+ }
+
+ /**
+ * Returns a string representation of this node.
+ *
+ * @param Context $context symbol table
+ */
+ public function render(Context $context): string
+ {
+ $result = '';
+
+ foreach ($this->nodes as $node) {
+ $result .= $node->render($context);
+ }
+
+ return $result;
+ }
+}
diff --git a/lib/exTpl/BinaryExpression.php b/lib/exTpl/BinaryExpression.php
new file mode 100644
index 0000000..1c82730
--- /dev/null
+++ b/lib/exTpl/BinaryExpression.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * BinaryExpression represents a binary operator.
+ */
+abstract class BinaryExpression implements Expression
+{
+ protected Expression $left;
+ protected Expression $right;
+ protected mixed $operator;
+
+ /**
+ * Initializes a new Expression instance.
+ *
+ * @param Expression $left left operand
+ * @param Expression $right right operand
+ * @param mixed $operator operator token
+ */
+ public function __construct(Expression $left, Expression $right, mixed $operator)
+ {
+ $this->left = $left;
+ $this->right = $right;
+ $this->operator = $operator;
+ }
+}
diff --git a/lib/exTpl/BooleanExpression.php b/lib/exTpl/BooleanExpression.php
new file mode 100644
index 0000000..0e18df0
--- /dev/null
+++ b/lib/exTpl/BooleanExpression.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * BooleanExpression represents a boolean operator.
+ */
+class BooleanExpression extends BinaryExpression
+{
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): bool
+ {
+ $left = $this->left->value($context);
+ $right = $this->right->value($context);
+
+ return match ($this->operator) {
+ T_IS_EQUAL => $left == $right,
+ T_IS_NOT_EQUAL => $left != $right,
+ '<' => $left < $right,
+ T_IS_SMALLER_OR_EQUAL => $left <= $right,
+ '>' => $left > $right,
+ T_IS_GREATER_OR_EQUAL => $left >= $right,
+ T_BOOLEAN_AND => $left && $right,
+ T_BOOLEAN_OR => $left || $right,
+ };
+ }
+}
diff --git a/lib/exTpl/ConditionExpression.php b/lib/exTpl/ConditionExpression.php
new file mode 100644
index 0000000..657ee31
--- /dev/null
+++ b/lib/exTpl/ConditionExpression.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * ConditionExpression represents the conditional operator ('?:').
+ */
+class ConditionExpression implements Expression
+{
+ protected Expression $condition;
+ protected Expression $left;
+ protected Expression $right;
+
+ /**
+ * Initializes a new Expression instance.
+ *
+ * @param Expression $condition expression
+ * @param Expression $left left alternative
+ * @param Expression $right right alternative
+ */
+ public function __construct(Expression $condition, Expression $left, Expression $right)
+ {
+ $this->condition = $condition;
+ $this->left = $left;
+ $this->right = $right;
+ }
+
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ return $this->condition->value($context) ?
+ $this->left->value($context) : $this->right->value($context);
+ }
+}
diff --git a/lib/exTpl/ConditionNode.php b/lib/exTpl/ConditionNode.php
new file mode 100644
index 0000000..bab2121
--- /dev/null
+++ b/lib/exTpl/ConditionNode.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * ConditionNode represents a single condition tag:
+ * "{if CONDITION}...{else}...{endif}".
+ */
+class ConditionNode extends ArrayNode
+{
+ protected Expression $condition;
+ protected ArrayNode|null $else_node = null;
+
+ /**
+ * Initializes a new Node instance with the given expression.
+ *
+ * @param Expression $condition expression object
+ */
+ public function __construct(Expression $condition)
+ {
+ $this->condition = $condition;
+ }
+
+ /**
+ * Adds an else block to this condition node.
+ */
+ public function addElse(): void
+ {
+ $this->else_node = new ArrayNode();
+ }
+
+ /**
+ * Adds a child node to this condition node.
+ *
+ * @param Node $node child node to add
+ */
+ public function addChild(Node $node): void
+ {
+ if ($this->else_node) {
+ $this->else_node->addChild($node);
+ } else {
+ parent::addChild($node);
+ }
+ }
+
+ /**
+ * Returns a string representation of this node.
+ *
+ * @param Context $context symbol table
+ */
+ public function render(Context $context): string
+ {
+ if ($this->condition->value($context)) {
+ return parent::render($context);
+ }
+
+ return $this->else_node ? $this->else_node->render($context) : '';
+ }
+}
diff --git a/lib/exTpl/ConstantExpression.php b/lib/exTpl/ConstantExpression.php
new file mode 100644
index 0000000..7235647
--- /dev/null
+++ b/lib/exTpl/ConstantExpression.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * ConstantExpression represents a literal value.
+ */
+class ConstantExpression implements Expression
+{
+ protected mixed $value;
+
+ /**
+ * Initializes a new Expression instance.
+ *
+ * @param mixed $value expression value
+ */
+ public function __construct(mixed $value)
+ {
+ $this->value = $value;
+ }
+
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ return $this->value;
+ }
+}
diff --git a/lib/exTpl/Context.php b/lib/exTpl/Context.php
new file mode 100644
index 0000000..309174f
--- /dev/null
+++ b/lib/exTpl/Context.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * Context.php - template parser symbol table
+ *
+ * A Context object represents the symbol table used to resolve
+ * symbol names to their values in the local scope. Each context
+ * may inherit symbol definitions from its parent context.
+ *
+ * @copyright 2013 Elmar Ludwig
+ * @license GPL2 or any later version
+ */
+
+namespace exTpl;
+
+use Closure;
+
+class Context
+{
+ private array $bindings;
+ private Closure|null $escape;
+ private Context|null $parent;
+
+ /**
+ * Initializes a new Context instance with the given bindings.
+ *
+ * @param array $bindings symbol table
+ * @param Context|null $parent parent context (or NULL)
+ */
+ public function __construct(array $bindings, Context $parent = null)
+ {
+ $this->bindings = $bindings;
+ $this->parent = $parent;
+ }
+
+ /**
+ * Looks up the value of a symbol in this context and returns it.
+ * The reserved symbol "this" is an alias for the current context.
+ *
+ * @param string $key symbol name
+ */
+ public function lookup(string $key): mixed
+ {
+ if (isset($this->bindings[$key])) {
+ return $this->bindings[$key];
+ } else if ($this->parent) {
+ return $this->parent->lookup($key);
+ }
+
+ return null;
+ }
+
+ /**
+ * Enables or disables automatic escaping for template values.
+ *
+ * @param callable|null $escape escape callback or null
+ */
+ public function autoescape(?callable $escape): void
+ {
+ $this->escape = $escape ? $escape(...) : null;
+ }
+
+ /**
+ * Escapes the given value using the configured strategy.
+ *
+ * @param mixed $value expression value
+ */
+ public function escape(mixed $value): mixed
+ {
+ if (isset($this->escape)) {
+ $value = call_user_func($this->escape, $value);
+ } else if ($this->parent) {
+ $value = $this->parent->escape($value);
+ }
+
+ return $value;
+ }
+}
diff --git a/lib/exTpl/Expression.php b/lib/exTpl/Expression.php
new file mode 100644
index 0000000..15a485d
--- /dev/null
+++ b/lib/exTpl/Expression.php
@@ -0,0 +1,27 @@
+<?php
+/*
+ * Expression.php - template parser expression interface and classes
+ *
+ * Copyright (c) 2013 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+namespace exTpl;
+
+/**
+ * Basic interface for expressions in the template parse tree. The
+ * only required method is "value" to get the expression's value.
+ */
+interface Expression
+{
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed;
+}
diff --git a/lib/exTpl/ExpressionNode.php b/lib/exTpl/ExpressionNode.php
new file mode 100644
index 0000000..061edce
--- /dev/null
+++ b/lib/exTpl/ExpressionNode.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * ExpressionNode represents an expression tag: "{...}".
+ */
+class ExpressionNode implements Node
+{
+ protected Expression $expr;
+
+ /**
+ * Initializes a new Node instance with the given expression.
+ *
+ * @param Expression $expr expression object
+ */
+ public function __construct(Expression $expr)
+ {
+ $this->expr = $expr;
+ }
+
+ /**
+ * Returns a string representation of this node.
+ *
+ * @param Context $context symbol table
+ */
+ public function render(Context $context): ?string
+ {
+ $value = $this->expr->value($context);
+
+ if (!($this->expr instanceof RawExpression)) {
+ $value = $context->escape($value);
+ }
+
+ return $value;
+ }
+}
diff --git a/lib/exTpl/FunctionExpression.php b/lib/exTpl/FunctionExpression.php
new file mode 100644
index 0000000..48d4413
--- /dev/null
+++ b/lib/exTpl/FunctionExpression.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * FunctionExpression represents a function call.
+ */
+class FunctionExpression implements Expression
+{
+ protected Expression $name;
+ protected array $arguments;
+
+ /**
+ * Initializes a new Expression instance.
+ *
+ * @param Expression $name function name
+ * @param array $arguments function arguments
+ */
+ public function __construct(Expression $name, array $arguments)
+ {
+ $this->name = $name;
+ $this->arguments = $arguments;
+ }
+
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ $callable = $this->name->value($context);
+ $arguments = [];
+
+ foreach ($this->arguments as $expr) {
+ $arguments[] = $expr->value($context);
+ }
+
+ if (is_callable($callable)) {
+ return $callable(...$arguments);
+ }
+
+ return null;
+ }
+}
diff --git a/lib/exTpl/IndexExpression.php b/lib/exTpl/IndexExpression.php
new file mode 100644
index 0000000..ecb3241
--- /dev/null
+++ b/lib/exTpl/IndexExpression.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * IndexExpression represents the array index operator.
+ */
+class IndexExpression extends BinaryExpression
+{
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ $left = $this->left->value($context);
+ $right = $this->right->value($context);
+
+ return $left[$right];
+ }
+}
diff --git a/lib/exTpl/IteratorNode.php b/lib/exTpl/IteratorNode.php
new file mode 100644
index 0000000..58dd593
--- /dev/null
+++ b/lib/exTpl/IteratorNode.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * IteratorNode represents a single iterator tag:
+ * "{foreach ARRAY [as [KEY =>] VALUE]}...{endforeach}".
+ */
+class IteratorNode extends ArrayNode
+{
+ protected Expression $expr;
+ protected string $key_name;
+ protected string $val_name;
+
+ /**
+ * Initializes a new Node instance with the given expression.
+ *
+ * @param Expression $expr expression object
+ * @param string $key_name name of variable on each iteration
+ * @param string $val_name name of variable on each iteration
+ */
+ public function __construct(Expression $expr, string $key_name, string $val_name)
+ {
+ $this->expr = $expr;
+ $this->key_name = $key_name;
+ $this->val_name = $val_name;
+ }
+
+ /**
+ * Returns a string representation of this node. The IteratorNode
+ * renders the node sequence for each value in the expression list.
+ *
+ * @param Context $context symbol table
+ */
+ public function render(Context $context): string
+ {
+ $values = $this->expr->value($context);
+ $result = '';
+
+ if (is_array($values) && is_int(key($values))) {
+ $bindings = [$this->key_name => &$key, $this->val_name => &$value];
+ $context = new Context($bindings, $context);
+
+ foreach ($values as $key => $value) {
+ $result .= parent::render(new Context($value, $context));
+ }
+ } else if (is_array($values) && count($values)) {
+ return parent::render(new Context($values, $context));
+ } else if ($values) {
+ return parent::render($context);
+ }
+
+ return $result;
+ }
+}
diff --git a/lib/exTpl/MinusExpression.php b/lib/exTpl/MinusExpression.php
new file mode 100644
index 0000000..6b81e91
--- /dev/null
+++ b/lib/exTpl/MinusExpression.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * MinusExpression represents the unary minus operator ('-').
+ */
+class MinusExpression extends UnaryExpression
+{
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ return -$this->expr->value($context);
+ }
+}
diff --git a/lib/exTpl/Node.php b/lib/exTpl/Node.php
new file mode 100644
index 0000000..5c24cab
--- /dev/null
+++ b/lib/exTpl/Node.php
@@ -0,0 +1,28 @@
+<?php
+/*
+ * Node.php - template parser node interface and classes
+ *
+ * Copyright (c) 2013 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+namespace exTpl;
+
+
+/**
+ * Basic interface for nodes in the template parse tree. The only
+ * required method is "render" to render a node and its children.
+ */
+interface Node
+{
+ /**
+ * Returns a string representation of this node.
+ *
+ * @param Context $context symbol table
+ */
+ public function render(Context $context): ?string;
+}
diff --git a/lib/exTpl/NotExpression.php b/lib/exTpl/NotExpression.php
new file mode 100644
index 0000000..cf7472d
--- /dev/null
+++ b/lib/exTpl/NotExpression.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * NotExpression represents the logical negation operator ('!').
+ */
+class NotExpression extends UnaryExpression
+{
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): bool
+ {
+ return !$this->expr->value($context);
+ }
+}
diff --git a/lib/exTpl/RawExpression.php b/lib/exTpl/RawExpression.php
new file mode 100644
index 0000000..275f239
--- /dev/null
+++ b/lib/exTpl/RawExpression.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * RawExpression represents the "raw" filter function.
+ */
+class RawExpression extends UnaryExpression
+{
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ return $this->expr->value($context);
+ }
+}
diff --git a/lib/exTpl/Scanner.php b/lib/exTpl/Scanner.php
new file mode 100644
index 0000000..76cbc01
--- /dev/null
+++ b/lib/exTpl/Scanner.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * Scanner.php - template parser lexical scanner
+ *
+ * Simple wrapper class around the Zend engine's lexical scanner. It
+ * automatically skips whitespace.
+ *
+ * @copyright 2013 Elmar Ludwig
+ * @license GPL2 or any later version
+ */
+namespace exTpl;
+
+class Scanner
+{
+ private array $tokens;
+ private mixed $token_type;
+ private mixed $token_value;
+
+ /**
+ * Initializes a new Scanner instance for the given text.
+ *
+ * @param string $text string to parse
+ */
+ public function __construct(string $text)
+ {
+ $this->tokens = token_get_all('<?php ' . $text);
+ }
+
+ /**
+ * Advances the scanner to the next token and returns its token type.
+ * The valid token types are those defined for token_get_all() in the
+ * PHP documentation. Returns false when the end of input is reached.
+ */
+ public function nextToken(): mixed
+ {
+ do {
+ $token = next($this->tokens);
+ $key = key($this->tokens);
+
+ // FIXME this workaround should be dropped
+ while (
+ $token && $token[0] === T_STRING
+ && isset($this->tokens[$key + 2])
+ && $this->tokens[++$key] === '-'
+ && $this->tokens[++$key][0] === T_STRING
+ ) {
+ $token[1] .= '-' . $this->tokens[$key][1];
+ next($this->tokens);
+ next($this->tokens);
+ }
+ } while (is_array($token) && $token[0] === T_WHITESPACE);
+
+ if (is_string($token) || $token === false) {
+ $this->token_type = $token;
+ $this->token_value = null;
+ } else {
+ $this->token_type = $token[0];
+
+ $this->token_value = match ($token[0]) {
+ T_CONSTANT_ENCAPSED_STRING => stripcslashes(substr($token[1], 1, -1)),
+ T_DNUMBER => (double) $token[1],
+ T_LNUMBER => (int) $token[1],
+ default => $token[1],
+ };
+ }
+
+ return $this->token_type;
+ }
+
+ /**
+ * Returns the current token type. The valid token types are
+ * those defined for token_get_all() in the PHP documentation.
+ */
+ public function tokenType(): mixed
+ {
+ return $this->token_type;
+ }
+
+ /**
+ * Returns the current token value if the token type supports
+ * a value (T_STRING, T_LNUMBER etc.). Returns null otherwise.
+ */
+ public function tokenValue(): mixed
+ {
+ return $this->token_value;
+ }
+}
diff --git a/lib/exTpl/SymbolExpression.php b/lib/exTpl/SymbolExpression.php
new file mode 100644
index 0000000..178a51f
--- /dev/null
+++ b/lib/exTpl/SymbolExpression.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * SymbolExpression represents a symbol (template variable).
+ */
+class SymbolExpression implements Expression
+{
+ protected string $name;
+
+ /**
+ * Initializes a new Expression instance.
+ *
+ * @param string $name symbol name
+ */
+ public function __construct(string $name)
+ {
+ $this->name = $name;
+ }
+
+ /**
+ * Returns the name of this symbol.
+ */
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Returns the value of this expression.
+ *
+ * @param Context $context symbol table
+ */
+ public function value(Context $context): mixed
+ {
+ return $context->lookup($this->name);
+ }
+}
diff --git a/lib/exTpl/Template.php b/lib/exTpl/Template.php
new file mode 100644
index 0000000..274e115
--- /dev/null
+++ b/lib/exTpl/Template.php
@@ -0,0 +1,547 @@
+<?php
+/*
+ * Template.php - expression template parser
+ *
+ * Copyright (c) 2013 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+namespace exTpl;
+
+use Closure;
+use InvalidArgumentException;
+
+
+/**
+ * The Template class is the only externally visible API of this
+ * template implementation. It can be used to create and render
+ * template objects.
+ */
+class Template
+{
+ private static string $tag_start = '{';
+ private static string $tag_end = '}';
+ private Closure|string|null $escape = null;
+ private array $functions;
+ private ArrayNode $template;
+
+ /**
+ * Sets the delimiter strings used for the template tags, the
+ * default delimiters are: $tag_start = '{', $tag_end = '}'.
+ *
+ * @param string $tag_start tag start marker
+ * @param string $tag_end tag end marker
+ */
+ public static function setTagMarkers(string $tag_start, string $tag_end): void
+ {
+ self::$tag_start = $tag_start;
+ self::$tag_end = $tag_end;
+ }
+
+ /**
+ * Initializes a new Template instance from the given string.
+ *
+ * @param string $string template text
+ *
+ * @throws TemplateParserException
+ */
+ public function __construct(string $string)
+ {
+ $this->template = new ArrayNode();
+ $this->functions = [
+ 'count' => fn($a) => count($a),
+ 'strlen' => fn($a) => mb_strlen($a),
+ ];
+ self::parseTemplate($this->template, $string, 0);
+ }
+
+ /**
+ * Enables or disables automatic escaping for template values.
+ * Currently supported strategies: NULL, 'html', 'json', 'xml'
+ *
+ * @param callable|string|null $escape escape strategy or callback
+ */
+ public function autoescape(callable|string|null $escape): void
+ {
+ if ($escape === 'html' || $escape === 'xml') {
+ $this->escape = 'htmlspecialchars';
+ } else if ($escape === 'json') {
+ $this->escape = 'json_encode';
+ } else if (is_callable($escape)) {
+ $this->escape = $escape;
+ } else if ($escape === null) {
+ $this->escape = null;
+ } else {
+ throw new InvalidArgumentException("invalid escape strategy: $escape");
+ }
+ }
+
+ /**
+ * Renders the template to a string using the given array of
+ * bindings to resolve symbol references inside the template.
+ *
+ * @param array $bindings symbol table
+ *
+ * @return string string representation of the template
+ */
+ public function render(array $bindings): string
+ {
+ $context = new Context($bindings + $this->functions);
+ $context->autoescape($this->escape);
+
+ return $this->template->render($context);
+ }
+
+ /**
+ * Skips tokens until the end of the current tag is reached.
+ *
+ * @param string $string template text
+ * @param int $pos offset in string
+ *
+ * @return int new offset in the string
+ */
+ private static function skipTokens(string $string, int $pos): int
+ {
+ for ($len = strlen($string); $pos < $len && substr_compare($string, self::$tag_end, $pos, strlen(self::$tag_end)); ++$pos) {
+ $chr = $string[$pos];
+ if ($chr === '"' || $chr === "'") {
+ while (++$pos < $len && $string[$pos] !== $chr) {
+ if ($string[$pos] === '\\') {
+ ++$pos;
+ }
+ }
+ }
+ }
+
+ return $pos;
+ }
+
+ /**
+ * Parses a template string into a template node tree, starting
+ * at the specified offset. All created nodes are added to the
+ * given sequence node.
+ *
+ * @param ArrayNode $node template node to build
+ * @param string $string string to parse
+ * @param int $pos offset in string
+ *
+ * @return int new offset in the string
+ * @throws TemplateParserException
+ */
+ private static function parseTemplate(ArrayNode $node, string $string, int $pos): int
+ {
+ $len = strlen($string);
+
+ while ($pos < $len) {
+ $next_pos = strpos($string, self::$tag_start, $pos);
+
+ if ($next_pos === false) {
+ $child = new TextNode(substr($string, $pos));
+ $node->addChild($child);
+ break;
+ }
+
+ if ($next_pos > $pos) {
+ $child = new TextNode(substr($string, $pos, $next_pos - $pos));
+ $node->addChild($child);
+ }
+
+ $pos = $next_pos + strlen(self::$tag_start);
+ $next_pos = self::skipTokens($string, $pos);
+ $scanner = new Scanner(substr($string, $pos, $next_pos - $pos));
+ $pos = $next_pos + strlen(self::$tag_end);
+
+ switch ($scanner->nextToken()) {
+ case T_FOREACH:
+ $scanner->nextToken();
+ $expr = self::parseExpr($scanner);
+ $key_name = 'index';
+ $val_name = 'this';
+
+ if ($scanner->tokenType() === T_AS) {
+ $scanner->nextToken();
+
+ if ($scanner->tokenType() !== T_STRING) {
+ throw new TemplateParserException('symbol expected', $scanner);
+ }
+
+ $val_name = $scanner->tokenValue();
+ $scanner->nextToken();
+
+ if ($scanner->tokenType() === T_DOUBLE_ARROW) {
+ $scanner->nextToken();
+
+ if ($scanner->tokenType() !== T_STRING) {
+ throw new TemplateParserException('symbol expected', $scanner);
+ }
+
+ $key_name = $val_name;
+ $val_name = $scanner->tokenValue();
+ $scanner->nextToken();
+ }
+ }
+
+ $child = new IteratorNode($expr, $key_name, $val_name);
+ $pos = self::parseTemplate($child, $string, $pos);
+ $node->addChild($child);
+ break;
+ case T_ENDIF:
+ case T_ENDFOREACH:
+ return $pos;
+ case T_IF:
+ $scanner->nextToken();
+ $child = new ConditionNode(self::parseExpr($scanner));
+ $pos = self::parseTemplate($child, $string, $pos);
+ $node->addChild($child);
+ break;
+ case T_ELSEIF:
+ $scanner->nextToken();
+ $child = new ConditionNode(self::parseExpr($scanner));
+ $node->addElse();
+ $node->addChild($child);
+ return self::parseTemplate($child, $string, $pos);
+ case T_ELSE:
+ $scanner->nextToken();
+ $node->addElse();
+ break;
+ default:
+ $child = new ExpressionNode(self::parseExpr($scanner));
+ $node->addChild($child);
+ }
+
+ if ($scanner->tokenType() !== false) {
+ throw new TemplateParserException('syntax error', $scanner);
+ }
+ }
+
+ return $pos;
+ }
+
+ /**
+ * value: NUMBER | STRING | SYMBOL | '(' expr ')'
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseValue(Scanner $scanner): mixed
+ {
+ switch ($scanner->tokenType()) {
+ case T_CONSTANT_ENCAPSED_STRING:
+ case T_DNUMBER:
+ case T_LNUMBER:
+ $result = new ConstantExpression($scanner->tokenValue());
+ break;
+ case T_STRING:
+ $result = new SymbolExpression($scanner->tokenValue());
+ break;
+ case '(':
+ $scanner->nextToken();
+ $result = self::parseExpr($scanner);
+
+ if ($scanner->tokenType() !== ')') {
+ throw new TemplateParserException('missing ")"', $scanner);
+ }
+ break;
+ default:
+ throw new TemplateParserException('syntax error', $scanner);
+ }
+
+ $scanner->nextToken();
+ return $result;
+ }
+
+ /**
+ * function: value | function '(' ')' | function '(' expr { ',' expr } ')'
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseFunction(Scanner $scanner): mixed
+ {
+ $result = self::parseValue($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === '(') {
+ $scanner->nextToken();
+ $arguments = [];
+
+ if ($scanner->tokenType() !== ')') {
+ $arguments[] = self::parseExpr($scanner);
+
+ while ($scanner->tokenType() === ',') {
+ $scanner->nextToken();
+ $arguments[] = self::parseExpr($scanner);
+ }
+
+ if ($scanner->tokenType() !== ')') {
+ throw new TemplateParserException('missing ")"', $scanner);
+ }
+ }
+
+ $scanner->nextToken();
+ $result = new FunctionExpression($result, $arguments);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * index: function | index '[' expr ']' | index '.' SYMBOL
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseIndex(Scanner $scanner): mixed
+ {
+ $result = self::parseFunction($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === '[' || $type === '.') {
+ $scanner->nextToken();
+
+ if ($type === '[') {
+ $expr = self::parseExpr($scanner);
+
+ if ($scanner->tokenType() !== ']') {
+ throw new TemplateParserException('missing "]"', $scanner);
+ }
+ } else if ($scanner->tokenType() === T_STRING) {
+ $expr = new ConstantExpression($scanner->tokenValue());
+ } else {
+ throw new TemplateParserException('symbol expected', $scanner);
+ }
+
+ $scanner->nextToken();
+ $result = new IndexExpression($result, $expr, $type);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * filter: index | filter '|' SYMBOL | filter '|' SYMBOL '(' expr { ',' expr } ')'
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseFilter(Scanner $scanner): mixed
+ {
+ $result = self::parseIndex($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === '|') {
+ $scanner->nextToken();
+
+ if ($scanner->tokenType() !== T_STRING) {
+ throw new TemplateParserException('symbol expected', $scanner);
+ }
+
+ $arguments = [$result];
+ $symbol = new SymbolExpression($scanner->tokenValue());
+ $scanner->nextToken();
+
+ if ($scanner->tokenType() === '(') {
+ $scanner->nextToken();
+
+ if ($scanner->tokenType() !== ')') {
+ $arguments[] = self::parseExpr($scanner);
+
+ while ($scanner->tokenType() === ',') {
+ $scanner->nextToken();
+ $arguments[] = self::parseExpr($scanner);
+ }
+
+ if ($scanner->tokenType() !== ')') {
+ throw new TemplateParserException('missing ")"', $scanner);
+ }
+ }
+
+ $scanner->nextToken();
+ }
+
+ if ($symbol->name() === 'raw') {
+ $result = new RawExpression($result);
+ } else {
+ $result = new FunctionExpression($symbol, $arguments);
+ }
+
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * sign: '!' sign | '+' sign | '-' sign | filter
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseSign(Scanner $scanner): mixed
+ {
+ switch ($scanner->tokenType()) {
+ case '!':
+ $scanner->nextToken();
+ $result = new NotExpression(self::parseSign($scanner));
+ break;
+ case '+':
+ $scanner->nextToken();
+ $result = self::parseSign($scanner);
+ break;
+ case '-':
+ $scanner->nextToken();
+ $result = new MinusExpression(self::parseSign($scanner));
+ break;
+ default:
+ $result = self::parseFilter($scanner);
+ }
+
+ return $result;
+ }
+
+ /**
+ * product: sign | product '*' sign | product '/' sign | product '%' sign
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseProduct(Scanner $scanner): mixed
+ {
+ $result = self::parseSign($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === '*' || $type === '/' || $type === '%') {
+ $scanner->nextToken();
+ $result = new ArithExpression($result, self::parseSign($scanner), $type);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * sum: product | sum '+' product | sum '-' product | sum '~' product
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseSum(Scanner $scanner): mixed
+ {
+ $result = self::parseProduct($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === '+' || $type === '-' || $type === '~') {
+ $scanner->nextToken();
+ $result = new ArithExpression($result, self::parseProduct($scanner), $type);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * lt_gt: sum | lt_gt '<' concat | lt_gt IS_SMALLER_OR_EQUAL concat
+ * | lt_gt '>' concat | lt_gt IS_GREATER_OR_EQUAL concat
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseLtGt(Scanner $scanner): mixed
+ {
+ $result = self::parseSum($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === '<' || $type === T_IS_SMALLER_OR_EQUAL ||
+ $type === '>' || $type === T_IS_GREATER_OR_EQUAL) {
+ $scanner->nextToken();
+ $result = new BooleanExpression($result, self::parseSum($scanner), $type);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * cmp: lt_gt | cmp IS_EQUAL lt_gt | cmp IS_NOT_EQUAL lt_gt
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseCmp(Scanner $scanner): mixed
+ {
+ $result = self::parseLtGt($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === T_IS_EQUAL || $type === T_IS_NOT_EQUAL) {
+ $scanner->nextToken();
+ $result = new BooleanExpression($result, self::parseLtGt($scanner), $type);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * and: cmp | and BOOLEAN_AND cmp
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseAnd(Scanner $scanner): mixed
+ {
+ $result = self::parseCmp($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === T_BOOLEAN_AND) {
+ $scanner->nextToken();
+ $result = new BooleanExpression($result, self::parseCmp($scanner), $type);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * or: and | or BOOLEAN_OR and
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseOr(Scanner $scanner): mixed
+ {
+ $result = self::parseAnd($scanner);
+ $type = $scanner->tokenType();
+
+ while ($type === T_BOOLEAN_OR) {
+ $scanner->nextToken();
+ $result = new BooleanExpression($result, self::parseAnd($scanner), $type);
+ $type = $scanner->tokenType();
+ }
+
+ return $result;
+ }
+
+ /**
+ * expr: or | or '?' expr ':' expr | or '?' ':' expr
+ *
+ * @throws TemplateParserException
+ */
+ private static function parseExpr(Scanner $scanner): mixed
+ {
+ $result = self::parseOr($scanner);
+
+ if ($scanner->tokenType() === '?') {
+ $scanner->nextToken();
+
+ if ($scanner->tokenType() !== ':') {
+ $expr = self::parseExpr($scanner);
+ } else {
+ $expr = $result;
+ }
+
+ if ($scanner->tokenType() !== ':') {
+ throw new TemplateParserException('missing ":"', $scanner);
+ }
+
+ $scanner->nextToken();
+ $result = new ConditionExpression($result, $expr, self::parseExpr($scanner));
+ }
+
+ return $result;
+ }
+}
diff --git a/lib/exTpl/TemplateParserException.php b/lib/exTpl/TemplateParserException.php
new file mode 100644
index 0000000..2c7abf8
--- /dev/null
+++ b/lib/exTpl/TemplateParserException.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace exTpl;
+
+use Exception;
+
+/**
+ * Exception class used to report template parse errors.
+ */
+class TemplateParserException extends Exception
+{
+ public function __construct(string $message, Scanner $scanner)
+ {
+ $type = $scanner->tokenType();
+ $value = is_int($type) ? $scanner->tokenValue() : $type;
+
+ return parent::__construct("$message at \"$value\"");
+ }
+}
diff --git a/lib/exTpl/TextNode.php b/lib/exTpl/TextNode.php
new file mode 100644
index 0000000..053cafc
--- /dev/null
+++ b/lib/exTpl/TextNode.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * TextNode represents a verbatim text segment.
+ */
+class TextNode implements Node
+{
+ protected string $text;
+
+ /**
+ * Initializes a new Node instance with the given text.
+ *
+ * @param string $text verbatim text
+ */
+ public function __construct(string $text)
+ {
+ $this->text = $text;
+ }
+
+ /**
+ * Returns a string representation of this node.
+ *
+ * @param Context $context symbol table
+ */
+ public function render(Context $context): string
+ {
+ return $this->text;
+ }
+}
diff --git a/lib/exTpl/UnaryExpression.php b/lib/exTpl/UnaryExpression.php
new file mode 100644
index 0000000..9979311
--- /dev/null
+++ b/lib/exTpl/UnaryExpression.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace exTpl;
+
+/**
+ * UnaryExpression represents a unary operator.
+ */
+abstract class UnaryExpression implements Expression
+{
+ protected Expression $expr;
+
+ /**
+ * Initializes a new Expression instance.
+ *
+ * @param Expression $expr expression object
+ */
+ public function __construct(Expression $expr)
+ {
+ $this->expr = $expr;
+ }
+}