From be9d0911deb50b4740dd7a788dffc04d8ed36b75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:16:01 +0000 Subject: [PATCH 1/3] Initial plan From c57faa7aa88725f9f7c34dade2298b62169401e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:18:02 +0000 Subject: [PATCH 2/3] Plan architectural refactor baseline --- .tmp_example_output.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .tmp_example_output.txt diff --git a/.tmp_example_output.txt b/.tmp_example_output.txt new file mode 100644 index 0000000..e69de29 From 7b69039c67a0d2e1aecada0034e22b85274ae663 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:25:33 +0000 Subject: [PATCH 3/3] Refactor view engine architecture and runtime flow --- .tmp_example_output.txt | 0 README.md | 20 +- src/Core/View/Abstract/AbstractDirective.php | 11 + src/Core/View/Abstract/AbstractFilter.php | 15 + src/Core/View/Abstract/AbstractMiddleware.php | 12 + src/Core/View/Abstract/AbstractNode.php | 42 ++ src/Core/View/Cache/CacheKey.php | 20 + src/Core/View/Cache/FileWatcher.php | 25 ++ src/Core/View/Compiler.php | 260 +++++++---- src/Core/View/Debug/ErrorFormatter.php | 15 + src/Core/View/Debug/RuntimeContext.php | 24 + src/Core/View/Debug/SourceMap.php | 24 + src/Core/View/Debug/TemplateDebugger.php | 17 + src/Core/View/Directives/BlockDirective.php | 19 + .../View/Directives/DirectiveRegistry.php | 26 ++ src/Core/View/Directives/ForeachDirective.php | 24 + src/Core/View/Directives/IfDirective.php | 19 + src/Core/View/Directives/IncludeDirective.php | 24 + src/Core/View/Directives/SetDirective.php | 24 + src/Core/View/Engine.php | 254 +++++------ src/Core/View/Escape/Escaper.php | 40 ++ src/Core/View/Escape/HtmlEscaper.php | 11 + src/Core/View/Escape/JavaScriptEscaper.php | 15 + src/Core/View/Escape/UriEscaper.php | 11 + .../Exceptions/CircularReferenceException.php | 7 + src/Core/View/Exceptions/CompileException.php | 7 + src/Core/View/Exceptions/RuntimeException.php | 7 + src/Core/View/Filters/ArrayFilters.php | 17 + src/Core/View/Filters/DateFilters.php | 13 + src/Core/View/Filters/FilterRegistry.php | 93 +--- src/Core/View/Filters/NumberFilters.php | 15 + src/Core/View/Filters/StringFilters.php | 22 + src/Core/View/Layout/BlockStack.php | 25 ++ src/Core/View/Layout/LayoutManager.php | 57 +++ src/Core/View/Layout/LayoutResolver.php | 20 + src/Core/View/Lexer.php | 227 +++++----- src/Core/View/Middleware/CacheMiddleware.php | 13 + .../View/Middleware/MiddlewarePipeline.php | 31 ++ .../View/Middleware/ProfilingMiddleware.php | 17 + .../View/Middleware/SecurityMiddleware.php | 14 + src/Core/View/Nodes/BlockNode.php | 25 ++ src/Core/View/Nodes/ComponentNode.php | 19 +- src/Core/View/Nodes/ExpressionNode.php | 19 +- src/Core/View/Nodes/ForeachNode.php | 26 ++ src/Core/View/Nodes/IfNode.php | 23 +- src/Core/View/Nodes/IncludeNode.php | 24 + src/Core/View/Nodes/NodeInterface.php | 18 +- src/Core/View/Nodes/RawNode.php | 19 +- src/Core/View/Nodes/SetNode.php | 24 + src/Core/View/Nodes/TextNode.php | 19 +- src/Core/View/Parser.php | 415 +++++++++++------- src/Core/View/Renderer.php | 73 ++- src/Core/View/Scope/Scope.php | 30 ++ src/Core/View/Scope/ScopeStack.php | 70 +++ src/Core/View/Scope/Variable.php | 11 + src/Core/View/TemplateResolver.php | 41 ++ src/Core/View/Validation/SyntaxValidator.php | 23 + .../View/Validation/TemplateValidator.php | 16 + 58 files changed, 1805 insertions(+), 627 deletions(-) delete mode 100644 .tmp_example_output.txt create mode 100644 src/Core/View/Abstract/AbstractDirective.php create mode 100644 src/Core/View/Abstract/AbstractFilter.php create mode 100644 src/Core/View/Abstract/AbstractMiddleware.php create mode 100644 src/Core/View/Abstract/AbstractNode.php create mode 100644 src/Core/View/Cache/CacheKey.php create mode 100644 src/Core/View/Cache/FileWatcher.php create mode 100644 src/Core/View/Debug/ErrorFormatter.php create mode 100644 src/Core/View/Debug/RuntimeContext.php create mode 100644 src/Core/View/Debug/SourceMap.php create mode 100644 src/Core/View/Debug/TemplateDebugger.php create mode 100644 src/Core/View/Directives/BlockDirective.php create mode 100644 src/Core/View/Directives/DirectiveRegistry.php create mode 100644 src/Core/View/Directives/ForeachDirective.php create mode 100644 src/Core/View/Directives/IfDirective.php create mode 100644 src/Core/View/Directives/IncludeDirective.php create mode 100644 src/Core/View/Directives/SetDirective.php create mode 100644 src/Core/View/Escape/Escaper.php create mode 100644 src/Core/View/Escape/HtmlEscaper.php create mode 100644 src/Core/View/Escape/JavaScriptEscaper.php create mode 100644 src/Core/View/Escape/UriEscaper.php create mode 100644 src/Core/View/Exceptions/CircularReferenceException.php create mode 100644 src/Core/View/Exceptions/CompileException.php create mode 100644 src/Core/View/Exceptions/RuntimeException.php create mode 100644 src/Core/View/Filters/ArrayFilters.php create mode 100644 src/Core/View/Filters/DateFilters.php create mode 100644 src/Core/View/Filters/NumberFilters.php create mode 100644 src/Core/View/Filters/StringFilters.php create mode 100644 src/Core/View/Layout/BlockStack.php create mode 100644 src/Core/View/Layout/LayoutManager.php create mode 100644 src/Core/View/Layout/LayoutResolver.php create mode 100644 src/Core/View/Middleware/CacheMiddleware.php create mode 100644 src/Core/View/Middleware/MiddlewarePipeline.php create mode 100644 src/Core/View/Middleware/ProfilingMiddleware.php create mode 100644 src/Core/View/Middleware/SecurityMiddleware.php create mode 100644 src/Core/View/Nodes/BlockNode.php create mode 100644 src/Core/View/Nodes/ForeachNode.php create mode 100644 src/Core/View/Nodes/IncludeNode.php create mode 100644 src/Core/View/Nodes/SetNode.php create mode 100644 src/Core/View/Scope/Scope.php create mode 100644 src/Core/View/Scope/ScopeStack.php create mode 100644 src/Core/View/Scope/Variable.php create mode 100644 src/Core/View/TemplateResolver.php create mode 100644 src/Core/View/Validation/SyntaxValidator.php create mode 100644 src/Core/View/Validation/TemplateValidator.php diff --git a/.tmp_example_output.txt b/.tmp_example_output.txt deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index ee9e1dd..3135a2e 100644 --- a/README.md +++ b/README.md @@ -90,14 +90,22 @@ src/Core/View/ ├── Compiler.php # Compila templates ├── Parser.php # Parser da AST ├── Lexer.php # Tokenizador -├── Renderer.php # Renderizador +├── Renderer.php # Renderização via include (sem eval) ├── Environment.php # Configuração -├── Nodes/ # AST Nodes -├── Directives/ # Processadores de tags -├── Filters/ # Filtros +├── TemplateResolver.php # Resolução segura de caminhos +├── Abstract/ # Bases abstratas (Node/Directive/Filter/Middleware) +├── Nodes/ # AST nodes compiláveis +├── Directives/ # Registry + directives padrão +├── Filters/ # Filtros por domínio + registry +├── Scope/ # Scope e pilha de variáveis +├── Layout/ # Herança de templates e blocos +├── Cache/ # Cache key + file watcher +├── Escape/ # Escape context-aware (HTML/JS/CSS/URI) +├── Middleware/ # Pipeline de middlewares +├── Validation/ # Validação de sintaxe/template +├── Debug/ # Source/runtime debugging helpers ├── Components/ # Sistema de componentes -├── Cache/ # Cache -└── Exceptions/ # Exceções +└── Exceptions/ # Hierarquia de exceções ``` ## Contribuindo diff --git a/src/Core/View/Abstract/AbstractDirective.php b/src/Core/View/Abstract/AbstractDirective.php new file mode 100644 index 0000000..6bde27f --- /dev/null +++ b/src/Core/View/Abstract/AbstractDirective.php @@ -0,0 +1,11 @@ + */ + abstract public function parseAttributes(string $attributes): array; +} diff --git a/src/Core/View/Abstract/AbstractFilter.php b/src/Core/View/Abstract/AbstractFilter.php new file mode 100644 index 0000000..590dcc7 --- /dev/null +++ b/src/Core/View/Abstract/AbstractFilter.php @@ -0,0 +1,15 @@ +apply($value, ...$args); + } + + abstract public function apply(mixed $value, mixed ...$args): mixed; +} diff --git a/src/Core/View/Abstract/AbstractMiddleware.php b/src/Core/View/Abstract/AbstractMiddleware.php new file mode 100644 index 0000000..5abbe4f --- /dev/null +++ b/src/Core/View/Abstract/AbstractMiddleware.php @@ -0,0 +1,12 @@ +): string $next + * @param array $context + */ + abstract public function handle(array $context, callable $next): string; +} diff --git a/src/Core/View/Abstract/AbstractNode.php b/src/Core/View/Abstract/AbstractNode.php new file mode 100644 index 0000000..0def3f3 --- /dev/null +++ b/src/Core/View/Abstract/AbstractNode.php @@ -0,0 +1,42 @@ +line = $line; + $this->column = $column; + $this->metadata = $metadata; + } + + abstract public function compile(Compiler $compiler): string; + + public function accept(callable $visitor) + { + return $visitor($this); + } + + public function getLine(): int + { + return $this->line; + } + + public function getColumn(): int + { + return $this->column; + } + + public function getMetadata(): array + { + return $this->metadata; + } +} diff --git a/src/Core/View/Cache/CacheKey.php b/src/Core/View/Cache/CacheKey.php new file mode 100644 index 0000000..238415a --- /dev/null +++ b/src/Core/View/Cache/CacheKey.php @@ -0,0 +1,20 @@ + $dependencies */ + public function forTemplate(string $path, array $dependencies = []): string + { + $signature = $path; + foreach ($dependencies as $dep) { + $signature .= '|' . $dep; + if (is_file($dep)) { + $signature .= ':' . (string) filemtime($dep); + } + } + + return 'template_' . md5($signature); + } +} diff --git a/src/Core/View/Cache/FileWatcher.php b/src/Core/View/Cache/FileWatcher.php new file mode 100644 index 0000000..a343ce4 --- /dev/null +++ b/src/Core/View/Cache/FileWatcher.php @@ -0,0 +1,25 @@ + */ + private array $mtimes = []; + + /** @param array $paths */ + public function hasChanged(array $paths): bool + { + $changed = false; + + foreach ($paths as $path) { + $mtime = is_file($path) ? filemtime($path) : false; + if (!array_key_exists($path, $this->mtimes) || $this->mtimes[$path] !== $mtime) { + $changed = true; + $this->mtimes[$path] = $mtime; + } + } + + return $changed; + } +} diff --git a/src/Core/View/Compiler.php b/src/Core/View/Compiler.php index 5a8e31d..a494bbd 100644 --- a/src/Core/View/Compiler.php +++ b/src/Core/View/Compiler.php @@ -2,21 +2,24 @@ namespace Beobles\Core\View; -/** - * Compilador de AST para código PHP - * Transforma nós da AST em código PHP otimizado - */ +use Beobles\Core\View\Nodes\BlockNode; +use Beobles\Core\View\Nodes\ComponentNode; +use Beobles\Core\View\Nodes\ExpressionNode; +use Beobles\Core\View\Nodes\ForeachNode; +use Beobles\Core\View\Nodes\IfNode; +use Beobles\Core\View\Nodes\IncludeNode; +use Beobles\Core\View\Nodes\NodeInterface; +use Beobles\Core\View\Nodes\RawNode; +use Beobles\Core\View\Nodes\SetNode; +use Beobles\Core\View\Nodes\TextNode; + class Compiler { - /** - * Compila AST em código PHP - * - * @param array $nodes Nós da AST - * @return string Código PHP compilado - */ + /** @param array $nodes */ public function compile(array $nodes): string { - $code = "compileNode($node); @@ -25,109 +28,178 @@ public function compile(array $nodes): string return $code; } - /** - * Compila um nó individual - * - * @param object $node Nó para compilar - * @return string Código PHP - */ - private function compileNode(object $node): string - { - $class = class_basename($node); - - return match ($class) { - 'TextNode' => $this->compileText($node), - 'ExpressionNode' => $this->compileExpression($node), - 'RawNode' => $this->compileRaw($node), - 'ComponentNode' => $this->compileComponent($node), - 'IfNode' => $this->compileIf($node), - 'BlockNode' => $this->compileBlock($node), - 'ForeachNode' => $this->compileForeach($node), - default => '' - }; - } - - /** - * Compila texto - * - * @param object $node TextNode - * @return string - */ - private function compileText(object $node): string + public function compileNode(NodeInterface $node): string + { + return $node->compile($this); + } + + public function compileTextNode(TextNode $node): string { return 'echo ' . var_export($node->value, true) . ";\n"; } - /** - * Compila expressão {{ }} - * - * @param object $node ExpressionNode - * @return string - */ - private function compileExpression(object $node): string + public function compileExpressionNode(ExpressionNode $node): string + { + $expression = $this->compileExpression($node->value); + return 'echo $__engine->escape(' . $expression . ", 'html');\n"; + } + + public function compileRawNode(RawNode $node): string + { + return 'echo ' . $this->compileExpression($node->value) . ";\n"; + } + + public function compileComponentNode(ComponentNode $node): string { - $escaped = 'htmlspecialchars(' . $node->value . ', ENT_QUOTES, "UTF-8")'; - return 'echo ' . $escaped . ";\n"; + $props = []; + foreach ($node->attributes as $key => $value) { + $props[] = var_export((string) $key, true) . ' => ' . $this->compileExpression($value); + } + + return 'echo $__engine->renderComponent(' . var_export($node->name, true) . ', [' . implode(', ', $props) . "]);\n"; } - /** - * Compila raw output {! !} - * - * @param object $node RawNode - * @return string - */ - private function compileRaw(object $node): string + public function compileIfNode(IfNode $node): string { - return 'echo ' . $node->value . ";\n"; + $code = ''; + foreach ($node->branches as $index => $branch) { + $childrenCode = $this->compileChildren($branch['nodes']); + if ($index === 0) { + $code .= 'if (' . $this->compileExpression((string) $branch['condition']) . ") {\n"; + $code .= $childrenCode; + $code .= "}\n"; + continue; + } + + if ($branch['condition'] === null) { + $code .= "else {\n"; + $code .= $childrenCode; + $code .= "}\n"; + } else { + $code .= 'elseif (' . $this->compileExpression((string) $branch['condition']) . ") {\n"; + $code .= $childrenCode; + $code .= "}\n"; + } + } + + return $code; } - /** - * Compila componente - * - * @param object $node ComponentNode - * @return string - */ - private function compileComponent(object $node): string + public function compileForeachNode(ForeachNode $node): string { - $props = 'array(' . implode(', ', array_map( - fn($k, $v) => "'" . $k . "' => " . $v, - array_keys($node->attributes), - $node->attributes - )) . ')'; + [$valueVar, $indexVar] = $this->parseForeachAs($node->as); + $itemsExpr = $this->compileExpression($node->items); + $loopIndexVar = '$__loop_index'; + $loopItemsVar = '$__loop_items'; + + $code = "{$loopItemsVar} = {$itemsExpr};\n"; + $code .= "if (is_iterable({$loopItemsVar})) {\n"; + $code .= " {$loopItemsVar} = is_array({$loopItemsVar}) ? {$loopItemsVar} : iterator_to_array({$loopItemsVar}, false);\n"; + $code .= " {$loopIndexVar} = -1;\n"; + $code .= ' $__loop_stack[] = $__loop ?? null;' . "\n"; + $code .= " foreach ({$loopItemsVar} as " . ($indexVar !== null ? "{$indexVar} => {$valueVar}" : "{$valueVar}") . ") {\n"; + $code .= " {$loopIndexVar}++;\n"; + $code .= ' $__loop = [' . "\n"; + $code .= " 'index' => {$loopIndexVar},\n"; + $code .= " 'count' => {$loopIndexVar} + 1,\n"; + $code .= " 'total' => count({$loopItemsVar}),\n"; + $code .= " 'first' => {$loopIndexVar} === 0,\n"; + $code .= " 'last' => {$loopIndexVar} === count({$loopItemsVar}) - 1,\n"; + $code .= " 'even' => ({$loopIndexVar} + 1) % 2 === 0,\n"; + $code .= " 'odd' => ({$loopIndexVar} + 1) % 2 !== 0,\n"; + $code .= " 'percentage' => count({$loopItemsVar}) > 0 ? (int) round((({$loopIndexVar} + 1) / count({$loopItemsVar})) * 100) : 0,\n"; + $code .= " ];\n"; + $code .= $this->indent($this->compileChildren($node->children)); + $code .= " }\n"; + $code .= ' $__loop = array_pop($__loop_stack);' . "\n"; + $code .= "}\n"; + + return $code; + } - return 'echo $__engine->renderComponent(' . var_export($node->name, true) . ', ' . $props . ");\n"; + public function compileBlockNode(BlockNode $node): string + { + return $this->compileChildren($node->children); } - /** - * Compila If - * - * @param object $node IfNode - * @return string - */ - private function compileIf(object $node): string + public function compileSetNode(SetNode $node): string { - return 'if (' . $node->condition . ") {\n"; + $variable = '$' . ltrim($node->variable, '$'); + return $variable . ' = ' . $this->compileExpression($node->value) . ";\n"; } - /** - * Compila Block - * - * @param object $node BlockNode - * @return string - */ - private function compileBlock(object $node): string + public function compileIncludeNode(IncludeNode $node): string { - return 'ob_start();' . "\n"; + $dataExpr = $node->dataExpression ? $this->compileExpression($node->dataExpression) : '[]'; + return 'echo $__engine->render(' . var_export($node->path, true) . ', array_merge(get_defined_vars(), (array) (' . $dataExpr . ")));\n"; } - /** - * Compila Foreach - * - * @param object $node ForeachNode - * @return string - */ - private function compileForeach(object $node): string + private function compileExpression(string $expression): string { - return 'foreach (' . $node->items . ' as ' . $node->as . ") {\n"; + $parts = preg_split('/\|/', $expression) ?: []; + $base = trim(array_shift($parts) ?? 'null'); + + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $base) === 1) { + $compiled = '$__engine->resolveValue(' . var_export($base, true) . ', get_defined_vars())'; + } else { + $compiled = $this->transformDotNotation($base); + } + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)$/', $part, $matches) === 1) { + $filter = $matches[1]; + $args = trim($matches[2]); + $compiledArgs = $args === '' ? '[]' : '[' . $args . ']'; + $compiled = '$__engine->applyFilter(' . $compiled . ', ' . var_export($filter, true) . ', ' . $compiledArgs . ')'; + } else { + $compiled = '$__engine->applyFilter(' . $compiled . ', ' . var_export($part, true) . ')'; + } + } + + return $compiled; + } + + private function transformDotNotation(string $expression): string + { + return preg_replace_callback( + '/\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+)\b/', + fn(array $matches): string => '$__engine->resolveValue(' . var_export($matches[1], true) . ', get_defined_vars())', + $expression + ) ?? $expression; + } + + /** @param array $nodes */ + private function compileChildren(array $nodes): string + { + $code = ''; + foreach ($nodes as $node) { + $code .= $this->compileNode($node); + } + + return $code; + } + + /** @return array{0:string,1:?string} */ + private function parseForeachAs(string $as): array + { + $parts = array_map('trim', explode(',', $as)); + if (count($parts) === 2) { + return ['$' . ltrim($parts[0], '$'), '$' . ltrim($parts[1], '$')]; + } + + return ['$' . ltrim($parts[0], '$'), null]; + } + + private function indent(string $code, int $spaces = 8): string + { + $indent = str_repeat(' ', $spaces); + $lines = explode("\n", rtrim($code, "\n")); + + return implode("\n", array_map(static fn(string $line): string => $line === '' ? $line : $indent . $line, $lines)) . "\n"; } } diff --git a/src/Core/View/Debug/ErrorFormatter.php b/src/Core/View/Debug/ErrorFormatter.php new file mode 100644 index 0000000..39ba1d1 --- /dev/null +++ b/src/Core/View/Debug/ErrorFormatter.php @@ -0,0 +1,15 @@ + $variables */ + public function __construct( + private array $variables, + private float $startTime + ) { + } + + /** @return array */ + public function variables(): array + { + return $this->variables; + } + + public function elapsedMs(): float + { + return (microtime(true) - $this->startTime) * 1000; + } +} diff --git a/src/Core/View/Debug/SourceMap.php b/src/Core/View/Debug/SourceMap.php new file mode 100644 index 0000000..30fafc4 --- /dev/null +++ b/src/Core/View/Debug/SourceMap.php @@ -0,0 +1,24 @@ + */ + private array $map = []; + + public function record(int $compiledLine, string $file, int $line, int $column): void + { + $this->map[$compiledLine] = [ + 'file' => $file, + 'line' => $line, + 'column' => $column, + ]; + } + + /** @return array{file:string,line:int,column:int}|null */ + public function resolve(int $compiledLine): ?array + { + return $this->map[$compiledLine] ?? null; + } +} diff --git a/src/Core/View/Debug/TemplateDebugger.php b/src/Core/View/Debug/TemplateDebugger.php new file mode 100644 index 0000000..3541811 --- /dev/null +++ b/src/Core/View/Debug/TemplateDebugger.php @@ -0,0 +1,17 @@ + $variables */ + public function context(array $variables, float $startTime): RuntimeContext + { + return new RuntimeContext($variables, $startTime); + } +} diff --git a/src/Core/View/Directives/BlockDirective.php b/src/Core/View/Directives/BlockDirective.php new file mode 100644 index 0000000..c5b451e --- /dev/null +++ b/src/Core/View/Directives/BlockDirective.php @@ -0,0 +1,19 @@ + trim($matches[1] ?? '')]; + } +} diff --git a/src/Core/View/Directives/DirectiveRegistry.php b/src/Core/View/Directives/DirectiveRegistry.php new file mode 100644 index 0000000..45b7748 --- /dev/null +++ b/src/Core/View/Directives/DirectiveRegistry.php @@ -0,0 +1,26 @@ + */ + private array $directives = []; + + public function register(AbstractDirective $directive): void + { + $this->directives[strtolower($directive->getName())] = $directive; + } + + public function has(string $name): bool + { + return isset($this->directives[strtolower($name)]); + } + + public function get(string $name): ?AbstractDirective + { + return $this->directives[strtolower($name)] ?? null; + } +} diff --git a/src/Core/View/Directives/ForeachDirective.php b/src/Core/View/Directives/ForeachDirective.php new file mode 100644 index 0000000..5374de0 --- /dev/null +++ b/src/Core/View/Directives/ForeachDirective.php @@ -0,0 +1,24 @@ + trim($items[1] ?? ''), + 'as' => trim($as[1] ?? ''), + ]; + } +} diff --git a/src/Core/View/Directives/IfDirective.php b/src/Core/View/Directives/IfDirective.php new file mode 100644 index 0000000..2cf36ac --- /dev/null +++ b/src/Core/View/Directives/IfDirective.php @@ -0,0 +1,19 @@ + trim($matches[1] ?? '')]; + } +} diff --git a/src/Core/View/Directives/IncludeDirective.php b/src/Core/View/Directives/IncludeDirective.php new file mode 100644 index 0000000..fcbf94d --- /dev/null +++ b/src/Core/View/Directives/IncludeDirective.php @@ -0,0 +1,24 @@ + trim($pathMatches[1] ?? ''), + 'data' => isset($dataMatches[1]) ? trim($dataMatches[1]) : null, + ]; + } +} diff --git a/src/Core/View/Directives/SetDirective.php b/src/Core/View/Directives/SetDirective.php new file mode 100644 index 0000000..a5ab40c --- /dev/null +++ b/src/Core/View/Directives/SetDirective.php @@ -0,0 +1,24 @@ + trim($varMatches[1] ?? ''), + 'value' => trim($valueMatches[1] ?? ''), + ]; + } +} diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index f8f74b8..c9a771e 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -2,22 +2,28 @@ namespace Beobles\Core\View; +use Beobles\Core\View\Cache\CacheKey; use Beobles\Core\View\Cache\CacheManager; use Beobles\Core\View\Cache\FileCacheAdapter; +use Beobles\Core\View\Cache\FileWatcher; use Beobles\Core\View\Components\ComponentRegistry; +use Beobles\Core\View\Debug\TemplateDebugger; +use Beobles\Core\View\Directives\DirectiveRegistry; +use Beobles\Core\View\Escape\Escaper; use Beobles\Core\View\Exceptions\ViewException; use Beobles\Core\View\Filters\FilterRegistry; +use Beobles\Core\View\Layout\LayoutManager; +use Beobles\Core\View\Middleware\CacheMiddleware; +use Beobles\Core\View\Middleware\MiddlewarePipeline; +use Beobles\Core\View\Middleware\ProfilingMiddleware; +use Beobles\Core\View\Middleware\SecurityMiddleware; +use Beobles\Core\View\Scope\ScopeStack; +use Beobles\Core\View\Validation\TemplateValidator; -/** - * Motor de Template Engine Principal - * - * Orquestra a compilação, cache e renderização de templates - */ class Engine { private string $templatesDir; private string $cacheDir; - private bool $autoEscape; private bool $cacheEnabled; private Environment $environment; private Lexer $lexer; @@ -25,199 +31,179 @@ class Engine private Compiler $compiler; private Renderer $renderer; private CacheManager $cacheManager; + private CacheKey $cacheKey; + private FileWatcher $fileWatcher; + private TemplateResolver $templateResolver; + private LayoutManager $layoutManager; private ComponentRegistry $componentRegistry; private FilterRegistry $filterRegistry; + private ScopeStack $scopeStack; + private Escaper $escaper; + private MiddlewarePipeline $middlewarePipeline; + private TemplateValidator $templateValidator; + private TemplateDebugger $debugger; - /** - * Construtor do Engine - * - * @param array $config [ - * 'templates_dir' => string, - * 'cache_dir' => string, - * 'auto_escape' => bool, - * 'cache_enabled' => bool, - * ] - */ public function __construct(array $config = []) { $this->templatesDir = $config['templates_dir'] ?? __DIR__ . '/../../../templates'; $this->cacheDir = $config['cache_dir'] ?? __DIR__ . '/../../../cache'; - $this->autoEscape = $config['auto_escape'] ?? true; $this->cacheEnabled = $config['cache_enabled'] ?? true; - // Validar diretórios if (!is_dir($this->templatesDir)) { throw new ViewException("Templates directory not found: {$this->templatesDir}"); } - // Criar cache dir se necessário if ($this->cacheEnabled && !is_dir($this->cacheDir)) { mkdir($this->cacheDir, 0755, true); } - // Inicializar componentes - $this->environment = new Environment($config); - $this->lexer = new Lexer(); - $this->parser = new Parser(); - $this->compiler = new Compiler(); - $this->renderer = new Renderer(); + $this->environment = new Environment(array_merge($config, ['cache_dir' => $this->cacheDir])); $this->cacheManager = new CacheManager(new FileCacheAdapter($this->cacheDir)); + $this->cacheKey = new CacheKey(); + $this->fileWatcher = new FileWatcher(); + $this->templateResolver = new TemplateResolver($this->templatesDir); + $this->layoutManager = new LayoutManager($this->templateResolver); $this->componentRegistry = new ComponentRegistry(); $this->filterRegistry = new FilterRegistry(); + $this->scopeStack = new ScopeStack(); + $this->escaper = new Escaper(); + $this->middlewarePipeline = new MiddlewarePipeline(); + $this->templateValidator = new TemplateValidator(); + $this->debugger = new TemplateDebugger(); + + $directiveRegistry = new DirectiveRegistry(); + $this->lexer = new Lexer(); + $this->parser = new Parser($directiveRegistry); + $this->compiler = new Compiler(); + $this->renderer = new Renderer($this->environment->getConfig('compiled_templates_dir', $this->cacheDir)); + + $this->middlewarePipeline->add(new SecurityMiddleware()); + $this->middlewarePipeline->add(new CacheMiddleware()); + $this->middlewarePipeline->add(new ProfilingMiddleware()); } - /** - * Renderiza um template com dados - * - * @param string $templatePath Caminho relativo do template - * @param array $data Dados para o template - * @return string HTML renderizado - */ public function render(string $templatePath, array $data = []): string { - try { - // Resolver caminho absoluto do template - $absolutePath = $this->resolveTemplatePath($templatePath); - - if (!file_exists($absolutePath)) { - throw new ViewException("Template not found: {$templatePath}"); - } - - // Verificar cache - if ($this->cacheEnabled) { - $cacheKey = $this->generateCacheKey($templatePath); - $cachedContent = $this->cacheManager->get($cacheKey); - - if ($cachedContent !== null) { - return $this->renderer->render($cachedContent, $data, $this); - } - } + $start = $this->debugger->begin(); - // Ler template - $content = file_get_contents($absolutePath); + return $this->middlewarePipeline->process( + ['template' => $templatePath, 'data' => $data], + function (array $context) use ($templatePath, $data, $start): string { + try { + $absolutePath = $this->resolveTemplatePath($templatePath); - // Tokenizar - $tokens = $this->lexer->tokenize($content); + if (!is_file($absolutePath)) { + throw new ViewException("Template not found: {$templatePath}"); + } - // Fazer parse - $ast = $this->parser->parse($tokens); + $compiledCode = $this->compileTemplate($absolutePath, $templatePath); - // Compilar - $compiledCode = $this->compiler->compile($ast); + $runtimeData = array_merge($this->environment->getGlobals(), $data); + foreach ($runtimeData as $name => $value) { + $this->scopeStack->set((string) $name, $value); + } - // Cachear se habilitado - if ($this->cacheEnabled) { - $this->cacheManager->set($cacheKey, $compiledCode); + return $this->renderer->render($compiledCode, $runtimeData, $this); + } catch (\Throwable $e) { + throw new ViewException("Error rendering template '{$templatePath}': " . $e->getMessage(), 0, $e); + } } - - // Renderizar - return $this->renderer->render($compiledCode, $data, $this); - } catch (\Exception $e) { - throw new ViewException("Error rendering template '{$templatePath}': " . $e->getMessage(), 0, $e); - } + ); } - /** - * Registra um componente customizado - * - * @param string $name Nome do componente - * @param string $path Caminho do arquivo - * @return void - */ public function registerComponent(string $name, string $path): void { $this->componentRegistry->register($name, $path); } - /** - * Registra um filtro customizado - * - * @param string $name Nome do filtro - * @param callable $callback Callback do filtro - * @return void - */ public function registerFilter(string $name, callable $callback): void { $this->filterRegistry->register($name, $callback); } - /** - * Resolve o caminho completo do template - * - * @param string $path Caminho relativo - * @return string Caminho absoluto - */ public function resolveTemplatePath(string $path): string { - // Resolver alias @components - if (strpos($path, '@') === 0) { - $path = str_replace('@components/', 'components/', $path); - } - - // Adicionar extensão se necessário - if (!str_ends_with($path, '.html')) { - $path .= '.html'; - } - - return $this->templatesDir . '/' . $path; - } - - /** - * Gera chave de cache - * - * @param string $path Caminho do template - * @return string Chave de cache - */ - private function generateCacheKey(string $path): string - { - return 'template_' . md5($path); + return $this->templateResolver->resolve($path); } - /** - * Renderiza um componente - * - * @param string $name Nome do componente - * @param array $props Props do componente - * @return string HTML renderizado - */ public function renderComponent(string $name, array $props = []): string { $componentPath = $this->componentRegistry->resolve($name); return $this->render($componentPath, ['props' => $props]); } - /** - * Aplica um filtro a um valor - * - * @param mixed $value Valor - * @param string $filter Nome do filtro - * @param array $args Argumentos do filtro - * @return mixed Valor filtrado - */ - public function applyFilter($value, string $filter, array $args = []) + public function applyFilter(mixed $value, string $filter, array $args = []): mixed { return $this->filterRegistry->apply($value, $filter, $args); } - /** - * Obtém o environment - * - * @return Environment - */ + public function escape(mixed $value, string $context = 'html'): string + { + return $this->escaper->escape($value, $context); + } + + /** @param array $scope */ + public function resolveValue(string $path, array $scope): mixed + { + if (str_contains($path, '.')) { + $segments = explode('.', $path); + $current = $scope[$segments[0]] ?? $this->scopeStack->get($segments[0]); + + foreach (array_slice($segments, 1) as $segment) { + if (is_array($current) && array_key_exists($segment, $current)) { + $current = $current[$segment]; + continue; + } + + if (is_object($current) && isset($current->{$segment})) { + $current = $current->{$segment}; + continue; + } + + return null; + } + + return $current; + } + + return $scope[$path] ?? $this->scopeStack->get($path); + } + public function getEnvironment(): Environment { return $this->environment; } - /** - * Limpa o cache - * - * @return void - */ public function clearCache(): void { if ($this->cacheEnabled) { $this->cacheManager->clear(); } } + + private function compileTemplate(string $absolutePath, string $templatePath): string + { + $source = (string) file_get_contents($absolutePath); + $merged = $this->layoutManager->merge($absolutePath, $source); + $this->templateValidator->validate($merged); + + $cacheKey = $this->cacheKey->forTemplate($templatePath, [$absolutePath]); + + if ($this->cacheEnabled && !$this->fileWatcher->hasChanged([$absolutePath])) { + $cached = $this->cacheManager->get($cacheKey); + if ($cached !== null) { + return $cached; + } + } + + $tokens = $this->lexer->tokenize($merged, $absolutePath); + $ast = $this->parser->parse($tokens); + $compiledCode = $this->compiler->compile($ast); + + if ($this->cacheEnabled) { + $this->cacheManager->set($cacheKey, $compiledCode); + } + + return $compiledCode; + } } diff --git a/src/Core/View/Escape/Escaper.php b/src/Core/View/Escape/Escaper.php new file mode 100644 index 0000000..e09fe93 --- /dev/null +++ b/src/Core/View/Escape/Escaper.php @@ -0,0 +1,40 @@ +htmlEscaper ??= new HtmlEscaper(); + $this->jsEscaper ??= new JavaScriptEscaper(); + $this->uriEscaper ??= new UriEscaper(); + } + + public function escape(mixed $value, string $context = 'html'): string + { + if ($value === null) { + return ''; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_array($value) || is_object($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''; + } + + $value = (string) $value; + + return match (strtolower($context)) { + 'js', 'javascript' => $this->jsEscaper->escape($value), + 'uri', 'url' => $this->uriEscaper->escape($value), + 'css' => addcslashes($value, "\0..\37\\\"'<>"), + default => $this->htmlEscaper->escape($value), + }; + } +} diff --git a/src/Core/View/Escape/HtmlEscaper.php b/src/Core/View/Escape/HtmlEscaper.php new file mode 100644 index 0000000..f55e4f2 --- /dev/null +++ b/src/Core/View/Escape/HtmlEscaper.php @@ -0,0 +1,11 @@ +", "&"], + ["\\\\", "\\n", "\\r", "\\t", "\\f", "\\b", "\\\"", "\\'", "\\x3C", "\\x3E", "\\x26"], + $value + ); + } +} diff --git a/src/Core/View/Escape/UriEscaper.php b/src/Core/View/Escape/UriEscaper.php new file mode 100644 index 0000000..81a8b59 --- /dev/null +++ b/src/Core/View/Escape/UriEscaper.php @@ -0,0 +1,11 @@ + 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), + ]; + } +} diff --git a/src/Core/View/Filters/DateFilters.php b/src/Core/View/Filters/DateFilters.php new file mode 100644 index 0000000..216e721 --- /dev/null +++ b/src/Core/View/Filters/DateFilters.php @@ -0,0 +1,13 @@ + fn($v, $format = 'd/m/Y') => date((string) $format, is_numeric($v) ? (int) $v : strtotime((string) $v)), + ]; + } +} diff --git a/src/Core/View/Filters/FilterRegistry.php b/src/Core/View/Filters/FilterRegistry.php index 0de4e19..76c3205 100644 --- a/src/Core/View/Filters/FilterRegistry.php +++ b/src/Core/View/Filters/FilterRegistry.php @@ -4,11 +4,9 @@ use Beobles\Core\View\Exceptions\ViewException; -/** - * Registro de filtros - */ class FilterRegistry { + /** @var array */ private array $filters = []; public function __construct() @@ -16,91 +14,36 @@ public function __construct() $this->registerDefaultFilters(); } - /** - * Registra um filtro - * - * @param string $name Nome do filtro - * @param callable $callback Callback do filtro - * @return void - */ public function register(string $name, callable $callback): void { - $this->filters[$name] = $callback; + $this->filters[strtolower($name)] = $callback; } - /** - * Aplica um filtro - * - * @param mixed $value Valor - * @param string $filter Nome do filtro - * @param array $args Argumentos - * @return mixed Valor filtrado - */ - public function apply($value, string $filter, array $args = []) + public function apply(mixed $value, string $filter, array $args = []): mixed { - if (!isset($this->filters[$filter])) { + $normalized = strtolower($filter); + if (!isset($this->filters[$normalized])) { throw new ViewException("Filter not found: {$filter}"); } - $callback = $this->filters[$filter]; - return $callback($value, ...$args); + return ($this->filters[$normalized])($value, ...$args); } - /** - * Registra filtros padrão - * - * @return void - */ private function registerDefaultFilters(): void { - // String filters - $this->register('uppercase', fn($v) => strtoupper($v)); - $this->register('lowercase', fn($v) => strtolower($v)); - $this->register('ucfirst', fn($v) => ucfirst($v)); - $this->register('reverse', fn($v) => strrev($v)); - $this->register('trim', fn($v) => trim($v)); - $this->register('ltrim', fn($v) => ltrim($v)); - $this->register('rtrim', fn($v) => rtrim($v)); - - // Truncate - $this->register('truncate', fn($v, $len = 50, $suffix = '...') => - strlen($v) > $len ? substr($v, 0, $len) . $suffix : $v - ); - - // Number filters - $this->register('currency', fn($v, $currency = 'BRL') => - $currency === 'BRL' ? 'R\$ ' . number_format($v, 2, ',', '.') : '$' . number_format($v, 2) - ); - $this->register('number_format', fn($v, $decimals = 0) => number_format($v, $decimals, ',', '.')); - $this->register('abs', fn($v) => abs($v)); - - // Date filter - $this->register('date', fn($v, $format = 'd/m/Y') => - is_numeric($v) ? date($format, $v) : date($format, strtotime($v)) - ); - - // Array filters - $this->register('count', fn($v) => count($v)); - $this->register('first', fn($v) => $v[0] ?? null); - $this->register('last', fn($v) => end($v)); - $this->register('reverse', fn($v) => array_reverse($v)); - $this->register('join', fn($v, $sep = ',') => implode($sep, $v)); - - // JSON - $this->register('json', fn($v) => json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + foreach ([ + StringFilters::definitions(), + NumberFilters::definitions(), + DateFilters::definitions(), + ArrayFilters::definitions(), + ] as $group) { + foreach ($group as $name => $callable) { + $this->register($name, $callable); + } + } - // Raw output + $this->register('reverse', fn($v) => is_array($v) ? array_reverse($v) : strrev((string) $v)); $this->register('raw', fn($v) => $v); - - // Escape - $this->register('escape', fn($v) => htmlspecialchars($v, ENT_QUOTES, 'UTF-8')); - $this->register('htmlentities', fn($v) => htmlentities($v, ENT_QUOTES, 'UTF-8')); - - // Slug - $this->register('slug', function($v) { - $v = strtolower($v); - $v = preg_replace('/[^a-z0-9]+/', '-', $v); - return trim($v, '-'); - }); + $this->register('escape', fn($v) => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); } } diff --git a/src/Core/View/Filters/NumberFilters.php b/src/Core/View/Filters/NumberFilters.php new file mode 100644 index 0000000..cca992c --- /dev/null +++ b/src/Core/View/Filters/NumberFilters.php @@ -0,0 +1,15 @@ + fn($v, $currency = 'BRL') => $currency === 'BRL' ? 'R$ ' . number_format((float) $v, 2, ',', '.') : '$' . number_format((float) $v, 2, '.', ','), + 'number_format' => fn($v, $decimals = 0) => number_format((float) $v, (int) $decimals, ',', '.'), + 'abs' => fn($v) => abs((float) $v), + ]; + } +} diff --git a/src/Core/View/Filters/StringFilters.php b/src/Core/View/Filters/StringFilters.php new file mode 100644 index 0000000..84165ca --- /dev/null +++ b/src/Core/View/Filters/StringFilters.php @@ -0,0 +1,22 @@ + fn($v) => strtoupper((string) $v), + 'lowercase' => 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) { + $v = strtolower((string) $v); + $v = preg_replace('/[^a-z0-9]+/', '-', $v); + return trim((string) $v, '-'); + }, + ]; + } +} diff --git a/src/Core/View/Layout/BlockStack.php b/src/Core/View/Layout/BlockStack.php new file mode 100644 index 0000000..eb8bdc3 --- /dev/null +++ b/src/Core/View/Layout/BlockStack.php @@ -0,0 +1,25 @@ + */ + private array $blocks = []; + + public function set(string $name, string $content): void + { + $this->blocks[$name] = $content; + } + + public function get(string $name, string $default = ''): string + { + return $this->blocks[$name] ?? $default; + } + + /** @return array */ + public function all(): array + { + return $this->blocks; + } +} diff --git a/src/Core/View/Layout/LayoutManager.php b/src/Core/View/Layout/LayoutManager.php new file mode 100644 index 0000000..93ddb2c --- /dev/null +++ b/src/Core/View/Layout/LayoutManager.php @@ -0,0 +1,57 @@ +layoutResolver = new LayoutResolver(); + } + + public function merge(string $templatePath, string $content): string + { + $parent = $this->layoutResolver->extractExtendsPath($content); + if ($parent === null) { + return $content; + } + + $childBlocks = $this->extractBlocks($content); + $parentPath = $this->templateResolver->resolve($parent); + + if (!is_file($parentPath)) { + return $this->layoutResolver->stripExtendsStatement($content); + } + + $parentContent = (string) file_get_contents($parentPath); + + $merged = preg_replace_callback( + '/(.*?)<\/Block>/si', + function (array $matches) use ($childBlocks): string { + $name = $matches[1]; + return $childBlocks[$name] ?? $matches[2]; + }, + $parentContent + ); + + return $merged ?? $parentContent; + } + + /** @return array */ + private function extractBlocks(string $content): array + { + $content = $this->layoutResolver->stripExtendsStatement($content); + preg_match_all('/(.*?)<\/Block>/si', $content, $matches, PREG_SET_ORDER); + $blocks = []; + + foreach ($matches as $match) { + $blocks[$match[1]] = $match[2]; + } + + return $blocks; + } +} diff --git a/src/Core/View/Layout/LayoutResolver.php b/src/Core/View/Layout/LayoutResolver.php new file mode 100644 index 0000000..83738e5 --- /dev/null +++ b/src/Core/View/Layout/LayoutResolver.php @@ -0,0 +1,20 @@ +> */ + public function tokenize(string $content, string $sourceFile = 'template'): array { $tokens = []; $length = strlen($content); $pos = 0; + $line = 1; + $column = 1; while ($pos < $length) { - // Detectar keywords - if (strpos($content, 'extends', $pos) === $pos) { - $tokens[] = ['type' => 'KEYWORD', 'value' => 'extends']; - $pos += 7; + if (substr($content, $pos, 2) === '{{') { + $tokens[] = $this->extractExpression($content, $pos, $line, $column, '{{', '}}', 'EXPRESSION', $sourceFile); continue; } - if (strpos($content, 'import', $pos) === $pos) { - $tokens[] = ['type' => 'KEYWORD', 'value' => 'import']; - $pos += 6; + if (substr($content, $pos, 2) === '{!') { + $tokens[] = $this->extractExpression($content, $pos, $line, $column, '{!', '!}', 'RAW', $sourceFile); continue; } - // Detectar tag de abertura - if ($content[$pos] === '<' && preg_match('/^<([A-Z][a-zA-Z0-9]*)/', substr($content, $pos), $matches)) { - // Isso é uma tag customizada - $token = $this->extractTag($content, $pos); - $tokens[] = $token; - $pos += $token['length']; + if ($content[$pos] === '<' && preg_match('/^<\/?[A-Z]/', substr($content, $pos)) === 1) { + $tokens[] = $this->extractTag($content, $pos, $line, $column, $sourceFile); continue; } - // Detectar expressão {{ }} - if (strpos($content, '{{', $pos) === $pos) { - $token = $this->extractExpression($content, $pos); - $tokens[] = $token; - $pos += $token['length']; - continue; - } + $textStart = $pos; + $textLine = $line; + $textColumn = $column; - // Detectar raw output {! !} - if (strpos($content, '{!', $pos) === $pos) { - $token = $this->extractRaw($content, $pos); - $tokens[] = $token; - $pos += $token['length']; - continue; - } + while ($pos < $length) { + if (substr($content, $pos, 2) === '{{' || substr($content, $pos, 2) === '{!' || ($content[$pos] === '<' && preg_match('/^<\/?[A-Z]/', substr($content, $pos)) === 1)) { + break; + } - // Texto normal - $textLength = 0; - while ($pos + $textLength < $length) { - if (in_array($content[$pos + $textLength], ['<', '{'])) { - // Verifica se é realmente um token - if (preg_match('/^<[A-Z]/', substr($content, $pos + $textLength))) { - break; - } - if (strpos($content, '{{', $pos + $textLength) === $pos + $textLength || - strpos($content, '{!', $pos + $textLength) === $pos + $textLength) { - break; - } + if ($content[$pos] === "\n") { + $line++; + $column = 1; + $pos++; + continue; } - $textLength++; + + $pos++; + $column++; } - if ($textLength > 0) { + if ($pos > $textStart) { $tokens[] = [ 'type' => 'TEXT', - 'value' => substr($content, $pos, $textLength), - 'length' => $textLength + 'value' => substr($content, $textStart, $pos - $textStart), + 'line' => $textLine, + 'column' => $textColumn, + 'source' => $sourceFile, ]; - $pos += $textLength; } } return $tokens; } - /** - * Extrai uma tag customizada - * - * @param string $content Conteúdo - * @param int $pos Posição atual - * @return array Token da tag - */ - private function extractTag(string $content, int $pos): array + /** @return array */ + private function extractExpression(string $content, int &$pos, int &$line, int &$column, string $startDelim, string $endDelim, string $type, string $sourceFile): array { - preg_match('/^<([A-Z][a-zA-Z0-9]*)([^>]*)\s*\/?>/s', substr($content, $pos), $matches); + $startPos = $pos; + $startLine = $line; + $startColumn = $column; - if (empty($matches)) { - throw new SyntaxException("Invalid tag at position $pos"); + $pos += strlen($startDelim); + $column += strlen($startDelim); + + $end = strpos($content, $endDelim, $pos); + if ($end === false) { + throw new SyntaxException("Unclosed expression at {$sourceFile}:{$startLine}:{$startColumn}"); } - $tagName = $matches[1]; - $attributes = trim($matches[2]); - $fullMatch = $matches[0]; - $selfClosing = str_ends_with($fullMatch, '/>'); + $value = trim(substr($content, $pos, $end - $pos)); + $raw = substr($content, $startPos, ($end + strlen($endDelim)) - $startPos); + + $this->advancePosition($raw, $pos = $end + strlen($endDelim), $line, $column, $startPos); return [ - 'type' => 'TAG', - 'name' => $tagName, - 'attributes' => $attributes, - 'self_closing' => $selfClosing, - 'length' => strlen($fullMatch), - 'value' => $fullMatch + 'type' => $type, + 'value' => $value, + 'line' => $startLine, + 'column' => $startColumn, + 'source' => $sourceFile, ]; } - /** - * Extrai uma expressão {{ }} - * - * @param string $content Conteúdo - * @param int $pos Posição atual - * @return array Token da expressão - */ - private function extractExpression(string $content, int $pos): array + /** @return array */ + private function extractTag(string $content, int &$pos, int &$line, int &$column, string $sourceFile): array { - $start = $pos + 2; - $end = strpos($content, '}}', $start); + $start = $pos; + $startLine = $line; + $startColumn = $column; + $length = strlen($content); - if ($end === false) { - throw new SyntaxException("Unclosed expression at position $pos"); - } + $inSingle = false; + $inDouble = false; - $expression = substr($content, $start, $end - $start); - $length = $end - $pos + 2; + while ($pos < $length) { + $char = $content[$pos]; + + if ($char === "'" && !$inDouble) { + $inSingle = !$inSingle; + } elseif ($char === '"' && !$inSingle) { + $inDouble = !$inDouble; + } elseif ($char === '>' && !$inSingle && !$inDouble) { + $pos++; + $column++; + break; + } - return [ - 'type' => 'EXPRESSION', - 'value' => trim($expression), - 'length' => $length - ]; - } + if ($char === "\n") { + $line++; + $column = 1; + } else { + $column++; + } - /** - * Extrai raw output {! !} - * - * @param string $content Conteúdo - * @param int $pos Posição atual - * @return array Token raw - */ - private function extractRaw(string $content, int $pos): array - { - $start = $pos + 2; - $end = strpos($content, '!}', $start); + $pos++; + } - if ($end === false) { - throw new SyntaxException("Unclosed raw output at position $pos"); + $rawTag = substr($content, $start, $pos - $start); + if (!preg_match('/^<\s*(\/)?\s*([A-Z][A-Za-z0-9]*)\b(.*?)\s*(\/?)>$/s', $rawTag, $matches)) { + throw new SyntaxException("Invalid tag at {$sourceFile}:{$startLine}:{$startColumn}"); } - $expression = substr($content, $start, $end - $start); - $length = $end - $pos + 2; + $isClose = $matches[1] === '/'; + $name = $matches[2]; + $attributes = trim($matches[3] ?? ''); + $selfClosing = !$isClose && ($matches[4] === '/'); return [ - 'type' => 'RAW', - 'value' => trim($expression), - 'length' => $length + 'type' => $isClose ? 'TAG_CLOSE' : 'TAG_OPEN', + 'name' => $name, + 'attributes' => $attributes, + 'self_closing' => $selfClosing, + 'value' => $rawTag, + 'line' => $startLine, + 'column' => $startColumn, + 'source' => $sourceFile, ]; } + + private function advancePosition(string $text, int $newPos, int &$line, int &$column, int $oldPos): void + { + for ($i = 0; $i < strlen($text); $i++) { + if ($text[$i] === "\n") { + $line++; + $column = 1; + } else { + $column++; + } + } + } } diff --git a/src/Core/View/Middleware/CacheMiddleware.php b/src/Core/View/Middleware/CacheMiddleware.php new file mode 100644 index 0000000..fd40f25 --- /dev/null +++ b/src/Core/View/Middleware/CacheMiddleware.php @@ -0,0 +1,13 @@ + */ + private array $middlewares = []; + + public function add(AbstractMiddleware $middleware): void + { + $this->middlewares[] = $middleware; + } + + /** + * @param array $context + * @param callable(array): string $destination + */ + public function process(array $context, callable $destination): string + { + $pipeline = array_reduce( + array_reverse($this->middlewares), + fn(callable $next, AbstractMiddleware $middleware) => fn(array $ctx): string => $middleware->handle($ctx, $next), + $destination + ); + + return $pipeline($context); + } +} diff --git a/src/Core/View/Middleware/ProfilingMiddleware.php b/src/Core/View/Middleware/ProfilingMiddleware.php new file mode 100644 index 0000000..57e4772 --- /dev/null +++ b/src/Core/View/Middleware/ProfilingMiddleware.php @@ -0,0 +1,17 @@ + $children */ + public function __construct( + public string $name, + public array $children = [], + int $line = 1, + int $column = 1, + array $metadata = [] + ) { + parent::__construct($line, $column, $metadata); + } + + public function compile(Compiler $compiler): string + { + return $compiler->compileBlockNode($this); + } +} diff --git a/src/Core/View/Nodes/ComponentNode.php b/src/Core/View/Nodes/ComponentNode.php index fd4e062..65b4c1f 100644 --- a/src/Core/View/Nodes/ComponentNode.php +++ b/src/Core/View/Nodes/ComponentNode.php @@ -2,12 +2,25 @@ namespace Beobles\Core\View\Nodes; -class ComponentNode implements NodeInterface +use Beobles\Core\View\Abstract\AbstractNode; +use Beobles\Core\View\Compiler; + +class ComponentNode extends AbstractNode { public function __construct( public string $name, - public array $attributes = [] - ) {} + public array $attributes = [], + int $line = 1, + int $column = 1, + array $metadata = [] + ) { + parent::__construct($line, $column, $metadata); + } + + public function compile(Compiler $compiler): string + { + return $compiler->compileComponentNode($this); + } public function __toString(): string { diff --git a/src/Core/View/Nodes/ExpressionNode.php b/src/Core/View/Nodes/ExpressionNode.php index f12af9c..11611f3 100644 --- a/src/Core/View/Nodes/ExpressionNode.php +++ b/src/Core/View/Nodes/ExpressionNode.php @@ -2,11 +2,24 @@ namespace Beobles\Core\View\Nodes; -class ExpressionNode implements NodeInterface +use Beobles\Core\View\Abstract\AbstractNode; +use Beobles\Core\View\Compiler; + +class ExpressionNode extends AbstractNode { public function __construct( - public string $value - ) {} + public string $value, + int $line = 1, + int $column = 1, + array $metadata = [] + ) { + parent::__construct($line, $column, $metadata); + } + + public function compile(Compiler $compiler): string + { + return $compiler->compileExpressionNode($this); + } public function __toString(): string { diff --git a/src/Core/View/Nodes/ForeachNode.php b/src/Core/View/Nodes/ForeachNode.php new file mode 100644 index 0000000..38769bf --- /dev/null +++ b/src/Core/View/Nodes/ForeachNode.php @@ -0,0 +1,26 @@ + $children */ + public function __construct( + public string $items, + public string $as, + public array $children = [], + int $line = 1, + int $column = 1, + array $metadata = [] + ) { + parent::__construct($line, $column, $metadata); + } + + public function compile(Compiler $compiler): string + { + return $compiler->compileForeachNode($this); + } +} diff --git a/src/Core/View/Nodes/IfNode.php b/src/Core/View/Nodes/IfNode.php index 4422f4e..71831fc 100644 --- a/src/Core/View/Nodes/IfNode.php +++ b/src/Core/View/Nodes/IfNode.php @@ -2,14 +2,25 @@ namespace Beobles\Core\View\Nodes; -class IfNode implements NodeInterface +use Beobles\Core\View\Abstract\AbstractNode; +use Beobles\Core\View\Compiler; + +class IfNode extends AbstractNode { - public function __construct( - public string $condition - ) {} + /** @var array}> */ + public array $branches; + + /** + * @param array}> $branches + */ + public function __construct(array $branches, int $line = 1, int $column = 1, array $metadata = []) + { + parent::__construct($line, $column, $metadata); + $this->branches = $branches; + } - public function __toString(): string + public function compile(Compiler $compiler): string { - return 'IF: ' . $this->condition; + return $compiler->compileIfNode($this); } } diff --git a/src/Core/View/Nodes/IncludeNode.php b/src/Core/View/Nodes/IncludeNode.php new file mode 100644 index 0000000..b6f8865 --- /dev/null +++ b/src/Core/View/Nodes/IncludeNode.php @@ -0,0 +1,24 @@ +compileIncludeNode($this); + } +} diff --git a/src/Core/View/Nodes/NodeInterface.php b/src/Core/View/Nodes/NodeInterface.php index 61b7419..d08a33a 100644 --- a/src/Core/View/Nodes/NodeInterface.php +++ b/src/Core/View/Nodes/NodeInterface.php @@ -2,15 +2,15 @@ namespace Beobles\Core\View\Nodes; -/** - * Interface para nós da AST - */ +use Beobles\Core\View\Compiler; + interface NodeInterface { - /** - * Retorna string de representação - * - * @return string - */ - public function __toString(): string; + public function compile(Compiler $compiler): string; + + public function accept(callable $visitor); + + public function getLine(): int; + + public function getColumn(): int; } diff --git a/src/Core/View/Nodes/RawNode.php b/src/Core/View/Nodes/RawNode.php index 89e3111..7a70b6b 100644 --- a/src/Core/View/Nodes/RawNode.php +++ b/src/Core/View/Nodes/RawNode.php @@ -2,11 +2,24 @@ namespace Beobles\Core\View\Nodes; -class RawNode implements NodeInterface +use Beobles\Core\View\Abstract\AbstractNode; +use Beobles\Core\View\Compiler; + +class RawNode extends AbstractNode { public function __construct( - public string $value - ) {} + public string $value, + int $line = 1, + int $column = 1, + array $metadata = [] + ) { + parent::__construct($line, $column, $metadata); + } + + public function compile(Compiler $compiler): string + { + return $compiler->compileRawNode($this); + } public function __toString(): string { diff --git a/src/Core/View/Nodes/SetNode.php b/src/Core/View/Nodes/SetNode.php new file mode 100644 index 0000000..4c40cbb --- /dev/null +++ b/src/Core/View/Nodes/SetNode.php @@ -0,0 +1,24 @@ +compileSetNode($this); + } +} diff --git a/src/Core/View/Nodes/TextNode.php b/src/Core/View/Nodes/TextNode.php index 6078565..5f42a44 100644 --- a/src/Core/View/Nodes/TextNode.php +++ b/src/Core/View/Nodes/TextNode.php @@ -2,11 +2,24 @@ namespace Beobles\Core\View\Nodes; -class TextNode implements NodeInterface +use Beobles\Core\View\Abstract\AbstractNode; +use Beobles\Core\View\Compiler; + +class TextNode extends AbstractNode { public function __construct( - public string $value - ) {} + public string $value, + int $line = 1, + int $column = 1, + array $metadata = [] + ) { + parent::__construct($line, $column, $metadata); + } + + public function compile(Compiler $compiler): string + { + return $compiler->compileTextNode($this); + } public function __toString(): string { diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index 384cb83..000d788 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -2,220 +2,341 @@ namespace Beobles\Core\View; -use Beobles\Core\View\Nodes\TextNode; -use Beobles\Core\View\Nodes\ExpressionNode; -use Beobles\Core\View\Nodes\RawNode; +use Beobles\Core\View\Directives\BlockDirective; +use Beobles\Core\View\Directives\DirectiveRegistry; +use Beobles\Core\View\Directives\ForeachDirective; +use Beobles\Core\View\Directives\IfDirective; +use Beobles\Core\View\Directives\IncludeDirective; +use Beobles\Core\View\Directives\SetDirective; +use Beobles\Core\View\Exceptions\ParserException; +use Beobles\Core\View\Nodes\BlockNode; use Beobles\Core\View\Nodes\ComponentNode; +use Beobles\Core\View\Nodes\ExpressionNode; +use Beobles\Core\View\Nodes\ForeachNode; use Beobles\Core\View\Nodes\IfNode; -use Beobles\Core\View\Exceptions\ParserException; +use Beobles\Core\View\Nodes\IncludeNode; +use Beobles\Core\View\Nodes\NodeInterface; +use Beobles\Core\View\Nodes\RawNode; +use Beobles\Core\View\Nodes\SetNode; +use Beobles\Core\View\Nodes\TextNode; -/** - * Parser de AST (Abstract Syntax Tree) - * Converte tokens em nós para compilação - */ class Parser { - private array $tokens; + /** @var array> */ + private array $tokens = []; private int $position = 0; + private DirectiveRegistry $directives; - /** - * Faz parse dos tokens - * - * @param array $tokens Tokens da lexer - * @return array AST (nodes) + public function __construct(?DirectiveRegistry $directiveRegistry = null) + { + $this->directives = $directiveRegistry ?? new DirectiveRegistry(); + $this->registerDefaults(); + } + + /** @param array> $tokens + * @return array */ public function parse(array $tokens): array { $this->tokens = $tokens; $this->position = 0; + + return $this->parseNodes(); + } + + /** @return array */ + private function parseNodes(?string $stopTag = null): array + { $nodes = []; - while ($this->position < count($tokens)) { - $token = $this->current(); - - if ($token['type'] === 'TEXT') { - $nodes[] = new TextNode($token['value']); - $this->advance(); - } elseif ($token['type'] === 'EXPRESSION') { - $nodes[] = new ExpressionNode($token['value']); - $this->advance(); - } elseif ($token['type'] === 'RAW') { - $nodes[] = new RawNode($token['value']); - $this->advance(); - } elseif ($token['type'] === 'TAG') { - $node = $this->parseTag(); - if ($node) { - $nodes[] = $node; + while (($token = $this->current()) !== null) { + if ($token['type'] === 'TAG_CLOSE') { + if ($stopTag !== null && strcasecmp($token['name'], $stopTag) === 0) { + $this->advance(); + return $nodes; } - } elseif ($token['type'] === 'KEYWORD') { - $node = $this->parseKeyword(); - if ($node) { - $nodes[] = $node; - } - } else { - $this->advance(); + + $this->error("Unexpected closing tag ", $token); } + + $nodes[] = $this->parseNode(); + } + + if ($stopTag !== null) { + $this->error("Unclosed tag <{$stopTag}>", $this->tokens[count($this->tokens) - 1] ?? ['line' => 1, 'column' => 1, 'source' => 'template']); } return $nodes; } - /** - * Parse de uma tag customizada - * - * @return object Node - */ - private function parseTag(): ?object + private function parseNode(): NodeInterface { $token = $this->current(); - $tagName = $token['name']; + if ($token === null) { + $this->error('Unexpected end of template', ['line' => 1, 'column' => 1, 'source' => 'template']); + } - $this->advance(); + return match ($token['type']) { + 'TEXT' => $this->parseTextNode(), + 'EXPRESSION' => $this->parseExpressionNode(), + 'RAW' => $this->parseRawNode(), + 'TAG_OPEN' => $this->parseTagNode($token), + default => $this->error('Unexpected token type: ' . $token['type'], $token), + }; + } - switch ($tagName) { - case 'If': - return $this->parseIfTag($token); - case 'Block': - return $this->parseBlockTag($token); - case 'Foreach': - return $this->parseForEachTag($token); - case 'Component': - case preg_match('/^[A-Z]/', $tagName) ? $tagName : null: - return $this->parseComponentTag($token); - default: - return null; - } - } - - /** - * Parse de keyword (extends, import) - * - * @return object Node - */ - private function parseKeyword(): ?object + private function parseTextNode(): TextNode { - $token = $this->current(); - $keyword = $token['value']; + $token = $this->consume('TEXT'); + return new TextNode($token['value'], $token['line'], $token['column']); + } + + private function parseExpressionNode(): ExpressionNode + { + $token = $this->consume('EXPRESSION'); + return new ExpressionNode($token['value'], $token['line'], $token['column']); + } + + private function parseRawNode(): RawNode + { + $token = $this->consume('RAW'); + return new RawNode($token['value'], $token['line'], $token['column']); + } + + private function parseTagNode(array $token): NodeInterface + { + $name = $token['name']; + + return match (strtolower($name)) { + 'if' => $this->parseIfNode(), + 'foreach' => $this->parseForeachNode(), + 'block' => $this->parseBlockNode(), + 'set' => $this->parseSetNode(), + 'include' => $this->parseIncludeNode(), + 'elseif', 'else' => $this->error("Unexpected <{$name}> without matching ", $token), + default => $this->parseComponentNode(), + }; + } + + private function parseIfNode(): IfNode + { + $ifToken = $this->consume('TAG_OPEN'); + $directive = $this->directives->get('if'); + $attrs = $directive ? $directive->parseAttributes($ifToken['attributes']) : []; + $condition = trim((string) ($attrs['condition'] ?? '')); + + if ($condition === '') { + $this->error(' requires condition attribute', $ifToken); + } + + $branches = []; + $branches[] = ['condition' => $condition, 'nodes' => $this->parseUntilIfBoundary()]; + + while (($token = $this->current()) !== null && $token['type'] === 'TAG_OPEN') { + if (strcasecmp($token['name'], 'ElseIf') === 0) { + $elseifToken = $this->consume('TAG_OPEN'); + $attrs = $directive ? $directive->parseAttributes($elseifToken['attributes']) : []; + $elseifCondition = trim((string) ($attrs['condition'] ?? '')); + if ($elseifCondition === '') { + $this->error(' requires condition attribute', $elseifToken); + } + + $branches[] = ['condition' => $elseifCondition, 'nodes' => $this->parseUntilIfBoundary()]; + continue; + } + + if (strcasecmp($token['name'], 'Else') === 0) { + $this->consume('TAG_OPEN'); + $branches[] = ['condition' => null, 'nodes' => $this->parseNodes('Else')]; + break; + } + + break; + } + + $this->consumeWhitespaceText(); + + $closeIf = $this->current(); + if ($closeIf === null || $closeIf['type'] !== 'TAG_CLOSE' || strcasecmp($closeIf['name'], 'If') !== 0) { + $this->error('Missing closing ', $ifToken); + } $this->advance(); - // Implementar parsing de keywords conforme necessário - return null; + return new IfNode($branches, $ifToken['line'], $ifToken['column']); } - /** - * Parse de tag If - * - * @param array $token Token da tag - * @return IfNode - */ - private function parseIfTag(array $token): IfNode + /** @return array */ + private function parseUntilIfBoundary(): array + { + $nodes = []; + + while (($token = $this->current()) !== null) { + if ($token['type'] === 'TAG_OPEN' && in_array(strtolower($token['name']), ['elseif', 'else'], true)) { + return $nodes; + } + + if ($token['type'] === 'TAG_CLOSE' && strcasecmp($token['name'], 'If') === 0) { + return $nodes; + } + + $nodes[] = $this->parseNode(); + } + + return $nodes; + } + + private function parseForeachNode(): ForeachNode { - // Extrair condition do atributo - preg_match('/condition\s*=\s*["\']?\{\{(.+?)\}\}["\']?/', $token['attributes'], $matches); - $condition = $matches[1] ?? ''; + $token = $this->consume('TAG_OPEN'); + $directive = $this->directives->get('foreach'); + $attrs = $directive ? $directive->parseAttributes($token['attributes']) : []; - return new IfNode($condition); + $items = trim((string) ($attrs['items'] ?? '')); + $as = trim((string) ($attrs['as'] ?? '')); + + if ($items === '' || $as === '') { + $this->error(' requires items and as attributes', $token); + } + + $children = $this->parseNodes('Foreach'); + + return new ForeachNode($items, $as, $children, $token['line'], $token['column']); } - /** - * Parse de tag Block - * - * @param array $token Token da tag - * @return BlockNode - */ - private function parseBlockTag(array $token): BlockNode + private function parseBlockNode(): BlockNode + { + $token = $this->consume('TAG_OPEN'); + $directive = $this->directives->get('block'); + $attrs = $directive ? $directive->parseAttributes($token['attributes']) : []; + $name = trim((string) ($attrs['name'] ?? '')); + + if ($name === '') { + $this->error(' requires name attribute', $token); + } + + $children = $token['self_closing'] ? [] : $this->parseNodes('Block'); + + return new BlockNode($name, $children, $token['line'], $token['column']); + } + + private function parseSetNode(): SetNode { - preg_match('/name\s*=\s*["\']([^"\']*)["\']/s', $token['attributes'], $matches); - $name = $matches[1] ?? ''; + $token = $this->consume('TAG_OPEN'); + $directive = $this->directives->get('set'); + $attrs = $directive ? $directive->parseAttributes($token['attributes']) : []; + $variable = trim((string) ($attrs['var'] ?? '')); + $value = trim((string) ($attrs['value'] ?? '')); + + if ($variable === '' || $value === '') { + $this->error(' requires var and value attributes', $token); + } - return new BlockNode($name); + if (!$token['self_closing']) { + $this->parseNodes('Set'); + } + + return new SetNode($variable, $value, $token['line'], $token['column']); } - /** - * Parse de tag Foreach - * - * @param array $token Token da tag - * @return ForeachNode - */ - private function parseForEachTag(array $token): ForeachNode + private function parseIncludeNode(): IncludeNode { - preg_match('/items\s*=\s*\{\{(.+?)\}\}/', $token['attributes'], $itemsMatches); - preg_match('/as\s*=\s*["\']([^"\']*)["\']/s', $token['attributes'], $asMatches); + $token = $this->consume('TAG_OPEN'); + $directive = $this->directives->get('include'); + $attrs = $directive ? $directive->parseAttributes($token['attributes']) : []; + $path = trim((string) ($attrs['path'] ?? '')); - $items = $itemsMatches[1] ?? ''; - $as = $asMatches[1] ?? ''; + if ($path === '') { + $this->error(' requires path attribute', $token); + } - return new ForeachNode($items, $as); + if (!$token['self_closing']) { + $this->parseNodes('Include'); + } + + return new IncludeNode($path, $attrs['data'] ?? null, $token['line'], $token['column']); } - /** - * Parse de tag de componente - * - * @param array $token Token da tag - * @return ComponentNode - */ - private function parseComponentTag(array $token): ComponentNode + private function parseComponentNode(): ComponentNode { - $name = $token['name']; + $token = $this->consume('TAG_OPEN'); $attributes = $this->parseAttributes($token['attributes']); - return new ComponentNode($name, $attributes); + if (!$token['self_closing']) { + $this->parseNodes($token['name']); + } + + return new ComponentNode($token['name'], $attributes, $token['line'], $token['column']); } - /** - * Parse de atributos - * - * @param string $attributesStr String de atributos - * @return array Atributos parseados - */ + /** @return array */ private function parseAttributes(string $attributesStr): array { + if (trim($attributesStr) === '') { + return []; + } + $attributes = []; - preg_match_all('/(\w+)\s*=\s*(?:\{\{(.+?)\}\}|["\']([^"\']*)["\']/s', $attributesStr, $matches, PREG_SET_ORDER); + preg_match_all('/([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:\{\{(.+?)\}\}|"([^"]*)"|\'([^\']*)\')/s', $attributesStr, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $name = $match[1]; - $value = $match[2] ?? $match[3] ?? ''; - $attributes[$name] = $value; + $value = $match[2] ?? $match[3] ?? $match[4] ?? ''; + $attributes[$name] = trim($value); } return $attributes; } - /** - * Obtém token atual - * - * @return array Token - */ - private function current(): array + /** @return array */ + private function consume(string $type): array { - return $this->tokens[$this->position] ?? []; + $token = $this->current(); + if ($token === null || $token['type'] !== $type) { + $this->error("Expected {$type}", $token ?? ['line' => 1, 'column' => 1, 'source' => 'template']); + } + + $this->advance(); + + return $token; + } + + private function current(): ?array + { + return $this->tokens[$this->position] ?? null; } - /** - * Avança para próximo token - * - * @return void - */ private function advance(): void { $this->position++; } -} -// Node classes -class BlockNode -{ - public function __construct( - public string $name - ) {} -} + private function consumeWhitespaceText(): void + { + while (($token = $this->current()) !== null && $token['type'] === 'TEXT' && trim((string) $token['value']) === '') { + $this->advance(); + } + } -class ForeachNode -{ - public function __construct( - public string $items, - public string $as - ) {} + private function registerDefaults(): void + { + foreach ([ + new IfDirective(), + new ForeachDirective(), + new BlockDirective(), + new SetDirective(), + new IncludeDirective(), + ] as $directive) { + $this->directives->register($directive); + } + } + + private function error(string $message, array $token): never + { + $line = (int) ($token['line'] ?? 1); + $column = (int) ($token['column'] ?? 1); + $source = (string) ($token['source'] ?? 'template'); + + throw new ParserException(sprintf('%s at %s:%d:%d', $message, $source, $line, $column)); + } } diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 9546f1d..8e08c1a 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -2,33 +2,62 @@ namespace Beobles\Core\View; -/** - * Renderizador de templates compilados - */ +use Beobles\Core\View\Exceptions\RuntimeException; +use Beobles\Core\View\Exceptions\SyntaxException; + class Renderer { - /** - * Renderiza código PHP compilado - * - * @param string $compiledCode Código PHP compilado - * @param array $data Dados para o template - * @param Engine $engine Instância do engine - * @return string Output renderizado - */ - public function render(string $compiledCode, array $data = [], Engine $engine = null): string + public function __construct(private ?string $compiledTemplatesDir = null) { - // Criar escopo de variáveis - extract($data, EXTR_SKIP); - $__engine = $engine; + } + + public function render(string $compiledCode, array $data = [], ?Engine $engine = null): string + { + $compiledFile = $this->writeCompiledFile($compiledCode, $engine); + $this->assertValidPhp($compiledFile); + + $render = static function (string $__compiledFile, array $__data, ?Engine $__engine): string { + extract($__data, EXTR_SKIP); + ob_start(); + include $__compiledFile; + return (string) ob_get_clean(); + }; - // Capturar output - ob_start(); try { - eval('?>' . $compiledCode); - return ob_get_clean(); - } catch (\Exception $e) { - ob_end_clean(); - throw $e; + return $render($compiledFile, $data, $engine); + } catch (\Throwable $e) { + throw new RuntimeException('Runtime render error: ' . $e->getMessage(), 0, $e); + } + } + + private function writeCompiledFile(string $compiledCode, ?Engine $engine): string + { + $targetDir = $this->compiledTemplatesDir; + if ($targetDir === null && $engine !== null) { + $targetDir = $engine->getEnvironment()->getConfig('compiled_templates_dir', $engine->getEnvironment()->getConfig('cache_dir', sys_get_temp_dir())); + } + + $targetDir ??= sys_get_temp_dir(); + + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + $file = rtrim($targetDir, '/\\') . '/tpl_' . md5($compiledCode) . '.php'; + file_put_contents($file, $compiledCode); + + return $file; + } + + private function assertValidPhp(string $compiledFile): void + { + $phpBinary = PHP_BINARY ?: 'php'; + $output = []; + $status = 0; + @exec(escapeshellcmd($phpBinary) . ' -l ' . escapeshellarg($compiledFile) . ' 2>&1', $output, $status); + + if ($status !== 0) { + throw new SyntaxException('Compiled template syntax error: ' . implode("\n", $output)); } } } diff --git a/src/Core/View/Scope/Scope.php b/src/Core/View/Scope/Scope.php new file mode 100644 index 0000000..61e990e --- /dev/null +++ b/src/Core/View/Scope/Scope.php @@ -0,0 +1,30 @@ + */ + private array $variables = []; + + public function set(string $name, mixed $value): void + { + $this->variables[$name] = $value; + } + + public function has(string $name): bool + { + return array_key_exists($name, $this->variables); + } + + public function get(string $name, mixed $default = null): mixed + { + return $this->variables[$name] ?? $default; + } + + /** @return array */ + public function all(): array + { + return $this->variables; + } +} diff --git a/src/Core/View/Scope/ScopeStack.php b/src/Core/View/Scope/ScopeStack.php new file mode 100644 index 0000000..3e63c6d --- /dev/null +++ b/src/Core/View/Scope/ScopeStack.php @@ -0,0 +1,70 @@ + */ + private array $stack = []; + + public function __construct() + { + $this->push(new Scope()); + } + + public function push(?Scope $scope = null): Scope + { + $scope ??= new Scope(); + $this->stack[] = $scope; + + return $scope; + } + + public function pop(): ?Scope + { + if (count($this->stack) <= 1) { + return $this->stack[0] ?? null; + } + + return array_pop($this->stack); + } + + public function set(string $name, mixed $value): void + { + $current = $this->current(); + if ($current) { + $current->set($name, $value); + } + } + + public function get(string $name, mixed $default = null): mixed + { + for ($i = count($this->stack) - 1; $i >= 0; $i--) { + if ($this->stack[$i]->has($name)) { + return $this->stack[$i]->get($name); + } + } + + return $default; + } + + /** @return array */ + public function toArray(): array + { + $merged = []; + foreach ($this->stack as $scope) { + $merged = array_merge($merged, $scope->all()); + } + + return $merged; + } + + private function current(): ?Scope + { + if ($this->stack === []) { + return null; + } + + return $this->stack[count($this->stack) - 1]; + } +} diff --git a/src/Core/View/Scope/Variable.php b/src/Core/View/Scope/Variable.php new file mode 100644 index 0000000..6419718 --- /dev/null +++ b/src/Core/View/Scope/Variable.php @@ -0,0 +1,11 @@ +templatesDir . '/' . ltrim($path, '/'); + $realTemplateDir = realpath($this->templatesDir); + $realCandidate = realpath($candidate); + + if ($realTemplateDir === false) { + throw new ViewException('Templates directory is invalid: ' . $this->templatesDir); + } + + if ($realCandidate === false) { + return $candidate; + } + + if (!str_starts_with($realCandidate, $realTemplateDir)) { + throw new ViewException('Template path traversal is not allowed: ' . $path); + } + + return $realCandidate; + } +} diff --git a/src/Core/View/Validation/SyntaxValidator.php b/src/Core/View/Validation/SyntaxValidator.php new file mode 100644 index 0000000..1fbacd1 --- /dev/null +++ b/src/Core/View/Validation/SyntaxValidator.php @@ -0,0 +1,23 @@ +/i', $template); + if ($ifOpen !== $ifClose) { + throw new SyntaxException('Unbalanced tags in template'); + } + + $foreachOpen = preg_match_all('//i', $template); + if ($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 new file mode 100644 index 0000000..6557790 --- /dev/null +++ b/src/Core/View/Validation/TemplateValidator.php @@ -0,0 +1,16 @@ +syntaxValidator ??= new SyntaxValidator(); + } + + public function validate(string $template): void + { + $this->syntaxValidator->validate($template); + } +}