diff --git a/src/Filters/CurrencyFilter.php b/src/Filters/CurrencyFilter.php new file mode 100644 index 0000000..2711007 --- /dev/null +++ b/src/Filters/CurrencyFilter.php @@ -0,0 +1,36 @@ + 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); + } +} diff --git a/src/Filters/DateFilter.php b/src/Filters/DateFilter.php new file mode 100644 index 0000000..df893c0 --- /dev/null +++ b/src/Filters/DateFilter.php @@ -0,0 +1,50 @@ + 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; + } + } +} diff --git a/src/Filters/DefaultFilter.php b/src/Filters/DefaultFilter.php new file mode 100644 index 0000000..21fe123 --- /dev/null +++ b/src/Filters/DefaultFilter.php @@ -0,0 +1,33 @@ + 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; + } +} diff --git a/src/Filters/FirstFilter.php b/src/Filters/FirstFilter.php new file mode 100644 index 0000000..1bcb6b3 --- /dev/null +++ b/src/Filters/FirstFilter.php @@ -0,0 +1,33 @@ + 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, '.', ','); + } +} diff --git a/src/Filters/JoinFilter.php b/src/Filters/JoinFilter.php new file mode 100644 index 0000000..e4d2eca --- /dev/null +++ b/src/Filters/JoinFilter.php @@ -0,0 +1,34 @@ + 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; + } +} diff --git a/src/Filters/JsonFilter.php b/src/Filters/JsonFilter.php new file mode 100644 index 0000000..6c673be --- /dev/null +++ b/src/Filters/JsonFilter.php @@ -0,0 +1,27 @@ + 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; + } +} diff --git a/src/Filters/UcfirstFilter.php b/src/Filters/UcfirstFilter.php new file mode 100644 index 0000000..1034a73 --- /dev/null +++ b/src/Filters/UcfirstFilter.php @@ -0,0 +1,15 @@ +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; + } +} diff --git a/tests/CacheTest.php b/tests/CacheTest.php new file mode 100644 index 0000000..d96b672 --- /dev/null +++ b/tests/CacheTest.php @@ -0,0 +1,102 @@ +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); + } +} diff --git a/tests/FilterTest.php b/tests/FilterTest.php new file mode 100644 index 0000000..952c5a6 --- /dev/null +++ b/tests/FilterTest.php @@ -0,0 +1,144 @@ +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'); + } +} diff --git a/tests/HostProviderTest.php b/tests/HostProviderTest.php new file mode 100644 index 0000000..aae0483 --- /dev/null +++ b/tests/HostProviderTest.php @@ -0,0 +1,48 @@ +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'); + } +}