feat: implement filter pipeline, built-in filters, and AST caching (Phase 6)

This commit is contained in:
Funky Waddle 2026-02-11 23:42:20 -06:00
parent 439e4b99fb
commit 9697400c0c
19 changed files with 1041 additions and 0 deletions

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
use NumberFormatter;
/**
* Class CurrencyFilter
*
* Converts a numeric value into a formatted currency string.
*
* @package Scape\Filters
*/
class CurrencyFilter implements FilterInterface
{
/**
* @param mixed $value The numeric value to format.
* @param array $args [0 => string $currencyCode] (e.g., 'USD', 'EUR').
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$currencyCode = $args[0] ?? 'USD';
// Ensure value is numeric
if (!is_numeric($value)) {
return $value;
}
$formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
return $formatter->formatCurrency((float)$value, (string)$currencyCode);
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
use DateTime;
use Exception;
/**
* Class DateFilter
*
* Formats a timestamp or date string.
*
* @package Scape\Filters
*/
class DateFilter implements FilterInterface
{
/**
* @param mixed $value The date value (timestamp, string, or DateTime).
* @param array $args [0 => string $format] (e.g., 'Y-m-d').
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$format = $args[0] ?? 'Y-m-d H:i:s';
if ($value === null || $value === '') {
return $value;
}
try {
if (is_numeric($value)) {
$date = new DateTime();
$date->setTimestamp((int)$value);
} elseif (is_string($value)) {
$date = new DateTime($value);
} elseif ($value instanceof DateTime) {
$date = $value;
} else {
return $value;
}
return $date->format($format);
} catch (Exception) {
return $value;
}
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class DefaultFilter
*
* Provides a fallback value if the input is empty.
*
* @package Scape\Filters
*/
class DefaultFilter implements FilterInterface
{
/**
* @param mixed $value The value to check.
* @param array $args [0 => mixed $default]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$default = $args[0] ?? '';
if ($value === null || $value === '' || (is_array($value) && empty($value))) {
return $default;
}
return $value;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class FirstFilter
*
* Returns the first element of a collection.
*
* @package Scape\Filters
*/
class FirstFilter implements FilterInterface
{
/**
* @param mixed $value The collection.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
if (is_iterable($value)) {
foreach ($value as $item) {
return $item;
}
}
return null;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class FloatFilter
*
* Formats a numeric value as a string with a fixed number of decimal places.
*
* @package Scape\Filters
*/
class FloatFilter implements FilterInterface
{
/**
* @param mixed $value The numeric value to format.
* @param array $args [0 => int $precision]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$precision = isset($args[0]) ? (int)$args[0] : 2;
if (!is_numeric($value)) {
return $value;
}
return number_format((float)$value, $precision, '.', ',');
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class JoinFilter
*
* Joins array elements with a glue string.
*
* @package Scape\Filters
*/
class JoinFilter implements FilterInterface
{
/**
* @param mixed $value The array to join.
* @param array $args [0 => string $glue]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$glue = $args[0] ?? '';
if (is_iterable($value)) {
$array = is_array($value) ? $value : iterator_to_array($value);
return implode((string)$glue, $array);
}
return $value;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class JsonFilter
*
* Encodes a value into a JSON string.
*
* @package Scape\Filters
*/
class JsonFilter implements FilterInterface
{
/**
* @param mixed $value The value to encode.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
return json_encode($value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class KeysFilter
*
* Returns the keys of an associative array.
*
* @package Scape\Filters
*/
class KeysFilter implements FilterInterface
{
/**
* @param mixed $value The array/collection.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
if (is_iterable($value)) {
$array = is_array($value) ? $value : iterator_to_array($value);
return array_keys($array);
}
return [];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class LastFilter
*
* Returns the last element of a collection.
*
* @package Scape\Filters
*/
class LastFilter implements FilterInterface
{
/**
* @param mixed $value The collection.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
if (is_iterable($value)) {
$array = is_array($value) ? $value : iterator_to_array($value);
if (empty($array)) {
return null;
}
return end($array);
}
return null;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
class LowerFilter implements FilterInterface
{
public function transform(mixed $value, array $args = []): mixed
{
return strtolower((string)$value);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class TruncateFilter
*
* Truncates a string to a specified length.
*
* @package Scape\Filters
*/
class TruncateFilter implements FilterInterface
{
/**
* @param mixed $value The string to truncate.
* @param array $args [0 => int $length, 1 => string $suffix]
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
$length = (int)($args[0] ?? 100);
$suffix = $args[1] ?? '...';
$str = (string)$value;
if (mb_strlen($str) <= $length) {
return $str;
}
return mb_substr($str, 0, $length) . $suffix;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
class UcfirstFilter implements FilterInterface
{
public function transform(mixed $value, array $args = []): mixed
{
return ucfirst((string)$value);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
class UpperFilter implements FilterInterface
{
public function transform(mixed $value, array $args = []): mixed
{
return strtoupper((string)$value);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class UrlEncodeFilter
*
* URL encodes a string.
*
* @package Scape\Filters
*/
class UrlEncodeFilter implements FilterInterface
{
/**
* @param mixed $value The string to encode.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
return urlencode((string)$value);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Scape\Filters;
use Scape\Interfaces\FilterInterface;
/**
* Class WordCountFilter
*
* Counts words in a string.
*
* @package Scape\Filters
*/
class WordCountFilter implements FilterInterface
{
/**
* @param mixed $value The string to count words in.
* @param array $args Not used.
* @return mixed
*/
public function transform(mixed $value, array $args = []): mixed
{
return str_word_count((string)$value);
}
}

View file

@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace Scape\Interpreter;
use Scape\Config;
use Scape\Engine;
use Scape\Exceptions\PropertyNotFoundException;
/**
* Class ValueResolver
*
* Handles resolving variable paths (dot-notation and bracket-notation) against a data context.
*
* @package Scape\Interpreter
*/
class ValueResolver
{
public function __construct(
private readonly Config $config,
private readonly ?Engine $engine = null
) {
}
public function resolve(string $expression, array $context): mixed
{
if (empty($expression)) {
return null;
}
// Split expression by pipe symbol, respecting quotes
$parts = $this->splitByPipe($expression);
$path = trim(array_shift($parts));
$value = $this->resolvePath($path, $context);
foreach ($parts as $filterExpression) {
$value = $this->applyFilter($value, $filterExpression, $context);
}
return $value;
}
/**
* Splits a string by pipes, but only if they are not inside quotes.
*
* @param string $expression
* @return array
*/
private function splitByPipe(string $expression): array
{
$parts = [];
$currentPart = '';
$inQuote = false;
$quoteChar = '';
$length = strlen($expression);
for ($i = 0; $i < $length; $i++) {
$char = $expression[$i];
if (($char === "'" || $char === '"') && ($i === 0 || $expression[$i - 1] !== '\\')) {
if (!$inQuote) {
$inQuote = true;
$quoteChar = $char;
} elseif ($char === $quoteChar) {
$inQuote = false;
}
}
if ($char === '|' && !$inQuote) {
$parts[] = $currentPart;
$currentPart = '';
} else {
$currentPart .= $char;
}
}
$parts[] = $currentPart;
return $parts;
}
/**
* Applies a filter to a value.
*
* @param mixed $value
* @param string $filterExpression
* @param array $context
* @return mixed
*/
private function applyFilter(mixed $value, string $filterExpression, array $context): mixed
{
$filterExpression = trim($filterExpression);
if ($filterExpression === '') {
return $value;
}
if (preg_match('/^([\w:]+)(?:\((.*)\))?$/', $filterExpression, $matches)) {
$filterName = $matches[1];
// Special case for 'raw' filter which is handled by VariableNode
if ($filterName === 'raw') {
return $value;
}
$argsString = $matches[2] ?? '';
$args = [];
if ($argsString !== '') {
preg_match_all('/\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*"|[^,]+/', $argsString, $argMatches);
foreach ($argMatches[0] as $match) {
$match = trim($match);
if (str_starts_with($match, ',')) {
$match = ltrim(substr($match, 1));
}
if (str_ends_with($match, ',')) {
$match = rtrim(substr($match, 0, -1));
}
$match = trim($match);
if ($match !== '') {
// Resolve argument
if ((str_starts_with($match, "'") && str_ends_with($match, "'")) ||
(str_starts_with($match, '"') && str_ends_with($match, '"'))) {
$args[] = substr($match, 1, -1);
} elseif (is_numeric($match)) {
$args[] = $match + 0;
} else {
$args[] = $this->resolve($match, $context);
}
}
}
}
if ($this->engine !== null) {
try {
$filter = $this->engine->getFilter($filterName);
return $filter->transform($value, $args);
} catch (\Scape\Exceptions\FilterNotFoundException) {
// Fallback or ignore if filter not found?
// Usually Engine::getFilter throws, but maybe we should fail silently in production?
// For now, let it throw to maintain Excellence.
throw new \Scape\Exceptions\FilterNotFoundException("Filter '{$filterName}' not found.");
}
}
}
return $value;
}
/**
* Resolves a path against the given context.
*
* @param string $path The path to resolve (e.g., 'user.name' or 'items[0]').
* @param array $context The data context.
*
* @return mixed The resolved value.
*/
private function resolvePath(string $path, array $context): mixed
{
if (empty($path)) {
return null;
}
// Handle special host namespace
if (str_starts_with($path, 'host.')) {
if ($this->engine === null) {
return null;
}
$provider = $this->engine->getHostProvider();
if ($provider === null) {
return null;
}
$expression = substr($path, 5); // remove 'host.'
// Handle method-like call: host.translate('key')
if (preg_match('/^(\w+)\((.*)\)$/', $expression, $matches)) {
$method = $matches[1];
$argsString = $matches[2];
$args = [];
if ($argsString !== '') {
// Simple argument parsing for now
$rawArgs = array_map('trim', explode(',', $argsString));
foreach ($rawArgs as $arg) {
if ((str_starts_with($arg, "'") && str_ends_with($arg, "'")) ||
(str_starts_with($arg, '"') && str_ends_with($arg, '"'))) {
$args[] = substr($arg, 1, -1);
} elseif (is_numeric($arg)) {
$args[] = $arg + 0;
} else {
$args[] = $this->resolve($arg, $context);
}
}
}
if ($provider->has($method)) {
return $provider->call($method, $args);
}
}
// Handle property-like access: host.version
if ($provider->has($expression)) {
return $provider->call($expression);
}
return null;
}
// Split path into parts, preserving bracket content
$parts = $this->parsePath($path);
$current = $context;
foreach ($parts as $part) {
if ($this->isBracket($part)) {
$key = $this->extractBracketKey($part, $context);
if ($this->hasKey($current, $key)) {
$current = $this->getValue($current, $key);
} else {
return $this->handleMissing($path, $key);
}
} else {
if ($this->hasKey($current, $part)) {
$current = $this->getValue($current, $part);
} else {
return $this->handleMissing($path, $part);
}
}
}
return $current;
}
private function parsePath(string $path): array
{
// Simple regex to split by dot or bracket
// e.g. user.items[0]['title'] -> ['user', 'items', '[0]', "['title']"]
preg_match_all('/[^.\[\]]+|\[[^\]]+\]/', $path, $matches);
return $matches[0];
}
private function isBracket(string $part): bool
{
return str_starts_with($part, '[');
}
private function extractBracketKey(string $part, array $context): string|int
{
$inner = trim($part, '[]');
// Check if it's a string literal 'key' or "key"
if ((str_starts_with($inner, "'") && str_ends_with($inner, "'")) ||
(str_starts_with($inner, '"') && str_ends_with($inner, '"'))) {
return substr($inner, 1, -1);
}
// Check if it's numeric
if (is_numeric($inner)) {
return (int)$inner;
}
// Otherwise it's a variable reference inside brackets (e.g. items[index])
// Recursively resolve it
return (string)$this->resolve($inner, $context);
}
private function hasKey(mixed $container, string|int $key): bool
{
if (is_array($container)) {
return array_key_exists($key, $container);
}
if (is_object($container)) {
return property_exists($container, (string)$key) || isset($container->{$key});
}
return false;
}
private function getValue(mixed $container, string|int $key): mixed
{
if (is_array($container)) {
return $container[$key];
}
if (is_object($container)) {
return $container->{$key};
}
return null;
}
private function handleMissing(string $fullPath, string|int $key): mixed
{
if ($this->config->isDebug()) {
throw new PropertyNotFoundException("Property '{$key}' not found in path '{$fullPath}'.");
}
return null;
}
}

102
tests/CacheTest.php Normal file
View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Engine;
class CacheTest extends TestCase
{
private string $templatesDir;
private string $cacheDir;
protected function setUp(): void
{
$this->templatesDir = __DIR__ . '/fixtures';
$this->cacheDir = __DIR__ . '/../.scape/cache_test';
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0777, true);
}
}
protected function tearDown(): void
{
$this->rmdirRecursive($this->cacheDir);
}
private function rmdirRecursive(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->rmdirRecursive($path) : unlink($path);
}
rmdir($dir);
}
public function testCreatesCacheFile(): void
{
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'cache_dir' => $this->cacheDir,
'mode' => 'production'
]);
$templatePath = $this->templatesDir . '/tests/simple.scape.php';
$cacheKey = md5($templatePath);
$cacheFile = $this->cacheDir . '/' . $cacheKey . '.ast';
$this->assertFileDoesNotExist($cacheFile);
$engine->render('tests.simple', ['name' => 'Funky', 'items' => []]);
$this->assertFileExists($cacheFile);
}
public function testUsesCacheInProduction(): void
{
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'cache_dir' => $this->cacheDir,
'mode' => 'production'
]);
$engine->render('tests.simple', ['name' => 'First', 'items' => []]);
// Modify template file but production should still use old cache
$templatePath = $this->templatesDir . '/tests/simple.scape.php';
$originalContent = file_get_contents($templatePath);
file_put_contents($templatePath, 'Modified Content');
$output = $engine->render('tests.simple', ['name' => 'Second', 'items' => []]);
$this->assertStringContainsString('Hello Second!', $output); // simple.scape.php has "Hello {{ name }}!"
// Restore
file_put_contents($templatePath, $originalContent);
}
public function testInvalidatesCacheInDebug(): void
{
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'cache_dir' => $this->cacheDir,
'mode' => 'debug'
]);
$templatePath = $this->templatesDir . '/tests/cache_debug.scape.php';
file_put_contents($templatePath, 'Original');
$engine->render('tests.cache_debug');
// Sleep to ensure mtime difference
sleep(1);
file_put_contents($templatePath, 'Modified');
$output = $engine->render('tests.cache_debug');
$this->assertEquals('Modified', $output);
unlink($templatePath);
}
}

144
tests/FilterTest.php Normal file
View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Engine;
class FilterTest extends TestCase
{
private string $templatesDir;
private string $filtersDir;
protected function setUp(): void
{
$this->templatesDir = __DIR__ . '/fixtures';
$this->filtersDir = __DIR__ . '/fixtures/filters';
}
public function testInternalFilters(): void
{
file_put_contents($this->templatesDir . '/tests/filters_internal.scape.php',
'{( uses filters:string )}' . "\n" .
'{{ name | lower }}' . "\n" .
'{{ name | upper }}' . "\n" .
'{{ name | lower | ucfirst }}' . "\n" .
'{{ price | currency(\'USD\') }}' . "\n" .
'{{ val | float(3) }}' . "\n" .
'{{ date_val | date(\'Y-m-d\') }}' . "\n" .
'{{ bio | truncate(10) }}' . "\n" .
'{{ missing | default(\'N/A\') }}' . "\n" .
'{{ tags | join(\', \') }}' . "\n" .
'{{ tags | first }}' . "\n" .
'{{ tags | last }}' . "\n" .
'{{ bio | word_count }}' . "\n" .
'{{ user_data | keys | join(\',\') }}' . "\n" .
'{{ query | url_encode }}' . "\n" .
'{{{ user_data | json }}}'
);
$engine = new Engine(['templates_dir' => $this->templatesDir]);
$output = $engine->render('tests.filters_internal', [
'name' => 'fUnKy',
'price' => 1234.56,
'val' => 123.45678,
'date_val' => 1707693300,
'bio' => 'A very long biography indeed.',
'missing' => '',
'tags' => ['PHP', 'Scape', 'Templates'],
'user_data' => ['id' => 1, 'name' => 'Funky'],
'query' => 'hello world'
]);
$lines = explode("\n", $output);
$this->assertEquals('funky', $lines[0]);
$this->assertEquals('FUNKY', $lines[1]);
$this->assertEquals('Funky', $lines[2]);
$this->assertEquals('$1,234.56', $lines[3]);
$this->assertEquals('123.457', $lines[4]);
$this->assertStringContainsString('2024-02-11', $output);
$this->assertStringContainsString('A very lon...', $output);
$this->assertStringContainsString('N/A', $output);
$this->assertStringContainsString('PHP, Scape, Templates', $output);
$this->assertStringContainsString('PHP', $output);
$this->assertStringContainsString('Templates', $output);
$this->assertStringContainsString('5', $output);
$this->assertStringContainsString('id,name', $output);
$this->assertStringContainsString('hello+world', $output);
$this->assertStringContainsString('{"id":1,"name":"Funky"}', $output);
unlink($this->templatesDir . '/tests/filters_internal.scape.php');
}
public function testCustomFilterWithArguments(): void
{
file_put_contents($this->templatesDir . '/tests/filters_custom.scape.php',
'{( load_filter(\'currency\') )}' . "\n" .
'{{ price | currency }}' . "\n" .
'{{ price | currency(\'£\') }}' . "\n" .
'{{ price | currency(sym) }}'
);
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'filters_dir' => $this->filtersDir
]);
$output = $engine->render('tests.filters_custom', [
'price' => 1234.567,
'sym' => '€'
]);
$expected = "$1,234.57\n£1,234.57\n€1,234.57";
$this->assertEquals($expected, $output);
unlink($this->templatesDir . '/tests/filters_custom.scape.php');
}
public function testFiltersInForeach(): void
{
file_put_contents($this->templatesDir . '/tests/filters_foreach.scape.php',
'{( uses filters:string )}' . "\n" .
'{( foreach key in user_data | keys )}' . "\n" .
'{{ key }}: {{ user_data[key] }}' . "\n" .
'{( endforeach )}'
);
$engine = new Engine(['templates_dir' => $this->templatesDir]);
$output = $engine->render('tests.filters_foreach', [
'user_data' => ['id' => 1, 'name' => 'Funky']
]);
$this->assertStringContainsString('id: 1', $output);
$this->assertStringContainsString('name: Funky', $output);
unlink($this->templatesDir . '/tests/filters_foreach.scape.php');
}
public function testFiltersInInclude(): void
{
file_put_contents($this->templatesDir . '/partials/keys_list.scape.php',
'Keys: {{ context | join(\', \') }}'
);
file_put_contents($this->templatesDir . '/tests/filters_include.scape.php',
'{( uses filters:string )}' . "\n" .
'{[ include \'keys_list\' with user_data | keys ]}'
);
$engine = new Engine([
'templates_dir' => $this->templatesDir,
'partials_dir' => $this->templatesDir . '/partials'
]);
$output = $engine->render('tests.filters_include', [
'user_data' => ['id' => 1, 'name' => 'Funky']
]);
$this->assertEquals('Keys: id, name', trim($output));
unlink($this->templatesDir . '/partials/keys_list.scape.php');
unlink($this->templatesDir . '/tests/filters_include.scape.php');
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Engine;
use Scape\Interfaces\HostProviderInterface;
class HostProviderTest extends TestCase
{
public function testHostNamespaceDelegation(): void
{
$mockProvider = new class implements HostProviderInterface {
public function has(string $name): bool {
return in_array($name, ['version', 'translate']);
}
public function call(string $name, array $args = []): mixed {
if ($name === 'version') return '1.0.0';
if ($name === 'translate') {
$key = $args[0] ?? '';
$lang = $args[1] ?? 'en';
return "Translated '$key' to $lang";
}
return null;
}
};
$engine = new Engine();
$engine->registerHostProvider($mockProvider);
$templatesDir = __DIR__ . '/../templates';
file_put_contents($templatesDir . '/tests/host_test.scape.php',
'Version: {{ host.version }}' . "\n" .
'i18n: {{ host.translate(\'welcome\', \'fr\') }}' . "\n" .
'Var arg: {{ host.translate(my_key) }}'
);
$output = $engine->render('tests.host_test', ['my_key' => 'hello']);
$this->assertStringContainsString('Version: 1.0.0', $output);
$this->assertStringContainsString('i18n: Translated &apos;welcome&apos; to fr', $output);
$this->assertStringContainsString('Var arg: Translated &apos;hello&apos; to en', $output);
unlink($templatesDir . '/tests/host_test.scape.php');
}
}