feat: implement filter pipeline, built-in filters, and AST caching (Phase 6)
This commit is contained in:
parent
439e4b99fb
commit
9697400c0c
36
src/Filters/CurrencyFilter.php
Normal file
36
src/Filters/CurrencyFilter.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/Filters/DateFilter.php
Normal file
50
src/Filters/DateFilter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Filters/DefaultFilter.php
Normal file
33
src/Filters/DefaultFilter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Filters/FirstFilter.php
Normal file
33
src/Filters/FirstFilter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Filters/FloatFilter.php
Normal file
33
src/Filters/FloatFilter.php
Normal 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, '.', ',');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Filters/JoinFilter.php
Normal file
34
src/Filters/JoinFilter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Filters/JsonFilter.php
Normal file
27
src/Filters/JsonFilter.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Filters/KeysFilter.php
Normal file
32
src/Filters/KeysFilter.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Filters/LastFilter.php
Normal file
35
src/Filters/LastFilter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Filters/LowerFilter.php
Normal file
15
src/Filters/LowerFilter.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Filters/TruncateFilter.php
Normal file
35
src/Filters/TruncateFilter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Filters/UcfirstFilter.php
Normal file
15
src/Filters/UcfirstFilter.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Filters/UpperFilter.php
Normal file
15
src/Filters/UpperFilter.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Filters/UrlEncodeFilter.php
Normal file
27
src/Filters/UrlEncodeFilter.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Filters/WordCountFilter.php
Normal file
27
src/Filters/WordCountFilter.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/Interpreter/ValueResolver.php
Normal file
300
src/Interpreter/ValueResolver.php
Normal 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
102
tests/CacheTest.php
Normal 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
144
tests/FilterTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/HostProviderTest.php
Normal file
48
tests/HostProviderTest.php
Normal 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 'welcome' to fr', $output);
|
||||||
|
$this->assertStringContainsString('Var arg: Translated 'hello' to en', $output);
|
||||||
|
|
||||||
|
unlink($templatesDir . '/tests/host_test.scape.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue