From 7537b5a2df4dd04634f8f21a884dc22a04ff8167 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:48:25 +0000 Subject: [PATCH 1/6] fix: harden template path and render pipeline --- src/Core/View/Engine.php | 15 ++++++++----- src/Core/View/Renderer.php | 36 +++++++++++++++++++++++++----- src/Core/View/TemplateResolver.php | 18 ++++++++++++++- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index c9a771e..cb64737 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -88,24 +88,27 @@ public function render(string $templatePath, array $data = []): string return $this->middlewarePipeline->process( ['template' => $templatePath, 'data' => $data], - function (array $context) use ($templatePath, $data, $start): string { + function (array $context) use ($start): string { + $effectiveTemplatePath = isset($context['template']) ? (string) $context['template'] : ''; + $effectiveData = isset($context['data']) && is_array($context['data']) ? $context['data'] : []; + try { - $absolutePath = $this->resolveTemplatePath($templatePath); + $absolutePath = $this->resolveTemplatePath($effectiveTemplatePath); if (!is_file($absolutePath)) { - throw new ViewException("Template not found: {$templatePath}"); + throw new ViewException("Template not found: {$effectiveTemplatePath}"); } - $compiledCode = $this->compileTemplate($absolutePath, $templatePath); + $compiledCode = $this->compileTemplate($absolutePath, $effectiveTemplatePath); - $runtimeData = array_merge($this->environment->getGlobals(), $data); + $runtimeData = array_merge($this->environment->getGlobals(), $effectiveData); foreach ($runtimeData as $name => $value) { $this->scopeStack->set((string) $name, $value); } return $this->renderer->render($compiledCode, $runtimeData, $this); } catch (\Throwable $e) { - throw new ViewException("Error rendering template '{$templatePath}': " . $e->getMessage(), 0, $e); + throw new ViewException("Error rendering template '{$effectiveTemplatePath}': " . $e->getMessage(), 0, $e); } } ); diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 8e08c1a..d1f776f 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -39,25 +39,51 @@ private function writeCompiledFile(string $compiledCode, ?Engine $engine): strin $targetDir ??= sys_get_temp_dir(); - if (!is_dir($targetDir)) { - mkdir($targetDir, 0755, true); + if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) { + throw new RuntimeException('Failed to create compiled templates directory: ' . $targetDir); } $file = rtrim($targetDir, '/\\') . '/tpl_' . md5($compiledCode) . '.php'; - file_put_contents($file, $compiledCode); + if (file_put_contents($file, $compiledCode, LOCK_EX) === false) { + throw new RuntimeException('Failed to write compiled template file: ' . $file); + } return $file; } private function assertValidPhp(string $compiledFile): void { - $phpBinary = PHP_BINARY ?: 'php'; + $phpBinary = $this->resolvePhpBinary(); $output = []; $status = 0; - @exec(escapeshellcmd($phpBinary) . ' -l ' . escapeshellarg($compiledFile) . ' 2>&1', $output, $status); + exec(escapeshellarg($phpBinary) . ' -l ' . escapeshellarg($compiledFile) . ' 2>&1', $output, $status); if ($status !== 0) { throw new SyntaxException('Compiled template syntax error: ' . implode("\n", $output)); } } + + private function resolvePhpBinary(): string + { + $candidates = []; + + if (defined('PHP_BINARY') && is_string(PHP_BINARY) && PHP_BINARY !== '') { + $candidates[] = PHP_BINARY; + } + + if (defined('PHP_BINDIR') && is_string(PHP_BINDIR) && PHP_BINDIR !== '') { + $candidates[] = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php'; + if (DIRECTORY_SEPARATOR === '\\') { + $candidates[] = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php.exe'; + } + } + + foreach ($candidates as $candidate) { + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + return 'php'; + } } diff --git a/src/Core/View/TemplateResolver.php b/src/Core/View/TemplateResolver.php index 0329343..1d9c3cf 100644 --- a/src/Core/View/TemplateResolver.php +++ b/src/Core/View/TemplateResolver.php @@ -12,6 +12,10 @@ public function __construct(private string $templatesDir) public function resolve(string $path): string { + if (str_contains($path, "\0")) { + throw new ViewException('Template path contains invalid null byte.'); + } + if (str_starts_with($path, '@components/')) { $path = 'components/' . substr($path, strlen('@components/')); } @@ -32,10 +36,22 @@ public function resolve(string $path): string return $candidate; } - if (!str_starts_with($realCandidate, $realTemplateDir)) { + if (!$this->isPathInsideTemplatesDir($realCandidate, $realTemplateDir)) { throw new ViewException('Template path traversal is not allowed: ' . $path); } return $realCandidate; } + + private function isPathInsideTemplatesDir(string $realCandidate, string $realTemplateDir): bool + { + $normalizedTemplateDir = rtrim($realTemplateDir, DIRECTORY_SEPARATOR); + $normalizedCandidate = rtrim($realCandidate, DIRECTORY_SEPARATOR); + + if ($normalizedCandidate === $normalizedTemplateDir) { + return true; + } + + return str_starts_with($realCandidate, $normalizedTemplateDir . DIRECTORY_SEPARATOR); + } } From 73813d609f881be1693c2fb8b4b089a10767746d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:05:42 +0000 Subject: [PATCH 2/6] fix: remove Beobles namespace prefix + round-2 bug fixes --- README.md | 2 +- autoload.php | 2 +- examples/index.php | 2 +- src/Core/View/Abstract/AbstractDirective.php | 2 +- src/Core/View/Abstract/AbstractFilter.php | 2 +- src/Core/View/Abstract/AbstractMiddleware.php | 2 +- src/Core/View/Abstract/AbstractNode.php | 6 +-- src/Core/View/Cache/CacheInterface.php | 2 +- src/Core/View/Cache/CacheKey.php | 5 ++- src/Core/View/Cache/CacheManager.php | 2 +- src/Core/View/Cache/FileCacheAdapter.php | 2 +- src/Core/View/Cache/FileWatcher.php | 2 +- src/Core/View/Compiler.php | 24 +++++----- .../View/Components/ComponentRegistry.php | 4 +- src/Core/View/Debug/ErrorFormatter.php | 2 +- src/Core/View/Debug/RuntimeContext.php | 2 +- src/Core/View/Debug/SourceMap.php | 2 +- src/Core/View/Debug/TemplateDebugger.php | 2 +- src/Core/View/Directives/BlockDirective.php | 4 +- .../View/Directives/DirectiveRegistry.php | 4 +- src/Core/View/Directives/ForeachDirective.php | 4 +- src/Core/View/Directives/IfDirective.php | 4 +- src/Core/View/Directives/IncludeDirective.php | 4 +- src/Core/View/Directives/SetDirective.php | 4 +- src/Core/View/Engine.php | 44 ++++++++++--------- src/Core/View/Environment.php | 2 +- src/Core/View/Escape/Escaper.php | 2 +- src/Core/View/Escape/HtmlEscaper.php | 2 +- src/Core/View/Escape/JavaScriptEscaper.php | 6 +-- src/Core/View/Escape/UriEscaper.php | 2 +- .../Exceptions/CircularReferenceException.php | 2 +- src/Core/View/Exceptions/CompileException.php | 2 +- .../View/Exceptions/CompilerException.php | 2 +- src/Core/View/Exceptions/ParserException.php | 2 +- src/Core/View/Exceptions/RuntimeException.php | 2 +- src/Core/View/Exceptions/SyntaxException.php | 2 +- src/Core/View/Exceptions/ViewException.php | 2 +- src/Core/View/Filters/ArrayFilters.php | 4 +- src/Core/View/Filters/DateFilters.php | 7 ++- src/Core/View/Filters/FilterRegistry.php | 4 +- src/Core/View/Filters/NumberFilters.php | 2 +- src/Core/View/Filters/StringFilters.php | 10 ++--- src/Core/View/Layout/BlockStack.php | 2 +- src/Core/View/Layout/LayoutManager.php | 9 ++-- src/Core/View/Layout/LayoutResolver.php | 2 +- src/Core/View/Lexer.php | 4 +- src/Core/View/Middleware/CacheMiddleware.php | 4 +- .../View/Middleware/MiddlewarePipeline.php | 4 +- .../View/Middleware/ProfilingMiddleware.php | 4 +- .../View/Middleware/SecurityMiddleware.php | 4 +- src/Core/View/Nodes/BlockNode.php | 6 +-- src/Core/View/Nodes/ComponentNode.php | 6 +-- src/Core/View/Nodes/ExpressionNode.php | 6 +-- src/Core/View/Nodes/ForeachNode.php | 6 +-- src/Core/View/Nodes/IfNode.php | 6 +-- src/Core/View/Nodes/IncludeNode.php | 6 +-- src/Core/View/Nodes/NodeInterface.php | 4 +- src/Core/View/Nodes/RawNode.php | 6 +-- src/Core/View/Nodes/SetNode.php | 6 +-- src/Core/View/Nodes/TextNode.php | 6 +-- src/Core/View/Parser.php | 38 ++++++++-------- src/Core/View/Renderer.php | 6 +-- src/Core/View/Scope/Scope.php | 2 +- src/Core/View/Scope/ScopeStack.php | 2 +- src/Core/View/Scope/Variable.php | 2 +- src/Core/View/TemplateResolver.php | 4 +- src/Core/View/Validation/SyntaxValidator.php | 8 ++-- .../View/Validation/TemplateValidator.php | 2 +- 68 files changed, 178 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index 3135a2e..8ebcd3e 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ __DIR__ . '/templates', diff --git a/autoload.php b/autoload.php index 7f0c689..dc788e7 100644 --- a/autoload.php +++ b/autoload.php @@ -6,7 +6,7 @@ */ spl_autoload_register(function ($class) { - $prefix = 'Beobles\\Core\\View\\'; + $prefix = 'Core\\View\\'; $baseDir = __DIR__ . '/src/Core/View/'; if (strpos($class, $prefix) !== 0) { diff --git a/examples/index.php b/examples/index.php index 39253a0..9392a5e 100644 --- a/examples/index.php +++ b/examples/index.php @@ -2,7 +2,7 @@ require_once __DIR__ . '/../autoload.php'; -use Beobles\Core\View\Engine; +use Core\View\Engine; // Criar engine $engine = new Engine([ diff --git a/src/Core/View/Abstract/AbstractDirective.php b/src/Core/View/Abstract/AbstractDirective.php index 6bde27f..529c943 100644 --- a/src/Core/View/Abstract/AbstractDirective.php +++ b/src/Core/View/Abstract/AbstractDirective.php @@ -1,6 +1,6 @@ layoutManager->merge($absolutePath, $source); $this->templateValidator->validate($merged); diff --git a/src/Core/View/Environment.php b/src/Core/View/Environment.php index dc5736a..d0ce098 100644 --- a/src/Core/View/Environment.php +++ b/src/Core/View/Environment.php @@ -1,6 +1,6 @@ ", "&"], - ["\\\\", "\\n", "\\r", "\\t", "\\f", "\\b", "\\\"", "\\'", "\\x3C", "\\x3E", "\\x26"], + ["\\", "\n", "\r", "\t", "\f", "\b", "\"", "'", "<", ">", "&", "/"], + ["\\\\", "\\n", "\\r", "\\t", "\\f", "\\b", "\\\"", "\\'", "\\x3C", "\\x3E", "\\x26", "\\x2F"], $value ); } diff --git a/src/Core/View/Escape/UriEscaper.php b/src/Core/View/Escape/UriEscaper.php index 81a8b59..f6a3973 100644 --- a/src/Core/View/Escape/UriEscaper.php +++ b/src/Core/View/Escape/UriEscaper.php @@ -1,6 +1,6 @@ fn($v) => is_array($v) ? ($v[array_key_first($v)] ?? null) : null, 'last' => fn($v) => is_array($v) ? ($v[array_key_last($v)] ?? null) : null, 'join' => fn($v, $sep = ', ') => is_array($v) ? implode((string) $sep, $v) : (string) $v, - 'json' => fn($v) => json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), + 'json' => fn($v) => json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '', ]; } } diff --git a/src/Core/View/Filters/DateFilters.php b/src/Core/View/Filters/DateFilters.php index 216e721..c3576f3 100644 --- a/src/Core/View/Filters/DateFilters.php +++ b/src/Core/View/Filters/DateFilters.php @@ -1,13 +1,16 @@ fn($v, $format = 'd/m/Y') => date((string) $format, is_numeric($v) ? (int) $v : strtotime((string) $v)), + 'date' => function ($v, $format = 'd/m/Y'): string { + $timestamp = is_numeric($v) ? (int) $v : strtotime((string) $v); + return date((string) $format, $timestamp !== false ? $timestamp : time()); + }, ]; } } diff --git a/src/Core/View/Filters/FilterRegistry.php b/src/Core/View/Filters/FilterRegistry.php index 76c3205..bc34d4d 100644 --- a/src/Core/View/Filters/FilterRegistry.php +++ b/src/Core/View/Filters/FilterRegistry.php @@ -1,8 +1,8 @@ fn($v) => strtolower((string) $v), 'ucfirst' => fn($v) => ucfirst((string) $v), 'trim' => fn($v) => trim((string) $v), - 'truncate' => fn($v, $len = 50, $suffix = '...') => strlen((string) $v) > (int) $len ? substr((string) $v, 0, (int) $len) . $suffix : (string) $v, - 'slug' => function ($v) { + 'truncate' => fn($v, $len = 50, $suffix = '...') => mb_strlen((string) $v) > (int) $len ? mb_substr((string) $v, 0, (int) $len) . $suffix : (string) $v, + 'slug' => function ($v): string { $v = strtolower((string) $v); - $v = preg_replace('/[^a-z0-9]+/', '-', $v); - return trim((string) $v, '-'); + $v = preg_replace('/[^a-z0-9]+/', '-', $v) ?? ''; + return trim($v, '-'); }, ]; } diff --git a/src/Core/View/Layout/BlockStack.php b/src/Core/View/Layout/BlockStack.php index eb8bdc3..f81a602 100644 --- a/src/Core/View/Layout/BlockStack.php +++ b/src/Core/View/Layout/BlockStack.php @@ -1,6 +1,6 @@ layoutResolver->stripExtendsStatement($content); } - $parentContent = (string) file_get_contents($parentPath); + $parentContent = file_get_contents($parentPath); + if ($parentContent === false) { + return $this->layoutResolver->stripExtendsStatement($content); + } $merged = preg_replace_callback( '/(.*?)<\/Block>/si', diff --git a/src/Core/View/Layout/LayoutResolver.php b/src/Core/View/Layout/LayoutResolver.php index 83738e5..34063f1 100644 --- a/src/Core/View/Layout/LayoutResolver.php +++ b/src/Core/View/Layout/LayoutResolver.php @@ -1,6 +1,6 @@ /i', $template); - if ($ifOpen !== $ifClose) { + if ($ifOpen === false || $ifClose === false || $ifOpen !== $ifClose) { throw new SyntaxException('Unbalanced tags in template'); } $foreachOpen = preg_match_all('//i', $template); - if ($foreachOpen !== $foreachClose) { + if ($foreachOpen === false || $foreachClose === false || $foreachOpen !== $foreachClose) { throw new SyntaxException('Unbalanced tags in template'); } } diff --git a/src/Core/View/Validation/TemplateValidator.php b/src/Core/View/Validation/TemplateValidator.php index 6557790..b632c59 100644 --- a/src/Core/View/Validation/TemplateValidator.php +++ b/src/Core/View/Validation/TemplateValidator.php @@ -1,6 +1,6 @@ Date: Tue, 2 Jun 2026 23:19:14 +0000 Subject: [PATCH 3/6] fix: guard PHP-binary resolution and lint-output filtering against Apache mod_php false positives --- src/Core/View/Renderer.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 8453839..581eb89 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -59,7 +59,13 @@ private function assertValidPhp(string $compiledFile): void exec(escapeshellarg($phpBinary) . ' -l ' . escapeshellarg($compiledFile) . ' 2>&1', $output, $status); if ($status !== 0) { - throw new SyntaxException('Compiled template syntax error: ' . implode("\n", $output)); + $syntaxLines = array_filter($output, static function (string $line): bool { + return (bool) preg_match('/(?:Parse|Fatal) error|syntax error|Errors parsing/i', $line); + }); + + if ($syntaxLines !== []) { + throw new SyntaxException('Compiled template syntax error: ' . implode("\n", array_values($syntaxLines))); + } } } @@ -79,7 +85,9 @@ private function resolvePhpBinary(): string } foreach ($candidates as $candidate) { - if (is_file($candidate) && is_executable($candidate)) { + $base = strtolower(basename($candidate)); + $looksLikePhpCli = str_starts_with($base, 'php') && !str_ends_with($base, '.dll'); + if ($looksLikePhpCli && is_file($candidate) && is_executable($candidate)) { return $candidate; } } From a99582db664d4011d7cc43280f75b86f9403ab97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:31:09 +0000 Subject: [PATCH 4/6] fix: make string filters multibyte-safe and improve slug transliteration --- src/Core/View/Filters/StringFilters.php | 47 ++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/Core/View/Filters/StringFilters.php b/src/Core/View/Filters/StringFilters.php index 6cf7db3..c21b7d0 100644 --- a/src/Core/View/Filters/StringFilters.php +++ b/src/Core/View/Filters/StringFilters.php @@ -7,16 +7,55 @@ class StringFilters public static function definitions(): array { return [ - 'uppercase' => fn($v) => strtoupper((string) $v), - 'lowercase' => fn($v) => strtolower((string) $v), - 'ucfirst' => fn($v) => ucfirst((string) $v), + 'uppercase' => fn($v) => self::toUpper((string) $v), + 'lowercase' => fn($v) => self::toLower((string) $v), + 'ucfirst' => fn($v) => self::mbUcfirst((string) $v), 'trim' => fn($v) => trim((string) $v), 'truncate' => fn($v, $len = 50, $suffix = '...') => mb_strlen((string) $v) > (int) $len ? mb_substr((string) $v, 0, (int) $len) . $suffix : (string) $v, 'slug' => function ($v): string { - $v = strtolower((string) $v); + $v = self::toLower((string) $v); + if (function_exists('iconv')) { + $transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $v); + if (is_string($transliterated) && $transliterated !== '') { + $v = $transliterated; + } + } $v = preg_replace('/[^a-z0-9]+/', '-', $v) ?? ''; return trim($v, '-'); }, ]; } + + private static function toUpper(string $value): string + { + if (function_exists('mb_strtoupper')) { + return mb_strtoupper($value, 'UTF-8'); + } + + return strtoupper($value); + } + + private static function toLower(string $value): string + { + if (function_exists('mb_strtolower')) { + return mb_strtolower($value, 'UTF-8'); + } + + return strtolower($value); + } + + private static function mbUcfirst(string $value): string + { + if ($value === '') { + return ''; + } + + if (function_exists('mb_substr')) { + $firstChar = mb_substr($value, 0, 1, 'UTF-8'); + $remaining = mb_substr($value, 1, null, 'UTF-8'); + return self::toUpper($firstChar) . $remaining; + } + + return ucfirst($value); + } } From 3fe7483ae36378de9339e044f0525db19986ef16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:35:17 +0000 Subject: [PATCH 5/6] chore: plan aggressive edge-case hardening pass --- .tmp_smoke_output.html | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .tmp_smoke_output.html diff --git a/.tmp_smoke_output.html b/.tmp_smoke_output.html new file mode 100644 index 0000000..b47907a --- /dev/null +++ b/.tmp_smoke_output.html @@ -0,0 +1,62 @@ + + + + + + Home + + + +
+

Bem-vindo ao Template Engine

+ + + +
+

Olá, JOÃO SILVA!

+

Email: joao@example.com

+

Role: admin

+
+ + + +
+

Usuários do Sistema

+ +
+ #0: Maria
+ Email: maria@example.com +
+ +
+ #1: Pedro
+ Email: pedro@example.com +
+ +
+ #2: Ana
+ Email: ana@example.com +
+ +
+
+ + From deec2dec88c7f25850807efd3f106b7c82a41dad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:37:13 +0000 Subject: [PATCH 6/6] fix: harden lexer and filter pipeline with edge-case matrix coverage --- README.md | 8 ++ examples/edge_matrix.php | 138 +++++++++++++++++++++++ src/Core/View/Compiler.php | 47 +++++++- src/Core/View/Filters/ArrayFilters.php | 26 ++++- src/Core/View/Filters/FilterRegistry.php | 22 +++- src/Core/View/Filters/StringFilters.php | 43 ++++++- src/Core/View/Lexer.php | 25 +++- 7 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 examples/edge_matrix.php diff --git a/README.md b/README.md index 8ebcd3e..b611ca3 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,14 @@ import { UserCard } from "@components/UserCard"; Veja [SYNTAX.md](./SYNTAX.md) para documentação completa da sintaxe. +## Validação rápida + +```bash +find . -name '*.php' | xargs php -l +php examples/index.php +php examples/edge_matrix.php +``` + ## Estrutura do Projeto ``` diff --git a/examples/edge_matrix.php b/examples/edge_matrix.php new file mode 100644 index 0000000..dcdf23e --- /dev/null +++ b/examples/edge_matrix.php @@ -0,0 +1,138 @@ + $templatesDir, + 'cache_dir' => $cacheDir, + 'cache_enabled' => false, + 'auto_escape' => true, + 'debug' => true, +]); + +$writeTemplate = static function (string $name, string $content) use ($templatesDir): void { + file_put_contents($templatesDir . '/' . $name, $content); +}; + +$normalize = static function (string $value): string { + return trim((string) preg_replace('/\s+/', ' ', $value)); +}; + +$writeTemplate('partial.html', '[[{{ user.name }}]]'); + +$cases = [ + // Diretivas + [ + 'name' => 'directive_if_elseif_else', + 'template' => 'ONETWOOTHER', + 'data' => ['user' => ['level' => 2]], + 'expect' => 'TWO', + ], + [ + 'name' => 'directive_foreach_loop_meta', + 'template' => '{{ __loop.count }}-{{ index }}-{{ user.name }};', + 'data' => ['users' => [['name' => 'Ana'], ['name' => 'Beto']]], + 'expect' => '1-0-Ana;2-1-Beto;', + ], + [ + 'name' => 'directive_set', + 'template' => '{{ total }}', + 'data' => [], + 'expect' => '5', + ], + [ + 'name' => 'directive_include_with_data_expression', + 'template' => ' ["name" => user.name]] }} />', + 'data' => ['user' => ['name' => 'Carla']], + 'expect' => '[[Carla]]', + ], + [ + 'name' => 'directive_block_passthrough', + 'template' => 'conteudo', + 'data' => [], + 'expect' => 'conteudo', + ], + + // Filtros de string + ['name' => 'filter_uppercase_utf8', 'template' => '{{ value | uppercase }}', 'data' => ['value' => 'João'], 'expect' => 'JOÃO'], + ['name' => 'filter_lowercase_utf8', 'template' => '{{ value | lowercase }}', 'data' => ['value' => 'AÇÃO'], 'expect' => 'ação'], + ['name' => 'filter_ucfirst_utf8', 'template' => '{{ value | ucfirst }}', 'data' => ['value' => 'joão'], 'expect' => 'João'], + ['name' => 'filter_trim', 'template' => '{{ value | trim }}', 'data' => ['value' => ' x '], 'expect' => 'x'], + ['name' => 'filter_truncate', 'template' => '{{ value | truncate(4, "..") }}', 'data' => ['value' => 'abcdef'], 'expect' => 'abcd..'], + ['name' => 'filter_slug', 'template' => '{{ value | slug }}', 'data' => ['value' => 'Ação de Teste!'], 'expect' => 'acao-de-teste'], + ['name' => 'filter_reverse_utf8', 'template' => '{{ value | reverse }}', 'data' => ['value' => 'João'], 'expect' => 'oãoJ'], + + // Filtros numéricos/data + ['name' => 'filter_currency', 'template' => '{{ value | currency("BRL") }}', 'data' => ['value' => 1234.5], 'expect' => 'R$ 1.234,50'], + ['name' => 'filter_number_format', 'template' => '{{ value | number_format(2) }}', 'data' => ['value' => 1234.5], 'expect' => '1.234,50'], + ['name' => 'filter_abs', 'template' => '{{ value | abs }}', 'data' => ['value' => -10], 'expect' => '10'], + ['name' => 'filter_date', 'template' => '{{ value | date("Y-m-d") }}', 'data' => ['value' => '2024-01-02'], 'expect' => '2024-01-02'], + + // Filtros de array + ['name' => 'filter_count', 'template' => '{{ value | count }}', 'data' => ['value' => [1, 2, 3]], 'expect' => '3'], + ['name' => 'filter_first', 'template' => '{{ value | first }}', 'data' => ['value' => [10, 20]], 'expect' => '10'], + ['name' => 'filter_last', 'template' => '{{ value | last }}', 'data' => ['value' => [10, 20]], 'expect' => '20'], + ['name' => 'filter_join_nested_values', 'template' => '{! value | join(" | ") !}', 'data' => ['value' => ['a', ['k' => 'v'], true]], 'expectContains' => 'a | {"k":"v"} | 1'], + ['name' => 'filter_json', 'template' => '{! value | json !}', 'data' => ['value' => ['a' => 1]], 'expectContains' => '"a": 1'], +]; + +$failures = []; + +foreach ($cases as $case) { + $templateFile = $case['name'] . '.html'; + $writeTemplate($templateFile, $case['template']); + + try { + $output = $engine->render($templateFile, $case['data']); + } catch (\Throwable $e) { + $failures[] = $case['name'] . ': exception inesperada -> ' . $e->getMessage(); + continue; + } + + $normalizedOutput = $normalize($output); + if (array_key_exists('expect', $case)) { + $expected = $normalize((string) $case['expect']); + if ($normalizedOutput !== $expected) { + $failures[] = $case['name'] . ": esperado '{$expected}', obtido '{$normalizedOutput}'"; + } + continue; + } + + if (array_key_exists('expectContains', $case)) { + $expectedContains = (string) $case['expectContains']; + if (strpos($normalizedOutput, $expectedContains) === false) { + $failures[] = $case['name'] . ": saída não contém '{$expectedContains}', obtido '{$normalizedOutput}'"; + } + } +} + +if ($failures !== []) { + fwrite(STDERR, "Falhas na matriz de borda:\n"); + foreach ($failures as $failure) { + fwrite(STDERR, "- {$failure}\n"); + } + exit(1); +} + +echo "OK: matriz de diretivas/filtros passou com " . count($cases) . " cenários.\n"; diff --git a/src/Core/View/Compiler.php b/src/Core/View/Compiler.php index 7975aab..002e0c3 100644 --- a/src/Core/View/Compiler.php +++ b/src/Core/View/Compiler.php @@ -136,7 +136,7 @@ public function compileIncludeNode(IncludeNode $node): string private function compileExpression(string $expression): string { - $parts = preg_split('/\|/', $expression) ?: []; + $parts = $this->splitByPipe($expression); $base = trim(array_shift($parts) ?? 'null'); if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $base) === 1) { @@ -164,6 +164,51 @@ private function compileExpression(string $expression): string return $compiled; } + /** @return array */ + private function splitByPipe(string $expression): array + { + $parts = []; + $current = ''; + $length = strlen($expression); + $parenDepth = 0; + $inSingle = false; + $inDouble = false; + + for ($i = 0; $i < $length; $i++) { + $char = $expression[$i]; + + if ($char === "'" && !$inDouble) { + $inSingle = !$inSingle; + $current .= $char; + continue; + } + + if ($char === '"' && !$inSingle) { + $inDouble = !$inDouble; + $current .= $char; + continue; + } + + if (!$inSingle && !$inDouble) { + if ($char === '(') { + $parenDepth++; + } elseif ($char === ')') { + $parenDepth = max(0, $parenDepth - 1); + } elseif ($char === '|' && $parenDepth === 0) { + $parts[] = $current; + $current = ''; + continue; + } + } + + $current .= $char; + } + + $parts[] = $current; + + return $parts; + } + private function transformDotNotation(string $expression): string { return preg_replace_callback( diff --git a/src/Core/View/Filters/ArrayFilters.php b/src/Core/View/Filters/ArrayFilters.php index cd06355..9c464cb 100644 --- a/src/Core/View/Filters/ArrayFilters.php +++ b/src/Core/View/Filters/ArrayFilters.php @@ -10,8 +10,30 @@ public static function definitions(): array 'count' => fn($v) => is_countable($v) ? count($v) : 0, 'first' => fn($v) => is_array($v) ? ($v[array_key_first($v)] ?? null) : null, 'last' => fn($v) => is_array($v) ? ($v[array_key_last($v)] ?? null) : null, - 'join' => fn($v, $sep = ', ') => is_array($v) ? implode((string) $sep, $v) : (string) $v, - 'json' => fn($v) => json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '', + 'join' => fn($v, $sep = ', ') => is_array($v) ? implode((string) $sep, array_map([self::class, 'stringify'], $v)) : (string) $v, + 'json' => fn($v) => json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '', ]; } + + private static function stringify(mixed $value): string + { + if (is_string($value)) { + return $value; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if ($value === null) { + return ''; + } + + if (is_scalar($value)) { + return (string) $value; + } + + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR); + return is_string($json) ? $json : ''; + } } diff --git a/src/Core/View/Filters/FilterRegistry.php b/src/Core/View/Filters/FilterRegistry.php index bc34d4d..d3f864d 100644 --- a/src/Core/View/Filters/FilterRegistry.php +++ b/src/Core/View/Filters/FilterRegistry.php @@ -42,8 +42,28 @@ private function registerDefaultFilters(): void } } - $this->register('reverse', fn($v) => is_array($v) ? array_reverse($v) : strrev((string) $v)); + $this->register('reverse', fn($v) => is_array($v) ? array_reverse($v) : self::reverseString((string) $v)); $this->register('raw', fn($v) => $v); $this->register('escape', fn($v) => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); } + + private static function reverseString(string $value): string + { + if ($value === '') { + return ''; + } + + if (function_exists('mb_strlen') && function_exists('mb_substr')) { + $length = mb_strlen($value, 'UTF-8'); + $reversed = ''; + for ($i = $length - 1; $i >= 0; $i--) { + $char = mb_substr($value, $i, 1, 'UTF-8'); + $reversed .= $char === false ? '' : $char; + } + + return $reversed; + } + + return strrev($value); + } } diff --git a/src/Core/View/Filters/StringFilters.php b/src/Core/View/Filters/StringFilters.php index c21b7d0..a768894 100644 --- a/src/Core/View/Filters/StringFilters.php +++ b/src/Core/View/Filters/StringFilters.php @@ -11,7 +11,7 @@ public static function definitions(): array 'lowercase' => fn($v) => self::toLower((string) $v), 'ucfirst' => fn($v) => self::mbUcfirst((string) $v), 'trim' => fn($v) => trim((string) $v), - 'truncate' => fn($v, $len = 50, $suffix = '...') => mb_strlen((string) $v) > (int) $len ? mb_substr((string) $v, 0, (int) $len) . $suffix : (string) $v, + 'truncate' => fn($v, $len = 50, $suffix = '...') => self::truncate((string) $v, (int) $len, (string) $suffix), 'slug' => function ($v): string { $v = self::toLower((string) $v); if (function_exists('iconv')) { @@ -26,6 +26,19 @@ public static function definitions(): array ]; } + private static function truncate(string $value, int $length, string $suffix): string + { + if ($length < 0) { + $length = 0; + } + + if (self::strLength($value) <= $length) { + return $value; + } + + return self::strSlice($value, 0, $length) . $suffix; + } + private static function toUpper(string $value): string { if (function_exists('mb_strtoupper')) { @@ -50,12 +63,32 @@ private static function mbUcfirst(string $value): string return ''; } + $firstChar = self::strSlice($value, 0, 1); + $remaining = self::strSlice($value, 1); + if ($firstChar === '') { + return ''; + } + + return self::toUpper($firstChar) . $remaining; + } + + private static function strLength(string $value): int + { + if (function_exists('mb_strlen')) { + return mb_strlen($value, 'UTF-8'); + } + + return strlen($value); + } + + private static function strSlice(string $value, int $start, ?int $length = null): string + { if (function_exists('mb_substr')) { - $firstChar = mb_substr($value, 0, 1, 'UTF-8'); - $remaining = mb_substr($value, 1, null, 'UTF-8'); - return self::toUpper($firstChar) . $remaining; + $result = mb_substr($value, $start, $length, 'UTF-8'); + return $result === false ? '' : $result; } - return ucfirst($value); + $result = $length === null ? substr($value, $start) : substr($value, $start, $length); + return $result === false ? '' : $result; } } diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index ddc9545..6ced010 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -104,15 +104,34 @@ private function extractTag(string $content, int &$pos, int &$line, int &$column $inSingle = false; $inDouble = false; + $mustacheDepth = 0; while ($pos < $length) { + if (!$inSingle && !$inDouble && $pos + 1 < $length) { + $pair = $content[$pos] . $content[$pos + 1]; + + if ($pair === '{{') { + $mustacheDepth++; + $pos += 2; + $column += 2; + continue; + } + + if ($pair === '}}' && $mustacheDepth > 0) { + $mustacheDepth--; + $pos += 2; + $column += 2; + continue; + } + } + $char = $content[$pos]; - if ($char === "'" && !$inDouble) { + if ($mustacheDepth === 0 && $char === "'" && !$inDouble) { $inSingle = !$inSingle; - } elseif ($char === '"' && !$inSingle) { + } elseif ($mustacheDepth === 0 && $char === '"' && !$inSingle) { $inDouble = !$inDouble; - } elseif ($char === '>' && !$inSingle && !$inDouble) { + } elseif ($char === '>' && !$inSingle && !$inDouble && $mustacheDepth === 0) { $pos++; $column++; break;