From bd1ad76000b86ee4e4f7cd39ae08c223df1acd56 Mon Sep 17 00:00:00 2001
From: Robert Sesek <rsesek@bluestatic.org>
Date: Sat, 18 Apr 2020 13:34:19 -0400
Subject: [PATCH] Work on a new template.

---
 includes/class_template.php      | 693 +++++++++++++++++++++++++++++++
 includes/class_template_test.php |  47 +++
 2 files changed, 740 insertions(+)
 create mode 100644 includes/class_template.php
 create mode 100644 includes/class_template_test.php

diff --git a/includes/class_template.php b/includes/class_template.php
new file mode 100644
index 0000000..1eca911
--- /dev/null
+++ b/includes/class_template.php
@@ -0,0 +1,693 @@
+<?php
+/*=====================================================================*\
+|| ###################################################################
+|| # Bugdar
+|| # Copyright (c)2020 Blue Static
+|| #
+|| # 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; version 2 of the License.
+|| #
+|| # 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 General Public License for
+|| # more details.
+|| #
+|| # You should have received a copy of the GNU General Public License along
+|| # with this program; if not, write to the Free Software Foundation, Inc.,
+|| # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
+|| ###################################################################
+\*=====================================================================*/
+
+namespace Bugdar\Template;
+
+class TemplateError extends \Exception {}
+
+class TemplateParseError extends \Exception {}
+
+class TemplateLexError extends \Exception {}
+
+class TemplateCompileError extends \Exception{}
+
+class TemplateLoader
+{
+	private $_cached = [];
+
+	private $_compiler;
+
+	public function __construct()
+	{
+		$this->_compiler = new Compiler();
+	}
+
+	public function compiler() { return $this->_compiler; }
+
+	public function load($template_name)
+	{
+		if (isset($this->_cached[$template_name])) {
+			return $this->_cached[$template_name];
+		}
+
+		$template = $this->_query($template_name);
+		if ($template) {
+			return $template;
+		}
+
+		$template = $this->_load($template_name);
+		if ($template) {
+			$this->_store($template_name, $template);
+			return $template;
+		}
+	}
+
+	public function cache(array $template_names)
+	{
+	}
+
+	private function _query($template_name)
+	{
+	}
+
+	private function _load($template_name)
+	{
+		$file = $template_name;
+		$parser = new Parser($file);
+		$lexer = new Lexer($parser->parse());
+		return $this->_compiler->compile($lexer->lex());
+	}
+}
+
+class Template
+{
+	private $_name;
+	private $_template;
+	private $_data = [];
+
+	private static $_executing = null;
+
+	public function __construct($name, $template)
+	{
+		$this->_name = $name;
+		$this->_template = $template;
+	}
+
+	public function set($key, $value)
+	{
+		$this->_data[$key] = $value;
+	}
+
+	public function replace($data)
+	{
+		$this->_data = $data;
+	}
+
+	public function execute()
+	{
+		$old_executing = self::$_executing;
+		self::$_executing = $this;
+
+		$lambda = function($template) {
+			eval('$rendered = ' . $template . ';');
+			return $rendered;
+		};
+		$result = $lambda($this->_template);
+
+		self::$_executing = $old_executing;
+
+		return $result;
+	}
+
+	static public function get($key)
+	{
+		return self::$_executing->_data[$key];
+	}
+}
+
+const TOKEN_CONTENT				= 'T_CONTENT';
+const TOKEN_FUNCTION_START		= 'T_FUNC_START';
+const TOKEN_FUNCTION_NAME		= 'T_FUNC_NAME';
+const TOKEN_FUNCTION_ARG_NAME	= 'T_FUNC_ARG_NAME';
+const TOKEN_FUNCTION_ARG_SEP	= 'T_FUNC_ARG_SEP';
+const TOKEN_FUNCTION_ARG_VALUE	= 'T_FUNC_ARG_VALUE';
+const TOKEN_FUNCTION_CLOSE		= 'T_FUNC_CLOSE';
+const TOKEN_FUNCTION_END		= 'T_FUNC_END';
+
+/**
+ * The Parser is responsible for turning a string template into an array of
+ * parsed tokens.
+ */
+class Parser
+{
+	const START_TOKEN = '<t:';
+	const START_TOKEN_LEN = 3;
+
+	const END_TOKEN = '</t:';
+	const END_TOKEN_LEN = 4;
+
+	const CLOSE = '>';
+	const SELF_CLOSE = '/';
+
+	const ARG_SEP = '=';
+	const ARG_VALUE = '"';
+
+	/** @var string The input string stream. */
+	private $_stream;
+
+	/** @var int The length of the string. */
+	private $_length;
+
+	/** @var int The position in the {@see Parser::$_stream}. */
+	private $_pos;
+
+	/** @var int The start of the current token. */
+	private $_start = 0;
+
+	/** @var array<Token> The parsed token stream. */
+	private $_tokens = [];
+
+	public function __construct($stream)
+	{
+		$this->_stream = $stream;
+		$this->_length = strlen($stream);
+	}
+
+	public function parse()
+	{
+		$this->_pos = $this->_start = 0;
+		$this->_tokens = [];
+
+		while ($this->_pos < $this->_length) {
+			$last_token = $this->_lastToken();
+			$c = $this->_stream[$this->_pos];
+
+			if ($last_token == TOKEN_FUNCTION_START) {
+				if (ctype_space($c) || $c == self::SELF_CLOSE || $c == self::CLOSE) {
+					$this->_pushToken(TOKEN_FUNCTION_NAME, $this->_start, $this->_pos);
+					if ($c == self::SELF_CLOSE) {
+						++$this->_pos;
+					} else if ($c != self::CLOSE) {
+						$this->_start = ++$this->_pos;
+						$this->_scanWhitespace();
+					}
+					continue;
+				} else if (!ctype_alnum($c)) {
+					throw new TemplateParseError("Invalid character '$c' in function identifier.");
+				}
+			} else if ($last_token == TOKEN_FUNCTION_NAME || $last_token == TOKEN_FUNCTION_ARG_VALUE) {
+				if ($c == self::CLOSE) {
+					$last_token_obj = end($this->_tokens);
+					$this->_pushToken(TOKEN_FUNCTION_CLOSE, $this->_pos, $this->_pos + 1);
+					if ($this->_stream[$this->_pos - 1] == self::SELF_CLOSE) {
+						// Self-closing functions insert an end function automatically.
+						array_push($this->_tokens, new Token(TOKEN_FUNCTION_END, self::END_TOKEN));
+						array_push($this->_tokens, new Token(TOKEN_FUNCTION_NAME, $last_token_obj->value));
+						array_push($this->_tokens, new Token(TOKEN_FUNCTION_CLOSE, self::CLOSE));
+					}
+					$this->_start = ++$this->_pos;
+					continue;
+				} else if ($c == self::ARG_SEP) {
+					$this->_pushToken(TOKEN_FUNCTION_ARG_NAME, $this->_start, $this->_pos);
+					$this->_pushToken(TOKEN_FUNCTION_ARG_SEP, $this->_pos, $this->_pos + 1);
+					$this->_start = ++$this->_pos;
+					$this->_scanWhitespace();
+					continue;
+				} else if (!ctype_alnum($c)) {
+					var_dump($this);
+					throw new TemplateParseError("Invalid character '$c' in function argument.");
+				}
+			} else if ($last_token == TOKEN_FUNCTION_ARG_SEP) {
+				if ($c != self::ARG_VALUE) {
+					throw new TemplateParseError("Expected '\"' got '$c'.");
+				}
+				++$this->_pos;
+				$this->_scanValue();
+				$this->_pushToken(TOKEN_FUNCTION_ARG_VALUE, $this->_start, $this->_pos);
+				++$this->_pos;
+				$this->_scanWhitespace();
+				$this->_start = $this->_pos;
+				continue;
+			} else if ($last_token == TOKEN_FUNCTION_END) {
+				if ($c == self::CLOSE) {
+					$this->_pushToken(TOKEN_FUNCTION_NAME, $this->_start, $this->_pos);
+					array_push($this->_tokens, new Token(TOKEN_FUNCTION_CLOSE, self::CLOSE));
+					$this->_start = ++$this->_pos;
+					continue;
+				} else if (!ctype_alnum($c)) {
+					throw new TemplateParseError("Invalid character '$c' in close function identifier.");
+				}
+			} else if ($last_token == TOKEN_FUNCTION_CLOSE || $last_token == TOKEN_CONTENT) {
+				if (substr_compare($this->_stream, self::START_TOKEN, $this->_pos, self::START_TOKEN_LEN) == 0) {
+					$this->_parseContentToken();
+					array_push($this->_tokens, new Token(TOKEN_FUNCTION_START, self::START_TOKEN));
+					$this->_pos += self::START_TOKEN_LEN;
+					$this->_start = $this->_pos;
+					continue;
+				} else if (substr_compare($this->_stream, self::END_TOKEN, $this->_pos, self::END_TOKEN_LEN) == 0) {
+					$this->_parseContentToken();
+					array_push($this->_tokens, new Token(TOKEN_FUNCTION_END, self::END_TOKEN));
+					$this->_pos += self::END_TOKEN_LEN;
+					$this->_start = $this->_pos;
+					continue;
+				}
+			}
+
+			++$this->_pos;
+		}
+
+		$last_token = $this->_lastToken();
+		if ($last_token != TOKEN_CONTENT && $last_token != TOKEN_FUNCTION_CLOSE) {
+			throw new TemplateParseError("Unterminated teplate expression, have $last_token");
+		}
+
+		$this->_parseContentToken();
+		return $this->_tokens;
+	}
+
+	private function _pushToken($type, $start, $end)
+	{
+		array_push($this->_tokens, new Token($type, $this->_substr($start, $end)));
+	}
+
+	private function _lastToken()
+	{
+		return empty($this->_tokens) ? TOKEN_CONTENT : end($this->_tokens)->type;
+	}
+
+	private function _scanWhitespace()
+	{
+		while ($this->_pos < $this->_length) {
+			if (!ctype_space($this->_stream[$this->_pos])) {
+				break;
+			}
+			++$this->_pos;
+			++$this->_start;
+		}
+	}
+
+	private function _scanValue()
+	{
+		$this->_start = $this->_pos;
+		while ($this->_pos < $this->_length) {
+			$c = $this->_stream[$this->_pos];
+			if ($c == self::ARG_VALUE && $this->_stream[$this->_pos - 1] != '\\') {
+				return;
+			}
+			++$this->_pos;
+		}
+		throw new TemplateParseError('Unexpected end of argument value.');
+	}
+
+	private function _parseContentToken()
+	{
+		if ($this->_start == $this->_pos) {
+			return;
+		}
+		$this->_pushToken(TOKEN_CONTENT, $this->_start, $this->_pos);
+		$this->_start = $this->_pos;
+	}
+
+	private function _substr($start, $end)
+	{
+		return substr($this->_stream, $start, $end - $start);
+	}
+}
+
+class Lexer
+{
+	private $_tokens;
+	private $_stack = [];
+
+	public function __construct($tokens)
+	{
+		$this->_tokens = $tokens;
+	}
+
+	public function lex()
+	{
+		$template = new TemplateNode();
+		array_push($this->_stack, $template);
+
+		$prev_token_type = TOKEN_CONTENT;
+		foreach ($this->_tokens as $token) {
+			$top = end($this->_stack);
+			$bad_token_msg = "Unexpected token type {$token->type}.";
+
+			switch ($token->type) {
+			case TOKEN_CONTENT:
+				$top->appendChild(new ContentNode($token->value));
+				break;
+			case TOKEN_FUNCTION_START:
+				if ($prev_token_type != TOKEN_CONTENT && $prev_token_type != TOKEN_FUNCTION_CLOSE) {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				array_push($this->_stack, new FunctionNode());
+				break;
+			case TOKEN_FUNCTION_NAME:
+				if ($prev_token_type == TOKEN_FUNCTION_START) {
+					$top->setName($token->value);
+				} else if ($prev_token_type == TOKEN_FUNCTION_END) {
+					if ($top->name() != $token->value) {
+						throw new TemplateLexError("Invalid nesting: cannot close '{$top->name()}' with '{$token->value}'.");
+					}
+					$func = array_pop($this->_stack);
+					$top = end($this->_stack);
+					$top->appendChild($func);
+				} else {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				break;
+			case TOKEN_FUNCTION_ARG_NAME:
+				if ($prev_token_type != TOKEN_FUNCTION_NAME && $prev_token_type != TOKEN_FUNCTION_ARG_VALUE) {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				$arg = new ArgumentNode();
+				$arg->setKey($token->value);
+				array_push($this->_stack, $arg);
+				break;
+			case TOKEN_FUNCTION_ARG_SEP:
+				if ($prev_token_type != TOKEN_FUNCTION_ARG_NAME || !($top instanceof ArgumentNode)) {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				break;
+			case TOKEN_FUNCTION_ARG_VALUE:
+				if ($prev_token_type != TOKEN_FUNCTION_ARG_SEP || !($top instanceof ArgumentNode)) {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				$arg = array_pop($this->_stack);
+				$arg->setValue($token->value);
+
+				$func = end($this->_stack);
+				if (!($func instanceof FunctionNode)) {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				$func->addArgument($arg);
+				break;
+			case TOKEN_FUNCTION_CLOSE:
+				if ($prev_token_type != TOKEN_FUNCTION_NAME && $prev_token_type != TOKEN_FUNCTION_ARG_VALUE) {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				break;
+			case TOKEN_FUNCTION_END:
+				if ($prev_token_type != TOKEN_CONTENT && $prev_token_type != TOKEN_FUNCTION_CLOSE) {
+					throw new TemplateLexError($bad_token_msg);
+				}
+				break;
+			}
+
+			$prev_token_type = $token->type;
+		}
+
+		if (array_pop($this->_stack) !== $template) {
+			throw new TemplateLexError('Invalid nesting.');
+		}
+
+		return $template;
+	}
+}
+
+class Compiler
+{
+	private $_functions = [];
+
+	public function registerDefaults()
+	{
+		$this->registerFunction(new VarFunction());
+		$this->registerFunction(new IfFunction());
+		$this->registerFunction(new PhraseFunction());
+	}
+
+	public function registerFunction(TemplateFunction $func)
+	{
+		$name = $func->name();
+		if (isset($this->_functions[$name])) {
+			throw new TemplateCompileError("A TemplateFunction has already been registered for '$name'.");
+		}
+		$this->_functions[$name] = $func;
+	}
+
+	public function compile(TemplateNode $template)
+	{
+		$output = [];
+		foreach ($template->children() as $child) {
+			$output[] = $this->compileNode($child);
+		}
+		return implode(' . ', $output);
+	}
+
+	public function compileNode(Node $node)
+	{
+		if ($node instanceof ContentNode) {
+			return '\'' . $this->singleQuoteEscape($node->value()) . '\'';
+		} else if ($node instanceof FunctionNode) {
+			$name = $node->name();
+			if (!isset($this->_functions[$name])) {
+				throw new TemplateCompileError("No function registered for name '$name'.");
+			}
+			return $this->_functions[$name]->compile($this, $node);
+		} else {
+			throw new TemplateCompileError("Unexpected node type: $node.");
+		}
+	}
+
+	public function singleQuoteEscape($value)
+	{
+		return str_replace("'", "\\'", $value);
+	}
+
+	public function doubleQuoteEscape($value)
+	{
+		return str_replace('"', '\\"', $value);
+	}
+}
+
+class VarFunction implements TemplateFunction
+{
+	public function name() { return 'v'; }
+
+	public function compile(Compiler $compiler, FunctionNode $node)
+	{
+		$children = $node->children();
+		if (count($children) != 1 || !($children[0] instanceof ContentNode)) {
+			throw new TemplateCompileError('Template function v must only have 1 content child.');
+		}
+
+		$args = $node->arguments();
+		if (count($args) > 1) {
+			throw new TemplateCompileError('Template function v can only have 1 argument.');
+		}
+
+		if (count($args) == 1 && !isset($args['as'])) {
+			throw new TemplateCompileError('Template function v only uses an "as" argument.');
+		}
+
+		$as = isset($args['as']) ? $args['as']->value() : 'string';
+		$value = $compiler->singleQuoteEscape($children[0]->value());
+		$get_value = "\\Bugdar\\Template\\Template::get('$value')";
+
+		switch ($as) {
+		case 'int': return "intval($get_value)";
+		case 'float': return "floatval($get_value)";
+		case 'json': return "json_encode($get_value)";
+		case 'string': return "htmlspecialchars($get_value)";
+		case 'raw': return $get_value;
+		default:
+			throw new TemplateCompileError('Unknown value "as" type "' . $as . '".');
+		}
+	}
+}
+
+class IfFunction implements TemplateFunction
+{
+	public function name() { return 'if'; }
+
+	public function compile(Compiler $compiler, FunctionNode $node)
+	{
+		$children = $node->children();
+
+		$else_index = -1;
+		foreach ($children as $i => $child) {
+			if (($child instanceof FunctionNode) && $child->name() == 'else') {
+				if ($else_index !== -1) {
+					throw new TemplateCompileError('More than one else function specified.');
+				}
+				$else_index = $i;
+			}
+		}
+
+		$args = $node->arguments();
+		if (count($args) != 1 || !isset($args['condition'])) {
+			throw new TemplateCompileError('Template function if requires exactly 1 argument named condition.');
+		}
+
+		$true_children = $children;
+		$false_children = [];
+		if ($else_index !== -1) {
+			$true_children = array_slice($children, 0, $else_index);
+			$false_children = array_slice($children, $else_index + 1);
+		}
+
+		$true_branch = $this->_compileBranch($compiler, $true_children);
+		$false_branch = $this->_compileBranch($compiler, $false_children);
+
+		return ' ((' . $args['condition']->value() . ') ? (' . $true_branch . ') : (' . $false_branch . ')) ';
+	}
+
+	private function _compileBranch($compiler, $children)
+	{
+		if (empty($children)) {
+			return "''";
+		}
+
+		$output = [];
+		foreach ($children as $child) {
+			$output[] = $compiler->compileNode($child);
+		}
+		return implode(' . ', $output);
+	}
+}
+
+class PhraseFunction implements TemplateFunction
+{
+	public function name() { return 'p'; }
+
+	public function compile(Compiler $compiler, FunctionNode $node)
+	{
+		$children = $node->children();
+		if (sizeof($node->children()) != 1) {
+			throw new TemplateCompileError("Template function p requires exactly 1 child.");
+		}
+		if (!($children[0] instanceof ContentNode)) {
+			throw new TemplateCompileError("Template function p only suports content children.");
+		}
+
+		$content = $compiler->compileNode($children[0]);
+		$string = " T($content) ";
+
+		$args = $node->arguments();
+		if (empty($args)) {
+			return $string;
+		}
+
+		$args = $this->_compileArgs($compiler, $args);
+
+		return " sprintf($string, " . implode(', ', $args) . ') ';
+	}
+
+	private function _compileArgs(Compiler $compiler, $args)
+	{
+		for ($i = 1; $i <= sizeof($args); ++$i) {
+			if (!isset($args[$i])) {
+				throw new TemplateCompileError("Template function p missing argument $i.");
+			}
+		}
+		sort($args);
+
+		$compiled = [];
+		foreach ($args as $arg) {
+			$compiled[] = '"' . $compiler->doubleQuoteEscape($arg->value()) . '"';
+		}
+
+		return $compiled;
+	}
+}
+
+interface TemplateFunction
+{
+	public function name();
+
+	public function compile(Compiler $compiler, FunctionNode $node);
+}
+
+class Token
+{
+	/** @var int The TOKEN_ type. */
+	public $type;
+
+	/** @var any The value/content of the token. Type dependent. */
+	public $value;
+
+	public function __construct($type, $value=NULL)
+	{
+		$this->type = $type;
+		$this->value = $value;
+	}
+}
+
+abstract class Node
+{
+	private $_children = [];
+
+	public function appendChild(Node $child)
+	{
+		$this->_children[] = $child;
+	}
+
+	public function children() { return $this->_children; }
+}
+
+class TemplateNode extends Node {}
+
+class ContentNode extends Node
+{
+	private $_value;
+
+	public function __construct($value)
+	{
+		$this->_value = $value;
+	}
+
+	public function value() { return $this->_value; }
+
+	public function appendChild(Node $child)
+	{
+		throw new TemplateLexError('ContentNodes cannot have children.');
+	}
+}
+
+class FunctionNode extends Node
+{
+	private $_name;
+	private $_args = [];
+
+	public function setName($name)
+	{
+		if ($this->_name !== NULL) {
+			throw new TemplateLexError('Name already set.');
+		}
+		$this->_name = $name;
+	}
+	public function name() { return $this->_name; }
+
+	public function addArgument(ArgumentNode $arg)
+	{
+		if (isset($this->_args[$arg->key()])) {
+			throw new TemplateLexError("Duplicate argument '{$arg->key()}' in function '{$this->_name}'.");
+		}
+		$this->_args[$arg->key()] = $arg;
+	}
+	public function arguments() { return $this->_args; }
+}
+
+class ArgumentNode extends Node
+{
+	private $_key;
+	private $_value;
+
+	public function setKey($key)
+	{
+		if ($this->_key !== NULL) {
+			throw new TemplateLexError('Key already set.');
+		}
+		$this->_key = $key;
+	}
+	public function key() { return $this->_key; }
+
+	public function setValue($value)
+	{
+		if ($this->_value !== NULL) {
+			throw new TemplateLexError('Value already set.');
+		}
+		$this->_value = $value;
+	}
+	public function value() { return $this->_value; }
+}
diff --git a/includes/class_template_test.php b/includes/class_template_test.php
new file mode 100644
index 0000000..1794ccd
--- /dev/null
+++ b/includes/class_template_test.php
@@ -0,0 +1,47 @@
+<?php
+/*=====================================================================*\
+|| ###################################################################
+|| # Bugdar
+|| # Copyright (c)2020 Blue Static
+|| #
+|| # 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; version 2 of the License.
+|| #
+|| # 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 General Public License for
+|| # more details.
+|| #
+|| # You should have received a copy of the GNU General Public License along
+|| # with this program; if not, write to the Free Software Foundation, Inc.,
+|| # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
+|| ###################################################################
+\*=====================================================================*/
+
+require_once('./class_template.php');
+
+echo '<pre>';
+
+$parser = new Bugdar\Template\Parser(
+	'Hello world <t:if condition="moo">true<t:else/>false <t:p 1="Robert">Hello again %1$s</t:p></t:if>' .
+	' <t:v as="int">moo</t:v> ' .
+	'Test <t:p 1="Moo" 2="Another value  ">%1$s %2$s</t:p>'
+);
+$tokens = $parser->parse();
+var_dump($parser);
+
+$lexer = new Bugdar\Template\Lexer($tokens);
+$ast = $lexer->lex();
+print_r($ast);
+
+$compiler = new Bugdar\Template\Compiler();
+$compiler->registerDefaults();
+$template = $compiler->compile($ast);
+var_dump($template);
+
+function T($x) { return $x; }
+
+$tpl = new Bugdar\Template\Template('demo', $template);
+$tpl->set('moo', 'Injected Variable!<b>bold</b>');
+var_dump($tpl->execute());
-- 
2.43.5