From b9215ff603a0d96009400c2b92449f498896ff5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:04:37 +0000 Subject: [PATCH 01/17] Stabilize parser/compiler and harden template engine runtime --- src/Core/View/Compiler.php | 274 ++++++++++++++++++++++- src/Core/View/Engine.php | 27 ++- src/Core/View/Filters/FilterRegistry.php | 20 +- src/Core/View/Lexer.php | 20 +- src/Core/View/Parser.php | 101 +++++++-- src/Core/View/Renderer.php | 2 +- 6 files changed, 401 insertions(+), 43 deletions(-) diff --git a/src/Core/View/Compiler.php b/src/Core/View/Compiler.php index 5a8e31d..49c26d5 100644 --- a/src/Core/View/Compiler.php +++ b/src/Core/View/Compiler.php @@ -17,6 +17,31 @@ class Compiler public function compile(array $nodes): string { $code = "{\$segment})) {\n"; + $code .= " \$value = \$value->{\$segment};\n"; + $code .= " continue;\n"; + $code .= " }\n"; + $code .= " \$getter = 'get' . ucfirst(\$segment);\n"; + $code .= " if (method_exists(\$value, \$getter)) {\n"; + $code .= " \$value = \$value->{\$getter}();\n"; + $code .= " continue;\n"; + $code .= " }\n"; + $code .= " }\n"; + $code .= " return null;\n"; + $code .= " }\n"; + $code .= " return \$value;\n"; + $code .= " }\n"; + $code .= "}\n\n"; foreach ($nodes as $node) { $code .= $this->compileNode($node); @@ -33,7 +58,7 @@ public function compile(array $nodes): string */ private function compileNode(object $node): string { - $class = class_basename($node); + $class = (new \ReflectionClass($node))->getShortName(); return match ($class) { 'TextNode' => $this->compileText($node), @@ -41,8 +66,11 @@ private function compileNode(object $node): string 'RawNode' => $this->compileRaw($node), 'ComponentNode' => $this->compileComponent($node), 'IfNode' => $this->compileIf($node), + 'ElseNode' => $this->compileElse(), + 'ElseIfNode' => $this->compileElseIf($node), 'BlockNode' => $this->compileBlock($node), 'ForeachNode' => $this->compileForeach($node), + 'CloseTagNode' => $this->compileCloseTag($node), default => '' }; } @@ -66,8 +94,12 @@ private function compileText(object $node): string */ private function compileExpression(object $node): string { - $escaped = 'htmlspecialchars(' . $node->value . ', ENT_QUOTES, "UTF-8")'; - return 'echo ' . $escaped . ";\n"; + [$expression, $isRaw] = $this->compileExpressionChain($node->value); + $rendered = $isRaw + ? $expression + : 'htmlspecialchars((string)(' . $expression . '), ENT_QUOTES, "UTF-8")'; + + return 'echo ' . $rendered . ";\n"; } /** @@ -78,7 +110,7 @@ private function compileExpression(object $node): string */ private function compileRaw(object $node): string { - return 'echo ' . $node->value . ";\n"; + return 'echo (string)(' . $this->compilePhpExpression($node->value) . ");\n"; } /** @@ -90,7 +122,7 @@ private function compileRaw(object $node): string private function compileComponent(object $node): string { $props = 'array(' . implode(', ', array_map( - fn($k, $v) => "'" . $k . "' => " . $v, + fn($k, $v) => "'" . $k . "' => " . $this->compilePhpExpression((string) $v), array_keys($node->attributes), $node->attributes )) . ')'; @@ -106,7 +138,17 @@ private function compileComponent(object $node): string */ private function compileIf(object $node): string { - return 'if (' . $node->condition . ") {\n"; + return 'if (' . $this->compilePhpExpression($node->condition) . ") {\n"; + } + + private function compileElse(): string + { + return "} else {\n"; + } + + private function compileElseIf(object $node): string + { + return '} elseif (' . $this->compilePhpExpression($node->condition) . ") {\n"; } /** @@ -128,6 +170,224 @@ private function compileBlock(object $node): string */ private function compileForeach(object $node): string { - return 'foreach (' . $node->items . ' as ' . $node->as . ") {\n"; + $items = $this->compilePhpExpression($node->items); + $vars = array_values(array_filter(array_map('trim', explode(',', $node->as)))); + + if (count($vars) >= 2) { + $valueVar = '$' . ltrim($vars[0], '$'); + $keyVar = '$' . ltrim($vars[1], '$'); + return 'foreach ((array)(' . $items . ') as ' . $keyVar . ' => ' . $valueVar . ") {\n"; + } + + $valueVar = '$' . ltrim($vars[0] ?? 'item', '$'); + return 'foreach ((array)(' . $items . ') as ' . $valueVar . ") {\n"; + } + + private function compileCloseTag(object $node): string + { + return match ($node->name) { + 'If', 'Foreach', 'Block' => "}\n", + default => '', + }; + } + + /** + * @return array{0:string,1:bool} + */ + private function compileExpressionChain(string $expression): array + { + $parts = $this->splitByPipes($expression); + $baseExpression = array_shift($parts) ?? ''; + $code = $this->compilePhpExpression($baseExpression); + $isRaw = false; + + foreach ($parts as $filterPart) { + $filterPart = trim($filterPart); + if ($filterPart === '') { + continue; + } + + if (preg_match('/^(\w+)(?:\((.*)\))?$/s', $filterPart, $matches) !== 1) { + continue; + } + + $name = $matches[1]; + $args = trim($matches[2] ?? ''); + $argsCode = $args === '' ? '[]' : '[' . $args . ']'; + $code = '$__engine->applyFilter(' . $code . ', ' . var_export($name, true) . ', ' . $argsCode . ')'; + $isRaw = $name === 'raw'; + } + + return [$code, $isRaw]; + } + + private function splitByPipes(string $value): array + { + $parts = []; + $buffer = ''; + $depth = 0; + $quote = null; + $len = strlen($value); + + for ($i = 0; $i < $len; $i++) { + $char = $value[$i]; + if ($quote !== null) { + if ($char === $quote && ($i === 0 || $value[$i - 1] !== '\\')) { + $quote = null; + } + $buffer .= $char; + continue; + } + + if ($char === '"' || $char === "'") { + $quote = $char; + $buffer .= $char; + continue; + } + + if (in_array($char, ['(', '[', '{'], true)) { + $depth++; + $buffer .= $char; + continue; + } + + if (in_array($char, [')', ']', '}'], true)) { + $depth = max(0, $depth - 1); + $buffer .= $char; + continue; + } + + if ($char === '|' && $depth === 0) { + $parts[] = trim($buffer); + $buffer = ''; + continue; + } + + $buffer .= $char; + } + + $parts[] = trim($buffer); + return $parts; + } + + private function compilePhpExpression(string $expression): string + { + $expression = trim($expression); + if ($expression === '') { + return 'null'; + } + + $placeholderMap = []; + $counter = 0; + $expression = preg_replace_callback('/\b([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+)\b/', function ($matches) use (&$placeholderMap, &$counter) { + $placeholder = "__TPL_DOT_{$counter}__"; + $placeholderMap[$placeholder] = "__tpl_get(get_defined_vars(), '" . $matches[1] . "')"; + $counter++; + return $placeholder; + }, $expression); + + $tokens = token_get_all('isReservedIdentifier($text)) { + $result .= $text; + continue; + } + + $next = $this->nextNonWhitespaceToken($tokens, $i + 1); + if ($next === '(') { + $result .= $text; + continue; + } + + $prev = $this->prevNonWhitespaceToken($tokens, $i - 1); + if ($prev === '->' || $prev === '::' || $prev === '$') { + $result .= $text; + continue; + } + + $result .= '$' . $text; + } + + return $result; + } + + private function isReservedIdentifier(string $value): bool + { + if (preg_match('/^[A-Z_][A-Z0-9_]*$/', $value) === 1) { + return true; + } + + static $reserved = [ + 'true', 'false', 'null', 'and', 'or', 'xor', 'instanceof', 'new', + 'clone', 'match', 'fn', 'array', 'parent', 'self', 'static', + ]; + + return in_array(strtolower($value), $reserved, true); + } + + /** + * @param array $tokens + */ + private function nextNonWhitespaceToken(array $tokens, int $start) + { + for ($i = $start, $len = count($tokens); $i < $len; $i++) { + $token = $tokens[$i]; + if (is_string($token)) { + if (trim($token) !== '') { + return $token; + } + continue; + } + + if ($token[0] !== T_WHITESPACE) { + return $token[1]; + } + } + + return null; + } + + /** + * @param array $tokens + */ + private function prevNonWhitespaceToken(array $tokens, int $start) + { + for ($i = $start; $i >= 0; $i--) { + $token = $tokens[$i]; + if (is_string($token)) { + if (trim($token) !== '') { + return $token; + } + continue; + } + + if ($token[0] !== T_WHITESPACE) { + return $token[1]; + } + } + + return null; } } diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index f8f74b8..30ea9fd 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -15,6 +15,7 @@ */ class Engine { + private const CACHE_VERSION = '3'; private string $templatesDir; private string $cacheDir; private bool $autoEscape; @@ -52,7 +53,9 @@ public function __construct(array $config = []) // Criar cache dir se necessário if ($this->cacheEnabled && !is_dir($this->cacheDir)) { - mkdir($this->cacheDir, 0755, true); + if (!mkdir($this->cacheDir, 0755, true) && !is_dir($this->cacheDir)) { + throw new ViewException("Unable to create cache directory: {$this->cacheDir}"); + } } // Inicializar componentes @@ -85,7 +88,7 @@ public function render(string $templatePath, array $data = []): string // Verificar cache if ($this->cacheEnabled) { - $cacheKey = $this->generateCacheKey($templatePath); + $cacheKey = $this->generateCacheKey($absolutePath); $cachedContent = $this->cacheManager->get($cacheKey); if ($cachedContent !== null) { @@ -95,6 +98,9 @@ public function render(string $templatePath, array $data = []): string // Ler template $content = file_get_contents($absolutePath); + if ($content === false) { + throw new ViewException("Unable to read template: {$templatePath}"); + } // Tokenizar $tokens = $this->lexer->tokenize($content); @@ -149,6 +155,11 @@ public function registerFilter(string $name, callable $callback): void */ public function resolveTemplatePath(string $path): string { + $path = ltrim($path, '/'); + if (str_contains($path, '..')) { + throw new ViewException("Invalid template path: {$path}"); + } + // Resolver alias @components if (strpos($path, '@') === 0) { $path = str_replace('@components/', 'components/', $path); @@ -159,7 +170,12 @@ public function resolveTemplatePath(string $path): string $path .= '.html'; } - return $this->templatesDir . '/' . $path; + $resolved = realpath($this->templatesDir . '/' . $path); + if ($resolved === false || strpos($resolved, realpath($this->templatesDir)) !== 0) { + return $this->templatesDir . '/' . $path; + } + + return $resolved; } /** @@ -168,9 +184,10 @@ public function resolveTemplatePath(string $path): string * @param string $path Caminho do template * @return string Chave de cache */ - private function generateCacheKey(string $path): string + private function generateCacheKey(string $absolutePath): string { - return 'template_' . md5($path); + $modifiedAt = file_exists($absolutePath) ? (string) filemtime($absolutePath) : '0'; + return 'template_' . md5(self::CACHE_VERSION . '|' . $absolutePath . '|' . $modifiedAt); } /** diff --git a/src/Core/View/Filters/FilterRegistry.php b/src/Core/View/Filters/FilterRegistry.php index 0de4e19..2ca1281 100644 --- a/src/Core/View/Filters/FilterRegistry.php +++ b/src/Core/View/Filters/FilterRegistry.php @@ -54,9 +54,9 @@ public function apply($value, string $filter, array $args = []) 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('uppercase', fn($v) => function_exists('mb_strtoupper') ? mb_strtoupper((string) $v, 'UTF-8') : strtoupper((string) $v)); + $this->register('lowercase', fn($v) => function_exists('mb_strtolower') ? mb_strtolower((string) $v, 'UTF-8') : strtolower((string) $v)); + $this->register('ucfirst', fn($v) => ucfirst((string) $v)); $this->register('reverse', fn($v) => strrev($v)); $this->register('trim', fn($v) => trim($v)); $this->register('ltrim', fn($v) => ltrim($v)); @@ -69,9 +69,9 @@ private function registerDefaultFilters(): void // Number filters $this->register('currency', fn($v, $currency = 'BRL') => - $currency === 'BRL' ? 'R\$ ' . number_format($v, 2, ',', '.') : '$' . number_format($v, 2) + $currency === 'BRL' ? 'R$ ' . number_format((float) $v, 2, ',', '.') : '$' . number_format((float) $v, 2) ); - $this->register('number_format', fn($v, $decimals = 0) => number_format($v, $decimals, ',', '.')); + $this->register('number_format', fn($v, $decimals = 0) => number_format((float) $v, (int) $decimals, ',', '.')); $this->register('abs', fn($v) => abs($v)); // Date filter @@ -80,11 +80,11 @@ private function registerDefaultFilters(): void ); // 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)); + $this->register('count', fn($v) => is_countable($v) ? count($v) : 0); + $this->register('first', fn($v) => (is_array($v) || $v instanceof \Traversable) ? (is_array($v) ? reset($v) : (function () use ($v) { foreach ($v as $item) { return $item; } return null; })()) : null); + $this->register('last', fn($v) => (is_array($v) && !empty($v)) ? end($v) : null); + $this->register('reverse_array', fn($v) => is_array($v) ? array_reverse($v) : $v); + $this->register('join', fn($v, $sep = ',') => implode($sep, is_array($v) ? $v : (is_iterable($v) ? iterator_to_array($v) : [$v]))); // JSON $this->register('json', fn($v) => json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index fd73a0e..0672b06 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -31,18 +31,31 @@ public function tokenize(string $content): array while ($pos < $length) { // Detectar keywords - if (strpos($content, 'extends', $pos) === $pos) { + if (preg_match('/^extends\b/', substr($content, $pos)) === 1) { $tokens[] = ['type' => 'KEYWORD', 'value' => 'extends']; $pos += 7; continue; } - if (strpos($content, 'import', $pos) === $pos) { + if (preg_match('/^import\b/', substr($content, $pos)) === 1) { $tokens[] = ['type' => 'KEYWORD', 'value' => 'import']; $pos += 6; continue; } + // Detectar tag de fechamento + if ($content[$pos] === '<' && preg_match('/^<\/([A-Z][a-zA-Z0-9]*)\s*>/', substr($content, $pos), $matches)) { + $fullMatch = $matches[0]; + $tokens[] = [ + 'type' => 'TAG_CLOSE', + 'name' => $matches[1], + 'length' => strlen($fullMatch), + 'value' => $fullMatch + ]; + $pos += strlen($fullMatch); + continue; + } + // Detectar tag de abertura if ($content[$pos] === '<' && preg_match('/^<([A-Z][a-zA-Z0-9]*)/', substr($content, $pos), $matches)) { // Isso é uma tag customizada @@ -76,6 +89,9 @@ public function tokenize(string $content): array if (preg_match('/^<[A-Z]/', substr($content, $pos + $textLength))) { break; } + if (preg_match('/^<\/[A-Z]/', substr($content, $pos + $textLength))) { + break; + } if (strpos($content, '{{', $pos + $textLength) === $pos + $textLength || strpos($content, '{!', $pos + $textLength) === $pos + $textLength) { break; diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index 384cb83..b83c853 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -7,7 +7,6 @@ use Beobles\Core\View\Nodes\RawNode; use Beobles\Core\View\Nodes\ComponentNode; use Beobles\Core\View\Nodes\IfNode; -use Beobles\Core\View\Exceptions\ParserException; /** * Parser de AST (Abstract Syntax Tree) @@ -47,6 +46,11 @@ public function parse(array $tokens): array if ($node) { $nodes[] = $node; } + } elseif ($token['type'] === 'TAG_CLOSE') { + $node = $this->parseCloseTag(); + if ($node) { + $nodes[] = $node; + } } elseif ($token['type'] === 'KEYWORD') { $node = $this->parseKeyword(); if ($node) { @@ -75,18 +79,38 @@ private function parseTag(): ?object switch ($tagName) { case 'If': return $this->parseIfTag($token); + case 'Else': + return new ElseNode(); + case 'ElseIf': + return $this->parseElseIfTag($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: + if (preg_match('/^[A-Z]/', $tagName) === 1) { + return $this->parseComponentTag($token); + } return null; } } + /** + * Parse de tag de fechamento + */ + private function parseCloseTag(): ?object + { + $token = $this->current(); + $this->advance(); + + return match ($token['name']) { + 'If', 'Foreach', 'Block' => new CloseTagNode($token['name']), + default => null, + }; + } + /** * Parse de keyword (extends, import) * @@ -111,11 +135,15 @@ private function parseKeyword(): ?object */ private function parseIfTag(array $token): IfNode { - // Extrair condition do atributo - preg_match('/condition\s*=\s*["\']?\{\{(.+?)\}\}["\']?/', $token['attributes'], $matches); - $condition = $matches[1] ?? ''; + return new IfNode($this->extractAttributeValue($token['attributes'], 'condition')); + } - return new IfNode($condition); + /** + * Parse de tag ElseIf + */ + private function parseElseIfTag(array $token): ElseIfNode + { + return new ElseIfNode($this->extractAttributeValue($token['attributes'], 'condition')); } /** @@ -126,10 +154,7 @@ private function parseIfTag(array $token): IfNode */ private function parseBlockTag(array $token): BlockNode { - preg_match('/name\s*=\s*["\']([^"\']*)["\']/s', $token['attributes'], $matches); - $name = $matches[1] ?? ''; - - return new BlockNode($name); + return new BlockNode($this->extractAttributeValue($token['attributes'], 'name')); } /** @@ -140,11 +165,8 @@ private function parseBlockTag(array $token): BlockNode */ private function parseForEachTag(array $token): ForeachNode { - preg_match('/items\s*=\s*\{\{(.+?)\}\}/', $token['attributes'], $itemsMatches); - preg_match('/as\s*=\s*["\']([^"\']*)["\']/s', $token['attributes'], $asMatches); - - $items = $itemsMatches[1] ?? ''; - $as = $asMatches[1] ?? ''; + $items = $this->extractAttributeValue($token['attributes'], 'items'); + $as = $this->extractAttributeValue($token['attributes'], 'as'); return new ForeachNode($items, $as); } @@ -172,17 +194,42 @@ private function parseComponentTag(array $token): ComponentNode private function parseAttributes(string $attributesStr): array { $attributes = []; - preg_match_all('/(\w+)\s*=\s*(?:\{\{(.+?)\}\}|["\']([^"\']*)["\']/s', $attributesStr, $matches, PREG_SET_ORDER); + if (trim($attributesStr) === '') { + return $attributes; + } + + preg_match_all('/(\w+)\s*=\s*(?:\{\{\s*(.+?)\s*\}\}|["\']([^"\']*)["\'])/s', $attributesStr, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $name = $match[1]; - $value = $match[2] ?? $match[3] ?? ''; - $attributes[$name] = $value; + $value = isset($match[2]) && $match[2] !== '' + ? trim($match[2]) + : var_export($match[3] ?? '', true); + $attributes[$name] = trim($value); } return $attributes; } + private function extractAttributeValue(string $attributes, string $name): string + { + $value = ''; + + if (preg_match('/' . preg_quote($name, '/') . '\s*=\s*\{\{\s*(.*?)\s*\}\}/s', $attributes, $matches) === 1) { + $value = trim($matches[1]); + } elseif (preg_match('/' . preg_quote($name, '/') . '\s*=\s*"([^"]*)"/s', $attributes, $matches) === 1) { + $value = trim($matches[1]); + } elseif (preg_match('/' . preg_quote($name, '/') . '\s*=\s*\'([^\']*)\'/s', $attributes, $matches) === 1) { + $value = trim($matches[1]); + } + + if (preg_match('/^\{\{\s*(.*?)\s*\}\}$/s', $value, $dynamic) === 1) { + return trim($dynamic[1]); + } + + return $value; + } + /** * Obtém token atual * @@ -219,3 +266,21 @@ public function __construct( public string $as ) {} } + +class ElseNode +{ +} + +class ElseIfNode +{ + public function __construct( + public string $condition + ) {} +} + +class CloseTagNode +{ + public function __construct( + public string $name + ) {} +} diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 9546f1d..759110e 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -26,7 +26,7 @@ public function render(string $compiledCode, array $data = [], Engine $engine = try { eval('?>' . $compiledCode); return ob_get_clean(); - } catch (\Exception $e) { + } catch (\Throwable $e) { ob_end_clean(); throw $e; } From 7ee312bdfd8c369c727334f31a6bc29b899822ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:20:37 +0000 Subject: [PATCH 02/17] refactor: self-compiling nodes, NodeVisitor, thin Compiler orchestrator --- .../View/Compilation/CompilationContext.php | 65 +++ .../View/Compilation/ExpressionCompiler.php | 276 ++++++++++++ src/Core/View/Compiler.php | 401 ++---------------- src/Core/View/Engine.php | 11 +- src/Core/View/NodeVisitor/NodeTraverser.php | 59 +++ .../View/NodeVisitor/NodeVisitorInterface.php | 31 ++ src/Core/View/Nodes/BlockNode.php | 26 ++ src/Core/View/Nodes/CloseTagNode.php | 27 ++ src/Core/View/Nodes/ComponentNode.php | 18 +- src/Core/View/Nodes/ElseIfNode.php | 20 + src/Core/View/Nodes/ElseNode.php | 18 + src/Core/View/Nodes/ExpressionNode.php | 17 +- src/Core/View/Nodes/ForeachNode.php | 33 ++ src/Core/View/Nodes/IfNode.php | 11 +- src/Core/View/Nodes/NodeInterface.php | 17 +- src/Core/View/Nodes/RawNode.php | 11 +- src/Core/View/Nodes/TextNode.php | 11 +- src/Core/View/Parser.php | 297 +++++-------- 18 files changed, 771 insertions(+), 578 deletions(-) create mode 100644 src/Core/View/Compilation/CompilationContext.php create mode 100644 src/Core/View/Compilation/ExpressionCompiler.php create mode 100644 src/Core/View/NodeVisitor/NodeTraverser.php create mode 100644 src/Core/View/NodeVisitor/NodeVisitorInterface.php create mode 100644 src/Core/View/Nodes/BlockNode.php create mode 100644 src/Core/View/Nodes/CloseTagNode.php create mode 100644 src/Core/View/Nodes/ElseIfNode.php create mode 100644 src/Core/View/Nodes/ElseNode.php create mode 100644 src/Core/View/Nodes/ForeachNode.php diff --git a/src/Core/View/Compilation/CompilationContext.php b/src/Core/View/Compilation/CompilationContext.php new file mode 100644 index 0000000..e86d0aa --- /dev/null +++ b/src/Core/View/Compilation/CompilationContext.php @@ -0,0 +1,65 @@ +expr() ou $ctx->exprChain() e depois + * $ctx->writeLine() sem precisar conhecer os detalhes de compilação. + */ +class CompilationContext +{ + private string $buffer = ''; + private ExpressionCompiler $expressionCompiler; + + public function __construct(ExpressionCompiler $expressionCompiler) + { + $this->expressionCompiler = $expressionCompiler; + } + + // ----------------------------------------------------------------------- + // Buffer de escrita + // ----------------------------------------------------------------------- + + public function write(string $code): void + { + $this->buffer .= $code; + } + + public function writeLine(string $code = ''): void + { + $this->buffer .= $code . "\n"; + } + + public function getCode(): string + { + return $this->buffer; + } + + // ----------------------------------------------------------------------- + // Delegação para ExpressionCompiler + // ----------------------------------------------------------------------- + + /** + * Compila uma expressão simples (sem filtros) para PHP. + */ + public function expr(string $expression): string + { + return $this->expressionCompiler->compile($expression); + } + + /** + * Compila uma expressão com possível cadeia de filtros. + * + * @return array{0: string, 1: bool} [código PHP, isRaw] + */ + public function exprChain(string $expression): array + { + return $this->expressionCompiler->compileChain($expression); + } +} diff --git a/src/Core/View/Compilation/ExpressionCompiler.php b/src/Core/View/Compilation/ExpressionCompiler.php new file mode 100644 index 0000000..4ae2fc4 --- /dev/null +++ b/src/Core/View/Compilation/ExpressionCompiler.php @@ -0,0 +1,276 @@ +{$segment})) { + $value = $value->{$segment}; + continue; + } + $getter = 'get' . ucfirst($segment); + if (method_exists($value, $getter)) { + $value = $value->{$getter}(); + continue; + } + } + return null; + } + return $value; + } +} + +PHP; + } + + /** + * Compila uma expressão simples (sem filtros) para PHP. + * + * Exemplos: + * "user.name" → "__tpl_get(get_defined_vars(), 'user.name')" + * "count" → "$count" + * "user.age > 18 ? 'M' : 'm'" → mantém operadores intactos + */ + public function compile(string $expression): string + { + $expression = trim($expression); + if ($expression === '') { + return 'null'; + } + + // Substituir dot-notation por placeholders antes de tokenizar + $placeholders = []; + $counter = 0; + $expression = preg_replace_callback( + '/\b([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+)\b/', + function (array $matches) use (&$placeholders, &$counter): string { + $key = "__TPL_DOT_{$counter}__"; + $placeholders[$key] = "__tpl_get(get_defined_vars(), '" . $matches[1] . "')"; + ++$counter; + return $key; + }, + $expression + ); + + $phpTokens = token_get_all('isReserved($text)) { + $result .= $text; + continue; + } + + // Chamada de função: next token é ( + $next = $this->nextSignificant($phpTokens, $i + 1); + if ($next === '(') { + $result .= $text; + continue; + } + + // Acesso a propriedade/método: precedido de -> ou :: + $prev = $this->prevSignificant($phpTokens, $i - 1); + if ($prev === '->' || $prev === '::' || $prev === '$') { + $result .= $text; + continue; + } + + $result .= '$' . $text; + } + + return $result; + } + + /** + * Compila uma expressão que pode conter cadeia de filtros. + * + * Retorna [código PHP, isRaw]. + * isRaw = true quando o último filtro da cadeia for "raw". + * + * @return array{0: string, 1: bool} + */ + public function compileChain(string $expression): array + { + $parts = $this->splitByPipes($expression); + $baseExpression = array_shift($parts) ?? ''; + $code = $this->compile($baseExpression); + $isRaw = false; + + foreach ($parts as $filterPart) { + $filterPart = trim($filterPart); + if ($filterPart === '') { + continue; + } + + if (preg_match('/^(\w+)(?:\((.*)\))?$/s', $filterPart, $matches) !== 1) { + continue; + } + + $name = $matches[1]; + $rawArgs = trim($matches[2] ?? ''); + $argsCode = $rawArgs === '' ? '[]' : '[' . $rawArgs . ']'; + + $code = '$__engine->applyFilter(' . $code . ', ' . var_export($name, true) . ', ' . $argsCode . ')'; + $isRaw = ($name === 'raw'); + } + + return [$code, $isRaw]; + } + + // ----------------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------------- + + private function isReserved(string $value): bool + { + // Constantes ALL_CAPS + if (preg_match('/^[A-Z_][A-Z0-9_]*$/', $value) === 1) { + return true; + } + + static $reserved = [ + 'true', 'false', 'null', 'and', 'or', 'xor', + 'instanceof', 'new', 'clone', 'match', 'fn', + 'array', 'parent', 'self', 'static', + ]; + + return in_array(strtolower($value), $reserved, true); + } + + /** @param array $tokens */ + private function nextSignificant(array $tokens, int $start): ?string + { + for ($i = $start, $len = count($tokens); $i < $len; $i++) { + $token = $tokens[$i]; + if (is_string($token)) { + return trim($token) !== '' ? $token : null; + } + if ($token[0] !== T_WHITESPACE) { + return $token[1]; + } + } + return null; + } + + /** @param array $tokens */ + private function prevSignificant(array $tokens, int $start): ?string + { + for ($i = $start; $i >= 0; $i--) { + $token = $tokens[$i]; + if (is_string($token)) { + return trim($token) !== '' ? $token : null; + } + if ($token[0] !== T_WHITESPACE) { + return $token[1]; + } + } + return null; + } + + /** + * Divide a string de expressão em partes pelo pipe |, + * respeitando strings, parênteses, colchetes e chaves. + * + * @return string[] + */ + private function splitByPipes(string $value): array + { + $parts = []; + $buffer = ''; + $depth = 0; + $quote = null; + $len = strlen($value); + + for ($i = 0; $i < $len; $i++) { + $char = $value[$i]; + + if ($quote !== null) { + if ($char === $quote && ($i === 0 || $value[$i - 1] !== '\\')) { + $quote = null; + } + $buffer .= $char; + continue; + } + + if ($char === '"' || $char === "'") { + $quote = $char; + $buffer .= $char; + continue; + } + + if (in_array($char, ['(', '[', '{'], true)) { + ++$depth; + $buffer .= $char; + continue; + } + + if (in_array($char, [')', ']', '}'], true)) { + $depth = max(0, $depth - 1); + $buffer .= $char; + continue; + } + + if ($char === '|' && $depth === 0) { + $parts[] = trim($buffer); + $buffer = ''; + continue; + } + + $buffer .= $char; + } + + $parts[] = trim($buffer); + + return $parts; + } +} diff --git a/src/Core/View/Compiler.php b/src/Core/View/Compiler.php index 49c26d5..8397cf3 100644 --- a/src/Core/View/Compiler.php +++ b/src/Core/View/Compiler.php @@ -2,392 +2,61 @@ namespace Beobles\Core\View; +use Beobles\Core\View\Compilation\CompilationContext; +use Beobles\Core\View\Compilation\ExpressionCompiler; +use Beobles\Core\View\NodeVisitor\NodeTraverser; +use Beobles\Core\View\NodeVisitor\NodeVisitorInterface; +use Beobles\Core\View\Nodes\NodeInterface; + /** - * Compilador de AST para código PHP - * Transforma nós da AST em código PHP otimizado + * Orquestrador da compilação de templates. + * + * Responsabilidades: + * 1. Escrever o preamble PHP (helper __tpl_get) + * 2. Passar a AST pelos NodeVisitors registrados + * 3. Iterar os nós pedindo que cada um se compile + * + * Todo conhecimento de como compilar um fragmento específico + * vive no próprio Node, não aqui. */ class Compiler { - /** - * Compila AST em código PHP - * - * @param array $nodes Nós da AST - * @return string Código PHP compilado - */ - public function compile(array $nodes): string - { - $code = "{\$segment})) {\n"; - $code .= " \$value = \$value->{\$segment};\n"; - $code .= " continue;\n"; - $code .= " }\n"; - $code .= " \$getter = 'get' . ucfirst(\$segment);\n"; - $code .= " if (method_exists(\$value, \$getter)) {\n"; - $code .= " \$value = \$value->{\$getter}();\n"; - $code .= " continue;\n"; - $code .= " }\n"; - $code .= " }\n"; - $code .= " return null;\n"; - $code .= " }\n"; - $code .= " return \$value;\n"; - $code .= " }\n"; - $code .= "}\n\n"; - - foreach ($nodes as $node) { - $code .= $this->compileNode($node); - } - - return $code; - } - - /** - * Compila um nó individual - * - * @param object $node Nó para compilar - * @return string Código PHP - */ - private function compileNode(object $node): string - { - $class = (new \ReflectionClass($node))->getShortName(); - - return match ($class) { - 'TextNode' => $this->compileText($node), - 'ExpressionNode' => $this->compileExpression($node), - 'RawNode' => $this->compileRaw($node), - 'ComponentNode' => $this->compileComponent($node), - 'IfNode' => $this->compileIf($node), - 'ElseNode' => $this->compileElse(), - 'ElseIfNode' => $this->compileElseIf($node), - 'BlockNode' => $this->compileBlock($node), - 'ForeachNode' => $this->compileForeach($node), - 'CloseTagNode' => $this->compileCloseTag($node), - default => '' - }; - } - - /** - * Compila texto - * - * @param object $node TextNode - * @return string - */ - private function compileText(object $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 - { - [$expression, $isRaw] = $this->compileExpressionChain($node->value); - $rendered = $isRaw - ? $expression - : 'htmlspecialchars((string)(' . $expression . '), ENT_QUOTES, "UTF-8")'; - - return 'echo ' . $rendered . ";\n"; - } - - /** - * Compila raw output {! !} - * - * @param object $node RawNode - * @return string - */ - private function compileRaw(object $node): string - { - return 'echo (string)(' . $this->compilePhpExpression($node->value) . ");\n"; - } - - /** - * Compila componente - * - * @param object $node ComponentNode - * @return string - */ - private function compileComponent(object $node): string - { - $props = 'array(' . implode(', ', array_map( - fn($k, $v) => "'" . $k . "' => " . $this->compilePhpExpression((string) $v), - array_keys($node->attributes), - $node->attributes - )) . ')'; - - return 'echo $__engine->renderComponent(' . var_export($node->name, true) . ', ' . $props . ");\n"; - } - - /** - * Compila If - * - * @param object $node IfNode - * @return string - */ - private function compileIf(object $node): string - { - return 'if (' . $this->compilePhpExpression($node->condition) . ") {\n"; - } - - private function compileElse(): string - { - return "} else {\n"; - } - - private function compileElseIf(object $node): string - { - return '} elseif (' . $this->compilePhpExpression($node->condition) . ") {\n"; - } - - /** - * Compila Block - * - * @param object $node BlockNode - * @return string - */ - private function compileBlock(object $node): string - { - return 'ob_start();' . "\n"; - } + private NodeTraverser $traverser; + private ExpressionCompiler $expressionCompiler; - /** - * Compila Foreach - * - * @param object $node ForeachNode - * @return string - */ - private function compileForeach(object $node): string - { - $items = $this->compilePhpExpression($node->items); - $vars = array_values(array_filter(array_map('trim', explode(',', $node->as)))); - - if (count($vars) >= 2) { - $valueVar = '$' . ltrim($vars[0], '$'); - $keyVar = '$' . ltrim($vars[1], '$'); - return 'foreach ((array)(' . $items . ') as ' . $keyVar . ' => ' . $valueVar . ") {\n"; - } - - $valueVar = '$' . ltrim($vars[0] ?? 'item', '$'); - return 'foreach ((array)(' . $items . ') as ' . $valueVar . ") {\n"; - } - - private function compileCloseTag(object $node): string + public function __construct() { - return match ($node->name) { - 'If', 'Foreach', 'Block' => "}\n", - default => '', - }; + $this->traverser = new NodeTraverser(); + $this->expressionCompiler = new ExpressionCompiler(); } /** - * @return array{0:string,1:bool} + * Registra um visitante de nós para análise ou transformação da AST. */ - private function compileExpressionChain(string $expression): array - { - $parts = $this->splitByPipes($expression); - $baseExpression = array_shift($parts) ?? ''; - $code = $this->compilePhpExpression($baseExpression); - $isRaw = false; - - foreach ($parts as $filterPart) { - $filterPart = trim($filterPart); - if ($filterPart === '') { - continue; - } - - if (preg_match('/^(\w+)(?:\((.*)\))?$/s', $filterPart, $matches) !== 1) { - continue; - } - - $name = $matches[1]; - $args = trim($matches[2] ?? ''); - $argsCode = $args === '' ? '[]' : '[' . $args . ']'; - $code = '$__engine->applyFilter(' . $code . ', ' . var_export($name, true) . ', ' . $argsCode . ')'; - $isRaw = $name === 'raw'; - } - - return [$code, $isRaw]; - } - - private function splitByPipes(string $value): array - { - $parts = []; - $buffer = ''; - $depth = 0; - $quote = null; - $len = strlen($value); - - for ($i = 0; $i < $len; $i++) { - $char = $value[$i]; - if ($quote !== null) { - if ($char === $quote && ($i === 0 || $value[$i - 1] !== '\\')) { - $quote = null; - } - $buffer .= $char; - continue; - } - - if ($char === '"' || $char === "'") { - $quote = $char; - $buffer .= $char; - continue; - } - - if (in_array($char, ['(', '[', '{'], true)) { - $depth++; - $buffer .= $char; - continue; - } - - if (in_array($char, [')', ']', '}'], true)) { - $depth = max(0, $depth - 1); - $buffer .= $char; - continue; - } - - if ($char === '|' && $depth === 0) { - $parts[] = trim($buffer); - $buffer = ''; - continue; - } - - $buffer .= $char; - } - - $parts[] = trim($buffer); - return $parts; - } - - private function compilePhpExpression(string $expression): string + public function addVisitor(NodeVisitorInterface $visitor): void { - $expression = trim($expression); - if ($expression === '') { - return 'null'; - } - - $placeholderMap = []; - $counter = 0; - $expression = preg_replace_callback('/\b([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+)\b/', function ($matches) use (&$placeholderMap, &$counter) { - $placeholder = "__TPL_DOT_{$counter}__"; - $placeholderMap[$placeholder] = "__tpl_get(get_defined_vars(), '" . $matches[1] . "')"; - $counter++; - return $placeholder; - }, $expression); - - $tokens = token_get_all('isReservedIdentifier($text)) { - $result .= $text; - continue; - } - - $next = $this->nextNonWhitespaceToken($tokens, $i + 1); - if ($next === '(') { - $result .= $text; - continue; - } - - $prev = $this->prevNonWhitespaceToken($tokens, $i - 1); - if ($prev === '->' || $prev === '::' || $prev === '$') { - $result .= $text; - continue; - } - - $result .= '$' . $text; - } - - return $result; - } - - private function isReservedIdentifier(string $value): bool - { - if (preg_match('/^[A-Z_][A-Z0-9_]*$/', $value) === 1) { - return true; - } - - static $reserved = [ - 'true', 'false', 'null', 'and', 'or', 'xor', 'instanceof', 'new', - 'clone', 'match', 'fn', 'array', 'parent', 'self', 'static', - ]; - - return in_array(strtolower($value), $reserved, true); + $this->traverser->addVisitor($visitor); } /** - * @param array $tokens + * Compila a AST em código PHP pronto para execução. + * + * @param NodeInterface[] $nodes */ - private function nextNonWhitespaceToken(array $tokens, int $start) + public function compile(array $nodes): string { - for ($i = $start, $len = count($tokens); $i < $len; $i++) { - $token = $tokens[$i]; - if (is_string($token)) { - if (trim($token) !== '') { - return $token; - } - continue; - } + $nodes = $this->traverser->traverse($nodes); - if ($token[0] !== T_WHITESPACE) { - return $token[1]; - } - } + $ctx = new CompilationContext($this->expressionCompiler); - return null; - } - - /** - * @param array $tokens - */ - private function prevNonWhitespaceToken(array $tokens, int $start) - { - for ($i = $start; $i >= 0; $i--) { - $token = $tokens[$i]; - if (is_string($token)) { - if (trim($token) !== '') { - return $token; - } - continue; - } + $ctx->writeLine('writeLine(); + $ctx->write($this->expressionCompiler->preamble()); - if ($token[0] !== T_WHITESPACE) { - return $token[1]; - } + foreach ($nodes as $node) { + $node->compile($ctx); } - return null; + return $ctx->getCode(); } } diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index 30ea9fd..876d153 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -7,6 +7,7 @@ use Beobles\Core\View\Components\ComponentRegistry; use Beobles\Core\View\Exceptions\ViewException; use Beobles\Core\View\Filters\FilterRegistry; +use Beobles\Core\View\NodeVisitor\NodeVisitorInterface; /** * Motor de Template Engine Principal @@ -15,7 +16,7 @@ */ class Engine { - private const CACHE_VERSION = '3'; + private const CACHE_VERSION = '4'; private string $templatesDir; private string $cacheDir; private bool $autoEscape; @@ -123,6 +124,14 @@ public function render(string $templatePath, array $data = []): string } } + /** + * Registra um visitante de nós para análise ou transformação da AST. + */ + public function addVisitor(NodeVisitorInterface $visitor): void + { + $this->compiler->addVisitor($visitor); + } + /** * Registra um componente customizado * diff --git a/src/Core/View/NodeVisitor/NodeTraverser.php b/src/Core/View/NodeVisitor/NodeTraverser.php new file mode 100644 index 0000000..2ed7c8d --- /dev/null +++ b/src/Core/View/NodeVisitor/NodeTraverser.php @@ -0,0 +1,59 @@ +visitors[] = $visitor; + } + + /** + * Percorre os nós aplicando todos os visitantes. + * + * @param NodeInterface[] $nodes + * @return NodeInterface[] + */ + public function traverse(array $nodes): array + { + if ($this->visitors === []) { + return $nodes; + } + + $result = []; + + foreach ($nodes as $node) { + // enterNode: visitante pode substituir o nó + foreach ($this->visitors as $visitor) { + $replacement = $visitor->enterNode($node); + if ($replacement !== null) { + $node = $replacement; + } + } + + // leaveNode: visitante pode substituir o nó + foreach ($this->visitors as $visitor) { + $replacement = $visitor->leaveNode($node); + if ($replacement !== null) { + $node = $replacement; + } + } + + $result[] = $node; + } + + return $result; + } +} diff --git a/src/Core/View/NodeVisitor/NodeVisitorInterface.php b/src/Core/View/NodeVisitor/NodeVisitorInterface.php new file mode 100644 index 0000000..11ee4be --- /dev/null +++ b/src/Core/View/NodeVisitor/NodeVisitorInterface.php @@ -0,0 +1,31 @@ +writeLine('ob_start(); // block: ' . $this->name); + } + + public function __toString(): string + { + return 'BLOCK: ' . $this->name; + } +} diff --git a/src/Core/View/Nodes/CloseTagNode.php b/src/Core/View/Nodes/CloseTagNode.php new file mode 100644 index 0000000..6559e30 --- /dev/null +++ b/src/Core/View/Nodes/CloseTagNode.php @@ -0,0 +1,27 @@ +tagName === 'Block') { + $ctx->writeLine('ob_get_clean();'); + } else { + $ctx->writeLine('}'); + } + } + + public function __toString(): string + { + return 'CLOSE: tagName . '>'; + } +} diff --git a/src/Core/View/Nodes/ComponentNode.php b/src/Core/View/Nodes/ComponentNode.php index fd4e062..dfb779e 100644 --- a/src/Core/View/Nodes/ComponentNode.php +++ b/src/Core/View/Nodes/ComponentNode.php @@ -2,13 +2,27 @@ namespace Beobles\Core\View\Nodes; +use Beobles\Core\View\Compilation\CompilationContext; + class ComponentNode implements NodeInterface { public function __construct( - public string $name, - public array $attributes = [] + public readonly string $name, + public readonly array $attributes = [] ) {} + public function compile(CompilationContext $ctx): void + { + $propPairs = array_map( + fn(string $k, string $v): string => "'" . $k . "' => " . $ctx->expr($v), + array_keys($this->attributes), + array_values($this->attributes) + ); + + $propsCode = 'array(' . implode(', ', $propPairs) . ')'; + $ctx->writeLine('echo $__engine->renderComponent(' . var_export($this->name, true) . ', ' . $propsCode . ');'); + } + public function __toString(): string { return 'COMPONENT: <' . $this->name . ' />'; diff --git a/src/Core/View/Nodes/ElseIfNode.php b/src/Core/View/Nodes/ElseIfNode.php new file mode 100644 index 0000000..4329e52 --- /dev/null +++ b/src/Core/View/Nodes/ElseIfNode.php @@ -0,0 +1,20 @@ +writeLine('} elseif (' . $ctx->expr($this->condition) . ') {'); + } + + public function __toString(): string + { + return 'ELSEIF: ' . $this->condition; + } +} diff --git a/src/Core/View/Nodes/ElseNode.php b/src/Core/View/Nodes/ElseNode.php new file mode 100644 index 0000000..fab8f20 --- /dev/null +++ b/src/Core/View/Nodes/ElseNode.php @@ -0,0 +1,18 @@ +writeLine('} else {'); + } + + public function __toString(): string + { + return 'ELSE'; + } +} diff --git a/src/Core/View/Nodes/ExpressionNode.php b/src/Core/View/Nodes/ExpressionNode.php index f12af9c..7af6fbe 100644 --- a/src/Core/View/Nodes/ExpressionNode.php +++ b/src/Core/View/Nodes/ExpressionNode.php @@ -2,11 +2,22 @@ namespace Beobles\Core\View\Nodes; +use Beobles\Core\View\Compilation\CompilationContext; + class ExpressionNode implements NodeInterface { - public function __construct( - public string $value - ) {} + public function __construct(public readonly string $value) {} + + public function compile(CompilationContext $ctx): void + { + [$code, $isRaw] = $ctx->exprChain($this->value); + + $output = $isRaw + ? '(string)(' . $code . ')' + : 'htmlspecialchars((string)(' . $code . '), ENT_QUOTES, "UTF-8")'; + + $ctx->writeLine('echo ' . $output . ';'); + } 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..444a848 --- /dev/null +++ b/src/Core/View/Nodes/ForeachNode.php @@ -0,0 +1,33 @@ +expr($this->items); + $vars = array_values(array_filter(array_map('trim', explode(',', $this->as)))); + + if (count($vars) >= 2) { + $valueVar = '$' . ltrim($vars[0], '$'); + $keyVar = '$' . ltrim($vars[1], '$'); + $ctx->writeLine('foreach ((array)(' . $itemsCode . ') as ' . $keyVar . ' => ' . $valueVar . ') {'); + } else { + $valueVar = '$' . ltrim($vars[0] ?? 'item', '$'); + $ctx->writeLine('foreach ((array)(' . $itemsCode . ') as ' . $valueVar . ') {'); + } + } + + public function __toString(): string + { + return 'FOREACH: ' . $this->items . ' as ' . $this->as; + } +} diff --git a/src/Core/View/Nodes/IfNode.php b/src/Core/View/Nodes/IfNode.php index 4422f4e..b5b3b26 100644 --- a/src/Core/View/Nodes/IfNode.php +++ b/src/Core/View/Nodes/IfNode.php @@ -2,11 +2,16 @@ namespace Beobles\Core\View\Nodes; +use Beobles\Core\View\Compilation\CompilationContext; + class IfNode implements NodeInterface { - public function __construct( - public string $condition - ) {} + public function __construct(public readonly string $condition) {} + + public function compile(CompilationContext $ctx): void + { + $ctx->writeLine('if (' . $ctx->expr($this->condition) . ') {'); + } public function __toString(): string { diff --git a/src/Core/View/Nodes/NodeInterface.php b/src/Core/View/Nodes/NodeInterface.php index 61b7419..4c7eca7 100644 --- a/src/Core/View/Nodes/NodeInterface.php +++ b/src/Core/View/Nodes/NodeInterface.php @@ -2,15 +2,24 @@ namespace Beobles\Core\View\Nodes; +use Beobles\Core\View\Compilation\CompilationContext; + /** - * Interface para nós da AST + * Contrato para todos os nós da AST. + * + * Cada nó é responsável por: + * - Representar um fragmento semântico do template + * - Saber como compilar a si mesmo via compile() */ interface NodeInterface { /** - * Retorna string de representação - * - * @return string + * Compila o nó, escrevendo código PHP no contexto de compilação. + */ + public function compile(CompilationContext $ctx): void; + + /** + * Retorna representação legível do nó (para debug/dump). */ public function __toString(): string; } diff --git a/src/Core/View/Nodes/RawNode.php b/src/Core/View/Nodes/RawNode.php index 89e3111..7f1f66c 100644 --- a/src/Core/View/Nodes/RawNode.php +++ b/src/Core/View/Nodes/RawNode.php @@ -2,11 +2,16 @@ namespace Beobles\Core\View\Nodes; +use Beobles\Core\View\Compilation\CompilationContext; + class RawNode implements NodeInterface { - public function __construct( - public string $value - ) {} + public function __construct(public readonly string $value) {} + + public function compile(CompilationContext $ctx): void + { + $ctx->writeLine('echo (string)(' . $ctx->expr($this->value) . ');'); + } public function __toString(): string { diff --git a/src/Core/View/Nodes/TextNode.php b/src/Core/View/Nodes/TextNode.php index 6078565..795a10b 100644 --- a/src/Core/View/Nodes/TextNode.php +++ b/src/Core/View/Nodes/TextNode.php @@ -2,11 +2,16 @@ namespace Beobles\Core\View\Nodes; +use Beobles\Core\View\Compilation\CompilationContext; + class TextNode implements NodeInterface { - public function __construct( - public string $value - ) {} + public function __construct(public readonly string $value) {} + + public function compile(CompilationContext $ctx): void + { + $ctx->writeLine('echo ' . var_export($this->value, true) . ';'); + } public function __toString(): string { diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index b83c853..86597fa 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -2,15 +2,19 @@ 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\Nodes\BlockNode; +use Beobles\Core\View\Nodes\CloseTagNode; use Beobles\Core\View\Nodes\ComponentNode; +use Beobles\Core\View\Nodes\ElseIfNode; +use Beobles\Core\View\Nodes\ElseNode; +use Beobles\Core\View\Nodes\ExpressionNode; +use Beobles\Core\View\Nodes\ForeachNode; use Beobles\Core\View\Nodes\IfNode; +use Beobles\Core\View\Nodes\RawNode; +use Beobles\Core\View\Nodes\TextNode; /** - * Parser de AST (Abstract Syntax Tree) - * Converte tokens em nós para compilação + * Converte tokens em nós (AST plana) para compilação posterior. */ class Parser { @@ -18,10 +22,8 @@ class Parser private int $position = 0; /** - * Faz parse dos tokens - * - * @param array $tokens Tokens da lexer - * @return array AST (nodes) + * @param array $tokens Tokens produzidos pela Lexer + * @return array<\Beobles\Core\View\Nodes\NodeInterface> */ public function parse(array $tokens): array { @@ -29,167 +31,99 @@ public function parse(array $tokens): array $this->position = 0; $nodes = []; - while ($this->position < count($tokens)) { + while ($this->position < count($this->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; - } - } elseif ($token['type'] === 'TAG_CLOSE') { - $node = $this->parseCloseTag(); - if ($node) { - $nodes[] = $node; - } - } elseif ($token['type'] === 'KEYWORD') { - $node = $this->parseKeyword(); - if ($node) { - $nodes[] = $node; - } - } else { - $this->advance(); + $node = match ($token['type'] ?? '') { + 'TEXT' => $this->parseText(), + 'EXPRESSION' => $this->parseExpression(), + 'RAW' => $this->parseRaw(), + 'TAG' => $this->parseTag(), + 'TAG_CLOSE' => $this->parseCloseTag(), + 'KEYWORD' => $this->parseKeyword(), + default => null, + }; + + if ($node !== null) { + $nodes[] = $node; } + + $this->advance(); } return $nodes; } - /** - * Parse de uma tag customizada - * - * @return object Node - */ - private function parseTag(): ?object - { - $token = $this->current(); - $tagName = $token['name']; + // ------------------------------------------------------------------------- + // Token-type handlers + // ------------------------------------------------------------------------- - $this->advance(); - - switch ($tagName) { - case 'If': - return $this->parseIfTag($token); - case 'Else': - return new ElseNode(); - case 'ElseIf': - return $this->parseElseIfTag($token); - case 'Block': - return $this->parseBlockTag($token); - case 'Foreach': - return $this->parseForEachTag($token); - case 'Component': - return $this->parseComponentTag($token); - default: - if (preg_match('/^[A-Z]/', $tagName) === 1) { - return $this->parseComponentTag($token); - } - return null; - } + private function parseText(): TextNode + { + return new TextNode($this->current()['value']); } - /** - * Parse de tag de fechamento - */ - private function parseCloseTag(): ?object + private function parseExpression(): ExpressionNode { - $token = $this->current(); - $this->advance(); + return new ExpressionNode($this->current()['value']); + } - return match ($token['name']) { - 'If', 'Foreach', 'Block' => new CloseTagNode($token['name']), - default => null, - }; + private function parseRaw(): RawNode + { + return new RawNode($this->current()['value']); } - /** - * Parse de keyword (extends, import) - * - * @return object Node - */ - private function parseKeyword(): ?object + private function parseTag(): ?\Beobles\Core\View\Nodes\NodeInterface { $token = $this->current(); - $keyword = $token['value']; - - $this->advance(); + $tagName = $token['name']; - // Implementar parsing de keywords conforme necessário - return null; + return match ($tagName) { + 'If' => new IfNode($this->extractAttributeValue($token['attributes'], 'condition')), + 'ElseIf' => new ElseIfNode($this->extractAttributeValue($token['attributes'], 'condition')), + 'Else' => new ElseNode(), + 'Block' => new BlockNode($this->extractAttributeValue($token['attributes'], 'name')), + 'Foreach' => new ForeachNode( + $this->extractAttributeValue($token['attributes'], 'items'), + $this->extractAttributeValue($token['attributes'], 'as') + ), + 'Component' => $this->parseComponentTag($token), + default => preg_match('/^[A-Z]/', $tagName) === 1 + ? $this->parseComponentTag($token) + : null, + }; } - /** - * Parse de tag If - * - * @param array $token Token da tag - * @return IfNode - */ - private function parseIfTag(array $token): IfNode + private function parseCloseTag(): ?CloseTagNode { - return new IfNode($this->extractAttributeValue($token['attributes'], 'condition')); - } + $token = $this->current(); - /** - * Parse de tag ElseIf - */ - private function parseElseIfTag(array $token): ElseIfNode - { - return new ElseIfNode($this->extractAttributeValue($token['attributes'], 'condition')); + return match ($token['name']) { + 'If', 'Foreach', 'Block' => new CloseTagNode($token['name']), + default => null, + }; } - /** - * Parse de tag Block - * - * @param array $token Token da tag - * @return BlockNode - */ - private function parseBlockTag(array $token): BlockNode + private function parseKeyword(): null { - return new BlockNode($this->extractAttributeValue($token['attributes'], 'name')); + // Keywords (extends, import) reserved for future implementation + return null; } - /** - * Parse de tag Foreach - * - * @param array $token Token da tag - * @return ForeachNode - */ - private function parseForEachTag(array $token): ForeachNode - { - $items = $this->extractAttributeValue($token['attributes'], 'items'); - $as = $this->extractAttributeValue($token['attributes'], 'as'); - - return new ForeachNode($items, $as); - } + // ------------------------------------------------------------------------- + // Component helper + // ------------------------------------------------------------------------- - /** - * Parse de tag de componente - * - * @param array $token Token da tag - * @return ComponentNode - */ private function parseComponentTag(array $token): ComponentNode { - $name = $token['name']; - $attributes = $this->parseAttributes($token['attributes']); - - return new ComponentNode($name, $attributes); + return new ComponentNode($token['name'], $this->parseAttributes($token['attributes'])); } + // ------------------------------------------------------------------------- + // Attribute helpers + // ------------------------------------------------------------------------- + /** - * Parse de atributos - * - * @param string $attributesStr String de atributos - * @return array Atributos parseados + * Extracts all key=>value attribute pairs from the raw attributes string. */ private function parseAttributes(string $attributesStr): array { @@ -201,7 +135,7 @@ private function parseAttributes(string $attributesStr): array preg_match_all('/(\w+)\s*=\s*(?:\{\{\s*(.+?)\s*\}\}|["\']([^"\']*)["\'])/s', $attributesStr, $matches, PREG_SET_ORDER); foreach ($matches as $match) { - $name = $match[1]; + $name = $match[1]; $value = isset($match[2]) && $match[2] !== '' ? trim($match[2]) : var_export($match[3] ?? '', true); @@ -211,76 +145,53 @@ private function parseAttributes(string $attributesStr): array return $attributes; } + /** + * Extracts the value of a single named attribute from the raw attributes string. + * Supports {{ expression }}, "string", and 'string' formats. + * When a quoted value itself contains {{ }}, the delimiters are stripped. + */ private function extractAttributeValue(string $attributes, string $name): string { - $value = ''; - - if (preg_match('/' . preg_quote($name, '/') . '\s*=\s*\{\{\s*(.*?)\s*\}\}/s', $attributes, $matches) === 1) { - $value = trim($matches[1]); - } elseif (preg_match('/' . preg_quote($name, '/') . '\s*=\s*"([^"]*)"/s', $attributes, $matches) === 1) { - $value = trim($matches[1]); - } elseif (preg_match('/' . preg_quote($name, '/') . '\s*=\s*\'([^\']*)\'/s', $attributes, $matches) === 1) { - $value = trim($matches[1]); - } + $qName = preg_quote($name, '/'); - if (preg_match('/^\{\{\s*(.*?)\s*\}\}$/s', $value, $dynamic) === 1) { - return trim($dynamic[1]); + // attribute="{{ expr }}" — expression in curly delimiters + if (preg_match('/' . $qName . '\s*=\s*\{\{\s*(.*?)\s*\}\}/s', $attributes, $m) === 1) { + return trim($m[1]); + } + // attribute="..." or attribute='...' + if (preg_match('/' . $qName . '\s*=\s*"([^"]*)"/s', $attributes, $m) === 1) { + return $this->unwrapCurly(trim($m[1])); + } + if (preg_match('/' . $qName . '\s*=\s*\'([^\']*)\'/s', $attributes, $m) === 1) { + return $this->unwrapCurly(trim($m[1])); } - return $value; + return ''; } /** - * Obtém token atual - * - * @return array Token + * If the value is wrapped in {{ }}, strips the delimiters and returns the inner expression. */ + private function unwrapCurly(string $value): string + { + if (preg_match('/^\{\{\s*(.*?)\s*\}\}$/s', $value, $m) === 1) { + return trim($m[1]); + } + return $value; + } + + // ------------------------------------------------------------------------- + // Cursor helpers + // ------------------------------------------------------------------------- + private function current(): array { - return $this->tokens[$this->position] ?? []; + return $this->tokens[$this->position] ?? ['type' => '', 'value' => '', 'name' => '', 'attributes' => '']; } - /** - * Avança para próximo token - * - * @return void - */ private function advance(): void { $this->position++; } } -// Node classes -class BlockNode -{ - public function __construct( - public string $name - ) {} -} - -class ForeachNode -{ - public function __construct( - public string $items, - public string $as - ) {} -} - -class ElseNode -{ -} - -class ElseIfNode -{ - public function __construct( - public string $condition - ) {} -} - -class CloseTagNode -{ - public function __construct( - public string $name - ) {} -} From 4749b93f10bbd58499e292d395a33e8f827fc3ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:31:31 +0000 Subject: [PATCH 03/17] Remove Beobles namespace prefix --- README.md | 2 +- autoload.php | 2 +- examples/index.php | 2 +- src/Core/View/Cache/CacheInterface.php | 2 +- src/Core/View/Cache/CacheManager.php | 2 +- src/Core/View/Cache/FileCacheAdapter.php | 2 +- .../View/Compilation/CompilationContext.php | 2 +- .../View/Compilation/ExpressionCompiler.php | 2 +- src/Core/View/Compiler.php | 12 ++++---- .../View/Components/ComponentRegistry.php | 4 +-- src/Core/View/Engine.php | 16 +++++------ src/Core/View/Environment.php | 2 +- .../View/Exceptions/CompilerException.php | 2 +- src/Core/View/Exceptions/ParserException.php | 2 +- src/Core/View/Exceptions/SyntaxException.php | 2 +- src/Core/View/Exceptions/ViewException.php | 2 +- src/Core/View/Filters/FilterRegistry.php | 4 +-- src/Core/View/Lexer.php | 4 +-- src/Core/View/NodeVisitor/NodeTraverser.php | 4 +-- .../View/NodeVisitor/NodeVisitorInterface.php | 4 +-- src/Core/View/Nodes/BlockNode.php | 4 +-- src/Core/View/Nodes/CloseTagNode.php | 4 +-- src/Core/View/Nodes/ComponentNode.php | 4 +-- src/Core/View/Nodes/ElseIfNode.php | 4 +-- src/Core/View/Nodes/ElseNode.php | 4 +-- src/Core/View/Nodes/ExpressionNode.php | 4 +-- src/Core/View/Nodes/ForeachNode.php | 4 +-- src/Core/View/Nodes/IfNode.php | 4 +-- src/Core/View/Nodes/NodeInterface.php | 4 +-- src/Core/View/Nodes/RawNode.php | 4 +-- src/Core/View/Nodes/TextNode.php | 4 +-- src/Core/View/Parser.php | 28 +++++++++---------- src/Core/View/Renderer.php | 2 +- 33 files changed, 74 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index ee9e1dd..421b982 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/Cache/CacheInterface.php b/src/Core/View/Cache/CacheInterface.php index 9c5249e..3cd3a46 100644 --- a/src/Core/View/Cache/CacheInterface.php +++ b/src/Core/View/Cache/CacheInterface.php @@ -1,6 +1,6 @@ + * @return array<\Core\View\Nodes\NodeInterface> */ public function parse(array $tokens): array { @@ -72,7 +72,7 @@ private function parseRaw(): RawNode return new RawNode($this->current()['value']); } - private function parseTag(): ?\Beobles\Core\View\Nodes\NodeInterface + private function parseTag(): ?\Core\View\Nodes\NodeInterface { $token = $this->current(); $tagName = $token['name']; diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 759110e..b14d3ff 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -1,6 +1,6 @@ Date: Wed, 27 May 2026 02:44:59 +0000 Subject: [PATCH 04/17] Fix keyword directive tokenization --- src/Core/View/Lexer.php | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index 2def250..ec6bad7 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -30,16 +30,14 @@ public function tokenize(string $content): array $pos = 0; while ($pos < $length) { - // Detectar keywords - if (preg_match('/^extends\b/', substr($content, $pos)) === 1) { - $tokens[] = ['type' => 'KEYWORD', 'value' => 'extends']; - $pos += 7; - continue; - } - - if (preg_match('/^import\b/', substr($content, $pos)) === 1) { - $tokens[] = ['type' => 'KEYWORD', 'value' => 'import']; - $pos += 6; + // Detectar diretivas por keyword (ex.: extends "base.html";) + if (preg_match('/^(extends|import)\b\s*[^;]*;/', substr($content, $pos), $matches) === 1) { + $tokens[] = [ + 'type' => self::TOKEN_KEYWORD, + 'value' => trim($matches[0]), + 'length' => strlen($matches[0]), + ]; + $pos += strlen($matches[0]); continue; } From e1ff63935db9c3ef29bdd3ac4aac11ce8ced685d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:53:45 +0000 Subject: [PATCH 05/17] Implement extends inheritance and block output fix --- src/Core/View/Engine.php | 67 +++++++++++++++++++++++++++- src/Core/View/Nodes/BlockNode.php | 6 +-- src/Core/View/Nodes/CloseTagNode.php | 4 +- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index 2226b86..685ad35 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -16,7 +16,7 @@ */ class Engine { - private const CACHE_VERSION = '4'; + private const CACHE_VERSION = '5'; private string $templatesDir; private string $cacheDir; private bool $autoEscape; @@ -103,6 +103,9 @@ public function render(string $templatePath, array $data = []): string throw new ViewException("Unable to read template: {$templatePath}"); } + // Resolver herança de templates (extends + blocks) + $content = $this->resolveTemplateInheritance($content, $absolutePath, [$absolutePath]); + // Tokenizar $tokens = $this->lexer->tokenize($content); @@ -199,6 +202,68 @@ private function generateCacheKey(string $absolutePath): string return 'template_' . md5(self::CACHE_VERSION . '|' . $absolutePath . '|' . $modifiedAt); } + /** + * Resolve herança de template via `extends "path";` e sobrescrita de . + * + * @param string[] $stack + */ + private function resolveTemplateInheritance(string $content, string $currentPath, array $stack): string + { + if (preg_match('/^\s*extends\s+["\']([^"\']+)["\']\s*;/i', $content, $match) !== 1) { + return $content; + } + + $parentRelativePath = trim($match[1]); + $parentAbsolutePath = $this->resolveTemplatePath($parentRelativePath); + + if (!file_exists($parentAbsolutePath)) { + throw new ViewException("Parent template not found: {$parentRelativePath}"); + } + + if (in_array($parentAbsolutePath, $stack, true)) { + throw new ViewException("Circular extends detected: {$parentRelativePath}"); + } + + $parentContent = file_get_contents($parentAbsolutePath); + if ($parentContent === false) { + throw new ViewException("Unable to read parent template: {$parentRelativePath}"); + } + + $childContent = preg_replace('/^\s*extends\s+["\']([^"\']+)["\']\s*;[ \t]*\R?/i', '', $content, 1) ?? $content; + $parentResolved = $this->resolveTemplateInheritance($parentContent, $parentAbsolutePath, [...$stack, $parentAbsolutePath]); + + return $this->mergeBlocks($parentResolved, $childContent); + } + + /** + * @return array + */ + private function extractBlocks(string $content): array + { + $blocks = []; + preg_match_all('/(.*?)<\/Block>/is', $content, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $blocks[$match[1]] = $match[2]; + } + + return $blocks; + } + + private function mergeBlocks(string $parentContent, string $childContent): string + { + $childBlocks = $this->extractBlocks($childContent); + + return (string) preg_replace_callback( + '/(.*?)<\/Block>/is', + static function (array $match) use ($childBlocks): string { + $name = $match[1]; + return $childBlocks[$name] ?? $match[2]; + }, + $parentContent + ); + } + /** * Renderiza um componente * diff --git a/src/Core/View/Nodes/BlockNode.php b/src/Core/View/Nodes/BlockNode.php index 6fe224e..ede5036 100644 --- a/src/Core/View/Nodes/BlockNode.php +++ b/src/Core/View/Nodes/BlockNode.php @@ -7,8 +7,8 @@ /** * Representa a abertura de um bloco de layout. * - * Em tempo de compilação emite ob_start(); o conteúdo é capturado - * até o CloseTagNode correspondente emitir ob_get_clean(). + * A resolução de herança (extends/override) ocorre antes da compilação. + * Aqui o bloco atua apenas como marcador sem emitir código. */ class BlockNode implements NodeInterface { @@ -16,7 +16,7 @@ public function __construct(public readonly string $name) {} public function compile(CompilationContext $ctx): void { - $ctx->writeLine('ob_start(); // block: ' . $this->name); + // No-op: conteúdo interno do bloco já deve ser emitido normalmente. } public function __toString(): string diff --git a/src/Core/View/Nodes/CloseTagNode.php b/src/Core/View/Nodes/CloseTagNode.php index de95b98..67de5c7 100644 --- a/src/Core/View/Nodes/CloseTagNode.php +++ b/src/Core/View/Nodes/CloseTagNode.php @@ -13,9 +13,7 @@ public function __construct(public readonly string $tagName) {} public function compile(CompilationContext $ctx): void { - if ($this->tagName === 'Block') { - $ctx->writeLine('ob_get_clean();'); - } else { + if ($this->tagName !== 'Block') { $ctx->writeLine('}'); } } From adc68bc4726fb804d3ad65b26d3fa042af3e0ce4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 03:01:54 +0000 Subject: [PATCH 06/17] Fix extends cache invalidation with dependency-aware keys --- src/Core/View/Engine.php | 71 +++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index 685ad35..dfb6b1c 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -16,7 +16,7 @@ */ class Engine { - private const CACHE_VERSION = '5'; + private const CACHE_VERSION = '6'; private string $templatesDir; private string $cacheDir; private bool $autoEscape; @@ -87,9 +87,11 @@ public function render(string $templatePath, array $data = []): string throw new ViewException("Template not found: {$templatePath}"); } + $dependencies = $this->collectTemplateDependencies($absolutePath, [$absolutePath]); + // Verificar cache if ($this->cacheEnabled) { - $cacheKey = $this->generateCacheKey($absolutePath); + $cacheKey = $this->generateCacheKey($dependencies); $cachedContent = $this->cacheManager->get($cacheKey); if ($cachedContent !== null) { @@ -104,7 +106,7 @@ public function render(string $templatePath, array $data = []): string } // Resolver herança de templates (extends + blocks) - $content = $this->resolveTemplateInheritance($content, $absolutePath, [$absolutePath]); + $content = $this->resolveTemplateInheritance($content, [$absolutePath]); // Tokenizar $tokens = $this->lexer->tokenize($content); @@ -196,10 +198,49 @@ public function resolveTemplatePath(string $path): string * @param string $path Caminho do template * @return string Chave de cache */ - private function generateCacheKey(string $absolutePath): string + private function generateCacheKey(array $dependencies): string { - $modifiedAt = file_exists($absolutePath) ? (string) filemtime($absolutePath) : '0'; - return 'template_' . md5(self::CACHE_VERSION . '|' . $absolutePath . '|' . $modifiedAt); + $signature = []; + + foreach ($dependencies as $path) { + $modifiedAt = file_exists($path) ? (string) filemtime($path) : '0'; + $signature[] = $path . '@' . $modifiedAt; + } + + return 'template_' . md5(self::CACHE_VERSION . '|' . implode('|', $signature)); + } + + /** + * Coleta cadeia completa de templates envolvidos em extends. + * + * @param string[] $stack + * @return string[] + */ + private function collectTemplateDependencies(string $absolutePath, array $stack): array + { + $content = file_get_contents($absolutePath); + if ($content === false) { + throw new ViewException("Unable to read template dependency: {$absolutePath}"); + } + + $parentRelativePath = $this->extractParentTemplatePath($content); + if ($parentRelativePath === null) { + return [$absolutePath]; + } + + $parentAbsolutePath = $this->resolveTemplatePath($parentRelativePath); + if (!file_exists($parentAbsolutePath)) { + throw new ViewException("Parent template not found: {$parentRelativePath}"); + } + + if (in_array($parentAbsolutePath, $stack, true)) { + throw new ViewException("Circular extends detected: {$parentRelativePath}"); + } + + return array_merge( + [$absolutePath], + $this->collectTemplateDependencies($parentAbsolutePath, [...$stack, $parentAbsolutePath]) + ); } /** @@ -207,13 +248,12 @@ private function generateCacheKey(string $absolutePath): string * * @param string[] $stack */ - private function resolveTemplateInheritance(string $content, string $currentPath, array $stack): string + private function resolveTemplateInheritance(string $content, array $stack): string { - if (preg_match('/^\s*extends\s+["\']([^"\']+)["\']\s*;/i', $content, $match) !== 1) { + $parentRelativePath = $this->extractParentTemplatePath($content); + if ($parentRelativePath === null) { return $content; } - - $parentRelativePath = trim($match[1]); $parentAbsolutePath = $this->resolveTemplatePath($parentRelativePath); if (!file_exists($parentAbsolutePath)) { @@ -230,11 +270,20 @@ private function resolveTemplateInheritance(string $content, string $currentPath } $childContent = preg_replace('/^\s*extends\s+["\']([^"\']+)["\']\s*;[ \t]*\R?/i', '', $content, 1) ?? $content; - $parentResolved = $this->resolveTemplateInheritance($parentContent, $parentAbsolutePath, [...$stack, $parentAbsolutePath]); + $parentResolved = $this->resolveTemplateInheritance($parentContent, [...$stack, $parentAbsolutePath]); return $this->mergeBlocks($parentResolved, $childContent); } + private function extractParentTemplatePath(string $content): ?string + { + if (preg_match('/^\s*extends\s+["\']([^"\']+)["\']\s*;/i', $content, $match) !== 1) { + return null; + } + + return trim($match[1]); + } + /** * @return array */ From 85120dcec3b6ccb87212120b555c3b2dd8a53e3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 03:19:04 +0000 Subject: [PATCH 07/17] Harden parser structure checks and optimize lexer scanning --- src/Core/View/Lexer.php | 109 +++++++++++++++++++++++----- src/Core/View/Parser.php | 152 +++++++++++++++++++++++++++++++++++---- 2 files changed, 231 insertions(+), 30 deletions(-) diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index ec6bad7..efab5b6 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -30,19 +30,20 @@ public function tokenize(string $content): array $pos = 0; while ($pos < $length) { - // Detectar diretivas por keyword (ex.: extends "base.html";) - if (preg_match('/^(extends|import)\b\s*[^;]*;/', substr($content, $pos), $matches) === 1) { - $tokens[] = [ - 'type' => self::TOKEN_KEYWORD, - 'value' => trim($matches[0]), - 'length' => strlen($matches[0]), - ]; - $pos += strlen($matches[0]); - continue; + $slice = substr($content, $pos); + + // Detectar diretivas por keyword apenas no início da linha. + if ($this->isDirectiveLineStart($content, $pos)) { + $directive = $this->extractKeywordDirective($content, $pos); + if ($directive !== null) { + $tokens[] = $directive; + $pos += $directive['length']; + continue; + } } // Detectar tag de fechamento - if ($content[$pos] === '<' && preg_match('/^<\/([A-Z][a-zA-Z0-9]*)\s*>/', substr($content, $pos), $matches)) { + if ($content[$pos] === '<' && preg_match('/^<\/([A-Z][a-zA-Z0-9]*)\s*>/', $slice, $matches)) { $fullMatch = $matches[0]; $tokens[] = [ 'type' => 'TAG_CLOSE', @@ -55,7 +56,7 @@ public function tokenize(string $content): array } // Detectar tag de abertura - if ($content[$pos] === '<' && preg_match('/^<([A-Z][a-zA-Z0-9]*)/', substr($content, $pos), $matches)) { + if ($content[$pos] === '<' && preg_match('/^<([A-Z][a-zA-Z0-9]*)/', $slice, $matches)) { // Isso é uma tag customizada $token = $this->extractTag($content, $pos); $tokens[] = $token; @@ -82,16 +83,24 @@ public function tokenize(string $content): array // 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))) { + $cursor = $pos + $textLength; + $char = $content[$cursor]; + + if ($char === '<') { + $next = $content[$cursor + 1] ?? ''; + $next2 = $content[$cursor + 2] ?? ''; + + if ($next !== '' && ctype_upper($next)) { break; } - if (preg_match('/^<\/[A-Z]/', substr($content, $pos + $textLength))) { + if ($next === '/' && $next2 !== '' && ctype_upper($next2)) { break; } - if (strpos($content, '{{', $pos + $textLength) === $pos + $textLength || - strpos($content, '{!', $pos + $textLength) === $pos + $textLength) { + } + + if ($char === '{') { + $next = $content[$cursor + 1] ?? ''; + if ($next === '{' || $next === '!') { break; } } @@ -111,6 +120,72 @@ public function tokenize(string $content): array return $tokens; } + private function isDirectiveLineStart(string $content, int $pos): bool + { + for ($i = $pos - 1; $i >= 0; $i--) { + $char = $content[$i]; + if ($char === "\n" || $char === "\r") { + return true; + } + if ($char !== ' ' && $char !== "\t") { + return false; + } + } + + return true; + } + + private function extractKeywordDirective(string $content, int $pos): ?array + { + $length = strlen($content); + $cursor = $pos; + while ($cursor < $length && ($content[$cursor] === ' ' || $content[$cursor] === "\t")) { + $cursor++; + } + + if (preg_match('/\G(extends|import)\b/A', $content, $m, 0, $cursor) !== 1) { + return null; + } + + $cursor += strlen($m[1]); + $quote = null; + + while ($cursor < $length) { + $char = $content[$cursor]; + + if ($quote !== null) { + if ($char === $quote && ($cursor === 0 || $content[$cursor - 1] !== '\\')) { + $quote = null; + } + $cursor++; + continue; + } + + if ($char === '"' || $char === "'") { + $quote = $char; + $cursor++; + continue; + } + + if ($char === ';') { + $directive = substr($content, $pos, ($cursor - $pos) + 1); + return [ + 'type' => self::TOKEN_KEYWORD, + 'value' => trim($directive), + 'length' => strlen($directive), + ]; + } + + if ($char === "\n" || $char === "\r") { + return null; + } + + $cursor++; + } + + throw new SyntaxException("Unclosed keyword directive at position $pos"); + } + /** * Extrai uma tag customizada * diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index 944affc..368da01 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -2,6 +2,7 @@ namespace Core\View; +use Core\View\Exceptions\ParserException; use Core\View\Nodes\BlockNode; use Core\View\Nodes\CloseTagNode; use Core\View\Nodes\ComponentNode; @@ -20,6 +21,8 @@ class Parser { private array $tokens; private int $position = 0; + /** @var array */ + private array $controlStack = []; /** * @param array $tokens Tokens produzidos pela Lexer @@ -29,6 +32,7 @@ public function parse(array $tokens): array { $this->tokens = $tokens; $this->position = 0; + $this->controlStack = []; $nodes = []; while ($this->position < count($this->tokens)) { @@ -50,6 +54,11 @@ public function parse(array $tokens): array $this->advance(); } + if ($this->controlStack !== []) { + $openTags = implode(', ', array_map(static fn(array $entry): string => $entry['tag'], $this->controlStack)); + throw new ParserException("Unclosed control tags: {$openTags}"); + } + return $nodes; } @@ -76,16 +85,14 @@ private function parseTag(): ?\Core\View\Nodes\NodeInterface { $token = $this->current(); $tagName = $token['name']; + $isSelfClosing = (bool) ($token['self_closing'] ?? false); return match ($tagName) { - 'If' => new IfNode($this->extractAttributeValue($token['attributes'], 'condition')), - 'ElseIf' => new ElseIfNode($this->extractAttributeValue($token['attributes'], 'condition')), - 'Else' => new ElseNode(), - 'Block' => new BlockNode($this->extractAttributeValue($token['attributes'], 'name')), - 'Foreach' => new ForeachNode( - $this->extractAttributeValue($token['attributes'], 'items'), - $this->extractAttributeValue($token['attributes'], 'as') - ), + 'If' => $this->parseIfTag($token['attributes'], $isSelfClosing), + 'ElseIf' => $this->parseElseIfTag($token['attributes'], $isSelfClosing), + 'Else' => $this->parseElseTag($isSelfClosing), + 'Block' => $this->parseBlockTag($token['attributes'], $isSelfClosing), + 'Foreach' => $this->parseForeachTag($token['attributes'], $isSelfClosing), 'Component' => $this->parseComponentTag($token), default => preg_match('/^[A-Z]/', $tagName) === 1 ? $this->parseComponentTag($token) @@ -96,11 +103,29 @@ private function parseTag(): ?\Core\View\Nodes\NodeInterface private function parseCloseTag(): ?CloseTagNode { $token = $this->current(); + $name = $token['name'] ?? ''; - return match ($token['name']) { - 'If', 'Foreach', 'Block' => new CloseTagNode($token['name']), - default => null, - }; + if ($name === 'Else') { + $current = end($this->controlStack); + if ($current === false || $current['tag'] !== 'If' || !$current['hasElse']) { + throw new ParserException('Unexpected closing tag without an active block'); + } + return null; + } + + if (!in_array($name, ['If', 'Foreach', 'Block'], true)) { + throw new ParserException("Unexpected closing tag "); + } + + $current = end($this->controlStack); + if ($current === false || $current['tag'] !== $name) { + $openTag = $current['tag'] ?? 'none'; + throw new ParserException("Mismatched closing tag . Current open tag: {$openTag}"); + } + + array_pop($this->controlStack); + + return new CloseTagNode($name); } private function parseKeyword(): null @@ -115,9 +140,111 @@ private function parseKeyword(): null private function parseComponentTag(array $token): ComponentNode { + if (!(bool) ($token['self_closing'] ?? false)) { + throw new ParserException( + "Component tag <{$token['name']}> must be self-closing (use <{$token['name']} ... />)" + ); + } + return new ComponentNode($token['name'], $this->parseAttributes($token['attributes'])); } + private function parseIfTag(string $attributes, bool $isSelfClosing): IfNode + { + if ($isSelfClosing) { + throw new ParserException(' cannot be self-closing'); + } + + $condition = $this->extractAttributeValue($attributes, 'condition'); + if ($condition === '') { + throw new ParserException(' requires a non-empty condition attribute'); + } + + $this->controlStack[] = ['tag' => 'If', 'hasElse' => false]; + + return new IfNode($condition); + } + + private function parseElseIfTag(string $attributes, bool $isSelfClosing): ElseIfNode + { + if ($isSelfClosing) { + throw new ParserException(' cannot be self-closing'); + } + + $current = end($this->controlStack); + if ($current === false || $current['tag'] !== 'If') { + throw new ParserException(' must be inside an block'); + } + + if ($current['hasElse']) { + throw new ParserException(' cannot appear after in the same block'); + } + + $condition = $this->extractAttributeValue($attributes, 'condition'); + if ($condition === '') { + throw new ParserException(' requires a non-empty condition attribute'); + } + + return new ElseIfNode($condition); + } + + private function parseElseTag(bool $isSelfClosing): ElseNode + { + if ($isSelfClosing) { + throw new ParserException(' cannot be self-closing'); + } + + $stackIndex = array_key_last($this->controlStack); + if ($stackIndex === null || $this->controlStack[$stackIndex]['tag'] !== 'If') { + throw new ParserException(' must be inside an block'); + } + + if ($this->controlStack[$stackIndex]['hasElse']) { + throw new ParserException('Only one is allowed per block'); + } + + $this->controlStack[$stackIndex]['hasElse'] = true; + + return new ElseNode(); + } + + private function parseBlockTag(string $attributes, bool $isSelfClosing): BlockNode + { + if ($isSelfClosing) { + throw new ParserException(' cannot be self-closing'); + } + + $name = $this->extractAttributeValue($attributes, 'name'); + if ($name === '') { + throw new ParserException(' requires a non-empty name attribute'); + } + + $this->controlStack[] = ['tag' => 'Block', 'hasElse' => false]; + + return new BlockNode($name); + } + + private function parseForeachTag(string $attributes, bool $isSelfClosing): ForeachNode + { + if ($isSelfClosing) { + throw new ParserException(' cannot be self-closing'); + } + + $items = $this->extractAttributeValue($attributes, 'items'); + $as = $this->extractAttributeValue($attributes, 'as'); + + if ($items === '') { + throw new ParserException(' requires a non-empty items attribute'); + } + if ($as === '') { + throw new ParserException(' requires a non-empty as attribute'); + } + + $this->controlStack[] = ['tag' => 'Foreach', 'hasElse' => false]; + + return new ForeachNode($items, $as); + } + // ------------------------------------------------------------------------- // Attribute helpers // ------------------------------------------------------------------------- @@ -194,4 +321,3 @@ private function advance(): void $this->position++; } } - From 21559acb56a1140a25ea800b4570039383c37095 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 03:28:35 +0000 Subject: [PATCH 08/17] Replace eval rendering with compiled include strategy --- src/Core/View/Engine.php | 5 ++- src/Core/View/Renderer.php | 63 ++++++++++++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index dfb6b1c..b4fb8d0 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -29,6 +29,7 @@ class Engine private CacheManager $cacheManager; private ComponentRegistry $componentRegistry; private FilterRegistry $filterRegistry; + private string $compiledTemplatesDir; /** * Construtor do Engine @@ -36,6 +37,7 @@ class Engine * @param array $config [ * 'templates_dir' => string, * 'cache_dir' => string, + * 'compiled_templates_dir' => string, * 'auto_escape' => bool, * 'cache_enabled' => bool, * ] @@ -46,6 +48,7 @@ public function __construct(array $config = []) $this->cacheDir = $config['cache_dir'] ?? __DIR__ . '/../../../cache'; $this->autoEscape = $config['auto_escape'] ?? true; $this->cacheEnabled = $config['cache_enabled'] ?? true; + $this->compiledTemplatesDir = ($config['compiled_templates_dir'] ?? ($this->cacheDir . '/compiled')); // Validar diretórios if (!is_dir($this->templatesDir)) { @@ -64,7 +67,7 @@ public function __construct(array $config = []) $this->lexer = new Lexer(); $this->parser = new Parser(); $this->compiler = new Compiler(); - $this->renderer = new Renderer(); + $this->renderer = new Renderer($this->compiledTemplatesDir); $this->cacheManager = new CacheManager(new FileCacheAdapter($this->cacheDir)); $this->componentRegistry = new ComponentRegistry(); $this->filterRegistry = new FilterRegistry(); diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index b14d3ff..95891a8 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -7,6 +7,14 @@ */ class Renderer { + private string $compiledTemplatesDir; + + public function __construct(?string $compiledTemplatesDir = null) + { + $baseDir = $compiledTemplatesDir ?? (sys_get_temp_dir() . '/php-template-engine'); + $this->compiledTemplatesDir = rtrim($baseDir, '/'); + } + /** * Renderiza código PHP compilado * @@ -17,18 +25,49 @@ class Renderer */ public function render(string $compiledCode, array $data = [], Engine $engine = null): string { - // Criar escopo de variáveis - extract($data, EXTR_SKIP); - $__engine = $engine; - - // Capturar output - ob_start(); - try { - eval('?>' . $compiledCode); - return ob_get_clean(); - } catch (\Throwable $e) { - ob_end_clean(); - throw $e; + $templateFile = $this->materializeCompiledTemplate($compiledCode); + $renderer = static function (string $__templateFile, array $__data, ?Engine $__engine): string { + extract($__data, EXTR_SKIP); + + ob_start(); + try { + include $__templateFile; + return ob_get_clean(); + } catch (\Throwable $e) { + ob_end_clean(); + throw $e; + } + }; + + return $renderer($templateFile, $data, $engine); + } + + private function materializeCompiledTemplate(string $compiledCode): string + { + if (!is_dir($this->compiledTemplatesDir)) { + if (!mkdir($this->compiledTemplatesDir, 0755, true) && !is_dir($this->compiledTemplatesDir)) { + throw new \RuntimeException("Unable to create compiled template directory: {$this->compiledTemplatesDir}"); + } } + + $hash = hash('sha256', $compiledCode); + $targetFile = $this->compiledTemplatesDir . '/tpl_' . $hash . '.php'; + + if (!is_file($targetFile)) { + $tmpFile = $targetFile . '.tmp.' . bin2hex(random_bytes(6)); + $bytes = file_put_contents($tmpFile, $compiledCode, LOCK_EX); + if ($bytes === false) { + throw new \RuntimeException("Unable to write compiled template file: {$targetFile}"); + } + + if (!@rename($tmpFile, $targetFile)) { + @unlink($tmpFile); + if (!is_file($targetFile)) { + throw new \RuntimeException("Unable to finalize compiled template file: {$targetFile}"); + } + } + } + + return $targetFile; } } From c75fb7173202ca4b9972067930267326cb01ba1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 03:35:46 +0000 Subject: [PATCH 09/17] Add precise compiled-template syntax validation errors --- src/Core/View/Engine.php | 8 ++-- src/Core/View/Renderer.php | 98 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index b4fb8d0..ed76cb0 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -120,14 +120,16 @@ public function render(string $templatePath, array $data = []): string // Compilar $compiledCode = $this->compiler->compile($ast); + // Renderizar + $output = $this->renderer->render($compiledCode, $data, $this); + // Cachear se habilitado if ($this->cacheEnabled) { $this->cacheManager->set($cacheKey, $compiledCode); } - // Renderizar - return $this->renderer->render($compiledCode, $data, $this); - } catch (\Exception $e) { + return $output; + } catch (\Throwable $e) { throw new ViewException("Error rendering template '{$templatePath}': " . $e->getMessage(), 0, $e); } } diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 95891a8..7b6f4af 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -2,12 +2,16 @@ namespace Core\View; +use Core\View\Exceptions\SyntaxException; + /** * Renderizador de templates compilados */ class Renderer { private string $compiledTemplatesDir; + /** @var array */ + private array $validatedTemplates = []; public function __construct(?string $compiledTemplatesDir = null) { @@ -68,6 +72,100 @@ private function materializeCompiledTemplate(string $compiledCode): string } } + $this->validateCompiledTemplateSyntax($targetFile); + return $targetFile; } + + private function validateCompiledTemplateSyntax(string $templateFile): void + { + if (isset($this->validatedTemplates[$templateFile])) { + return; + } + + $command = escapeshellarg(PHP_BINARY) . ' -n -l ' . escapeshellarg($templateFile); + $descriptors = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptors, $pipes); + if (!is_resource($process)) { + throw new SyntaxException("Unable to validate template syntax for '{$templateFile}'."); + } + + $stdout = stream_get_contents($pipes[1]) ?: ''; + fclose($pipes[1]); + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[2]); + $exitCode = proc_close($process); + $output = trim($stderr !== '' ? $stderr : $stdout); + + if ($exitCode === 0) { + $this->validatedTemplates[$templateFile] = true; + return; + } + + [$line, $column, $snippet] = $this->extractErrorLocation($templateFile, $output); + + throw new SyntaxException( + "Compiled template syntax error in '{$templateFile}' at line {$line}, column {$column}." . + ($snippet !== '' ? " Snippet: {$snippet}" : '') . + ($output !== '' ? " PHP lint: {$output}" : '') + ); + } + + /** + * @return array{0:int,1:int,2:string} + */ + private function extractErrorLocation(string $templateFile, string $lintOutput): array + { + $line = 1; + if (preg_match('/on line (\d+)/i', $lintOutput, $lineMatch) === 1) { + $line = (int) $lineMatch[1]; + } + + $lineContent = ''; + $lines = @file($templateFile); + if (is_array($lines) && isset($lines[$line - 1])) { + $lineContent = rtrim($lines[$line - 1], "\r\n"); + } + + $tokenDescriptor = ''; + if (preg_match('/unexpected\s+(.+?)\s+in\s+/i', $lintOutput, $tokenMatch) === 1) { + $tokenDescriptor = trim($tokenMatch[1]); + } + + $needle = $this->extractNeedleFromTokenDescriptor($tokenDescriptor); + $column = 1; + if ($needle !== '' && $lineContent !== '') { + $position = strpos($lineContent, $needle); + if ($position !== false) { + $column = $position + 1; + } + } + + return [$line, $column, $lineContent]; + } + + private function extractNeedleFromTokenDescriptor(string $descriptor): string + { + if ($descriptor === '') { + return ''; + } + + if (preg_match('/double-quoted string "([^"]*)"/', $descriptor, $match) === 1) { + return $match[1]; + } + + if (preg_match("/single-quoted string '([^']*)'/", $descriptor, $match) === 1) { + return $match[1]; + } + + if (preg_match('/token\s+([A-Z_]+)/', $descriptor, $match) === 1) { + return $match[1]; + } + + return trim($descriptor, "\"' "); + } } From 811414b9b8eb875ad28e06dc110eca06e08a41cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 03:41:36 +0000 Subject: [PATCH 10/17] Harden template syntax linting for non-CLI SAPIs --- src/Core/View/Renderer.php | 91 +++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 7b6f4af..b0c1540 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -83,15 +83,50 @@ private function validateCompiledTemplateSyntax(string $templateFile): void return; } - $command = escapeshellarg(PHP_BINARY) . ' -n -l ' . escapeshellarg($templateFile); + $lintResult = $this->runCliSyntaxLint($templateFile); + if ($lintResult === null) { + $this->validatedTemplates[$templateFile] = true; + return; + } + + [$exitCode, $output] = $lintResult; + if ($exitCode === 0) { + $this->validatedTemplates[$templateFile] = true; + return; + } + + if (!$this->isSyntaxLintOutput($output)) { + $this->validatedTemplates[$templateFile] = true; + return; + } + + [$line, $column, $snippet] = $this->extractErrorLocation($templateFile, $output); + + throw new SyntaxException( + "Compiled template syntax error in '{$templateFile}' at line {$line}, column {$column}." . + ($snippet !== '' ? " Snippet: {$snippet}" : '') . + ($output !== '' ? " PHP lint: {$output}" : '') + ); + } + + /** + * @return array{0:int,1:string}|null + */ + private function runCliSyntaxLint(string $templateFile): ?array + { + $phpBinary = $this->resolvePhpBinaryForLint(); + if ($phpBinary === null) { + return null; + } + $descriptors = [ 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; - $process = proc_open($command, $descriptors, $pipes); + $process = @proc_open([$phpBinary, '-n', '-l', $templateFile], $descriptors, $pipes); if (!is_resource($process)) { - throw new SyntaxException("Unable to validate template syntax for '{$templateFile}'."); + return null; } $stdout = stream_get_contents($pipes[1]) ?: ''; @@ -101,18 +136,50 @@ private function validateCompiledTemplateSyntax(string $templateFile): void $exitCode = proc_close($process); $output = trim($stderr !== '' ? $stderr : $stdout); - if ($exitCode === 0) { - $this->validatedTemplates[$templateFile] = true; - return; + return [$exitCode, $output]; + } + + private function resolvePhpBinaryForLint(): ?string + { + $candidates = []; + $candidates[] = PHP_BINARY; + + $bindir = defined('PHP_BINDIR') ? PHP_BINDIR : ''; + if ($bindir !== '') { + $candidates[] = rtrim($bindir, '/\\') . DIRECTORY_SEPARATOR . 'php'; + $candidates[] = rtrim($bindir, '/\\') . DIRECTORY_SEPARATOR . 'php.exe'; } - [$line, $column, $snippet] = $this->extractErrorLocation($templateFile, $output); + $candidates[] = 'php'; + $candidates[] = 'php.exe'; - throw new SyntaxException( - "Compiled template syntax error in '{$templateFile}' at line {$line}, column {$column}." . - ($snippet !== '' ? " Snippet: {$snippet}" : '') . - ($output !== '' ? " PHP lint: {$output}" : '') - ); + foreach ($candidates as $candidate) { + if (!$this->looksLikePhpBinary($candidate)) { + continue; + } + + if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/') || str_contains($candidate, '\\')) { + if (!is_file($candidate)) { + continue; + } + return $candidate; + } + + return $candidate; + } + + return null; + } + + private function looksLikePhpBinary(string $binary): bool + { + $name = basename(str_replace('\\', '/', $binary)); + return preg_match('/^php(?:-cgi|-dbg)?(?:\.exe)?$/i', $name) === 1; + } + + private function isSyntaxLintOutput(string $output): bool + { + return preg_match('/(parse error|syntax error|errors parsing|unexpected\s+\S+)/i', $output) === 1; } /** From 6d19c3b91886fd9f67ec19935c4e43b21a8f930d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:54:04 +0000 Subject: [PATCH 11/17] fix: repair lexer tag scanner, ElseIf close tag, and add __loop to Foreach - Lexer extractTag: replaced [^>]* regex with character-scanner that handles '>' inside quoted attribute values and {{ }} expressions (e.g. ) - Parser parseCloseTag: silently ignore instead of throwing an exception - ForeachNode: inject $__loop object (index, count, total, first, last, even, odd, percentage) at the top of each iteration; use a stack to restore $__loop for nested foreach loops - CloseTagNode: restore $__loop from stack when closing a Foreach - SYNTAX.md: fix associative-array example (as="value,key") to match actual engine convention (first var = value, second = key) --- SYNTAX.md | 2 +- src/Core/View/Lexer.php | 92 ++++++++++++++++++++++++---- src/Core/View/Nodes/CloseTagNode.php | 6 +- src/Core/View/Nodes/ForeachNode.php | 25 ++++++++ src/Core/View/Parser.php | 5 ++ 5 files changed, 115 insertions(+), 15 deletions(-) diff --git a/SYNTAX.md b/SYNTAX.md index a0d2fdd..b1a0621 100644 --- a/SYNTAX.md +++ b/SYNTAX.md @@ -151,7 +151,7 @@ Com índice: Com chave e valor (arrays associativos): ```html - +
{{ key }}: {{ value }}
``` diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index efab5b6..46657e3 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -187,32 +187,98 @@ private function extractKeywordDirective(string $content, int $pos): ?array } /** - * Extrai uma tag customizada - * + * Extrai uma tag customizada usando um scanner de caracteres que lida + * corretamente com '>' dentro de valores de atributos (strings entre + * aspas e expressões {{ }}). + * * @param string $content Conteúdo * @param int $pos Posição atual * @return array Token da tag */ private function extractTag(string $content, int $pos): array { - preg_match('/^<([A-Z][a-zA-Z0-9]*)([^>]*)\s*\/?>/s', substr($content, $pos), $matches); + $length = strlen($content); + $cursor = $pos + 1; // avança além do '<' + + // Lê o nome da tag: letras, dígitos e underscore + $nameStart = $cursor; + while ($cursor < $length && (ctype_alnum($content[$cursor]) || $content[$cursor] === '_')) { + $cursor++; + } + $tagName = substr($content, $nameStart, $cursor - $nameStart); - if (empty($matches)) { + if ($tagName === '') { throw new SyntaxException("Invalid tag at position $pos"); } - $tagName = $matches[1]; - $attributes = trim($matches[2]); - $fullMatch = $matches[0]; - $selfClosing = str_ends_with($fullMatch, '/>'); + $attrStart = $cursor; + $attrEnd = null; + $selfClosing = false; + + while ($cursor < $length) { + $char = $content[$cursor]; + + // Valor de atributo entre aspas — ignora qualquer '>' interno + if ($char === '"' || $char === "'") { + $quote = $char; + $cursor++; + while ($cursor < $length && $content[$cursor] !== $quote) { + if ($content[$cursor] === '\\') { + $cursor++; // pula char escapado + } + $cursor++; + } + if ($cursor < $length) { + $cursor++; // pula a aspa de fechamento + } + continue; + } + + // Expressão de template {{ ... }} — ignora qualquer '>' interno + if ($char === '{' && ($content[$cursor + 1] ?? '') === '{') { + $cursor += 2; + while ($cursor < $length) { + if ($content[$cursor] === '}' && ($content[$cursor + 1] ?? '') === '}') { + $cursor += 2; + break; + } + $cursor++; + } + continue; + } + + // Fechamento auto-fechante: /> + if ($char === '/' && ($content[$cursor + 1] ?? '') === '>') { + $attrEnd = $cursor; + $selfClosing = true; + $cursor += 2; + break; + } + + // Fechamento normal: > + if ($char === '>') { + $attrEnd = $cursor; + $cursor++; + break; + } + + $cursor++; + } + + if ($attrEnd === null) { + throw new SyntaxException("Unclosed tag '<{$tagName}' at position $pos"); + } + + $attrStr = substr($content, $attrStart, $attrEnd - $attrStart); + $fullMatch = substr($content, $pos, $cursor - $pos); return [ - 'type' => 'TAG', - 'name' => $tagName, - 'attributes' => $attributes, + 'type' => 'TAG', + 'name' => $tagName, + 'attributes' => trim($attrStr), 'self_closing' => $selfClosing, - 'length' => strlen($fullMatch), - 'value' => $fullMatch + 'length' => $cursor - $pos, + 'value' => $fullMatch, ]; } diff --git a/src/Core/View/Nodes/CloseTagNode.php b/src/Core/View/Nodes/CloseTagNode.php index 67de5c7..b3ad8e2 100644 --- a/src/Core/View/Nodes/CloseTagNode.php +++ b/src/Core/View/Nodes/CloseTagNode.php @@ -13,7 +13,11 @@ public function __construct(public readonly string $tagName) {} public function compile(CompilationContext $ctx): void { - if ($this->tagName !== 'Block') { + if ($this->tagName === 'Foreach') { + $ctx->writeLine('}'); + // Restaura o $__loop do loop pai (suporte a foreach aninhado) + $ctx->writeLine('$__loop = array_pop($__loop_stack);'); + } elseif ($this->tagName !== 'Block') { $ctx->writeLine('}'); } } diff --git a/src/Core/View/Nodes/ForeachNode.php b/src/Core/View/Nodes/ForeachNode.php index 9e3eb13..af3e8b9 100644 --- a/src/Core/View/Nodes/ForeachNode.php +++ b/src/Core/View/Nodes/ForeachNode.php @@ -16,6 +16,18 @@ public function compile(CompilationContext $ctx): void $itemsCode = $ctx->expr($this->items); $vars = array_values(array_filter(array_map('trim', explode(',', $this->as)))); + // Identificador único para variáveis internas deste loop (suporte a aninhamento) + $uid = substr(md5($this->items . ':' . $this->as), 0, 8); + $totalVar = '$__loop_total_' . $uid; + $indexVar = '$__loop_idx_' . $uid; + + // Empilha o $__loop do loop pai para restauração após o fechamento + $ctx->writeLine('$__loop_stack ??= [];'); + $ctx->writeLine('$__loop_stack[] = $__loop ?? null;'); + // Captura o array uma vez para pré-calcular o total + $ctx->writeLine($totalVar . ' = count((array)(' . $itemsCode . '));'); + $ctx->writeLine($indexVar . ' = 0;'); + if (count($vars) >= 2) { $valueVar = '$' . ltrim($vars[0], '$'); $keyVar = '$' . ltrim($vars[1], '$'); @@ -24,6 +36,19 @@ public function compile(CompilationContext $ctx): void $valueVar = '$' . ltrim($vars[0] ?? 'item', '$'); $ctx->writeLine('foreach ((array)(' . $itemsCode . ') as ' . $valueVar . ') {'); } + + // Injeta o objeto $__loop no início de cada iteração + $ctx->writeLine('$__loop = (object)['); + $ctx->writeLine(" 'index' => {$indexVar},"); + $ctx->writeLine(" 'count' => {$indexVar} + 1,"); + $ctx->writeLine(" 'total' => {$totalVar},"); + $ctx->writeLine(" 'first' => {$indexVar} === 0,"); + $ctx->writeLine(" 'last' => {$indexVar} === {$totalVar} - 1,"); + $ctx->writeLine(" 'even' => {$indexVar} % 2 === 0,"); + $ctx->writeLine(" 'odd' => {$indexVar} % 2 !== 0,"); + $ctx->writeLine(" 'percentage' => {$totalVar} > 0 ? round(({$indexVar} + 1) / {$totalVar} * 100, 2) : 0.0,"); + $ctx->writeLine('];'); + $ctx->writeLine($indexVar . '++;'); } public function __toString(): string diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index 368da01..35cec7f 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -113,6 +113,11 @@ private function parseCloseTag(): ?CloseTagNode return null; } + // ElseIf não empilha no controlStack, logo
é ignorado silenciosamente + if ($name === 'ElseIf') { + return null; + } + if (!in_array($name, ['If', 'Foreach', 'Block'], true)) { throw new ParserException("Unexpected closing tag "); } From 60a6bf75f954045f5f23e81f0bd880ccce0411b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:06:27 +0000 Subject: [PATCH 12/17] Fix extractExpression/extractRaw to respect quoted strings containing }} or !} --- src/Core/View/Lexer.php | 112 ++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 26 deletions(-) diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index 46657e3..12c9404 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -284,53 +284,113 @@ private function extractTag(string $content, int $pos): array /** * Extrai uma expressão {{ }} - * + * + * Usa um scanner de caracteres para ignorar corretamente }} + * que apareçam dentro de strings entre aspas na 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 { - $start = $pos + 2; - $end = strpos($content, '}}', $start); + $length = strlen($content); + $cursor = $pos + 2; // avança além de {{ + $buffer = ''; + $quote = null; - if ($end === false) { - throw new SyntaxException("Unclosed expression at position $pos"); - } + while ($cursor < $length) { + $char = $content[$cursor]; - $expression = substr($content, $start, $end - $start); - $length = $end - $pos + 2; + if ($quote !== null) { + if ($char === '\\' && $cursor + 1 < $length) { + $buffer .= $char . $content[$cursor + 1]; + $cursor += 2; + continue; + } + if ($char === $quote) { + $quote = null; + } + $buffer .= $char; + $cursor++; + continue; + } - return [ - 'type' => 'EXPRESSION', - 'value' => trim($expression), - 'length' => $length - ]; + if ($char === '"' || $char === "'") { + $quote = $char; + $buffer .= $char; + $cursor++; + continue; + } + + if ($char === '}' && ($content[$cursor + 1] ?? '') === '}') { + return [ + 'type' => 'EXPRESSION', + 'value' => trim($buffer), + 'length' => $cursor - $pos + 2, + ]; + } + + $buffer .= $char; + $cursor++; + } + + throw new SyntaxException("Unclosed expression at position $pos"); } /** * Extrai raw output {! !} - * + * + * Usa um scanner de caracteres para ignorar corretamente !} + * que apareçam dentro de strings entre aspas na expressão. + * * @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); + $length = strlen($content); + $cursor = $pos + 2; // avança além de {! + $buffer = ''; + $quote = null; - if ($end === false) { - throw new SyntaxException("Unclosed raw output at position $pos"); - } + while ($cursor < $length) { + $char = $content[$cursor]; - $expression = substr($content, $start, $end - $start); - $length = $end - $pos + 2; + if ($quote !== null) { + if ($char === '\\' && $cursor + 1 < $length) { + $buffer .= $char . $content[$cursor + 1]; + $cursor += 2; + continue; + } + if ($char === $quote) { + $quote = null; + } + $buffer .= $char; + $cursor++; + continue; + } - return [ - 'type' => 'RAW', - 'value' => trim($expression), - 'length' => $length - ]; + if ($char === '"' || $char === "'") { + $quote = $char; + $buffer .= $char; + $cursor++; + continue; + } + + if ($char === '!' && ($content[$cursor + 1] ?? '') === '}') { + return [ + 'type' => 'RAW', + 'value' => trim($buffer), + 'length' => $cursor - $pos + 2, + ]; + } + + $buffer .= $char; + $cursor++; + } + + throw new SyntaxException("Unclosed raw output at position $pos"); } } From 99eb30eeceb833fc037663012a56bd7935c440c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:44:50 +0000 Subject: [PATCH 13/17] Refactor rendering flow and exception model --- README.md | 9 ++- examples/index.php | 3 +- src/Core/View/Engine.php | 81 ++++++++++++++------ src/Core/View/Environment.php | 26 +++++++ src/Core/View/Exceptions/SyntaxException.php | 63 +++++++++++++++ src/Core/View/Exceptions/ViewException.php | 31 ++++++++ src/Core/View/Parser.php | 55 ++++++++----- src/Core/View/Renderer.php | 33 ++++++-- 8 files changed, 250 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 421b982..b596406 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ use Core\View\Engine; $engine = new Engine([ 'templates_dir' => __DIR__ . '/templates', 'cache_dir' => __DIR__ . '/cache', - 'auto_escape' => true + 'auto_escape' => true, + 'debug' => true, // false em produção para mensagens seguras ]); echo $engine->render('home.html', [ @@ -82,6 +83,12 @@ import { UserCard } from "@components/UserCard"; Veja [SYNTAX.md](./SYNTAX.md) para documentação completa da sintaxe. +## Modo Debug e Produção + +- `debug: true` (padrão): mantém mensagens detalhadas para facilitar desenvolvimento. +- `debug: false` ou `environment: 'production'`: retorna mensagens seguras e genéricas para produção. +- Erros de sintaxe compilada geram `SyntaxException` com contexto interno (arquivo, linha, coluna e snippet), preservado no encadeamento da exceção. + ## Estrutura do Projeto ``` diff --git a/examples/index.php b/examples/index.php index 9392a5e..7d7d20c 100644 --- a/examples/index.php +++ b/examples/index.php @@ -9,7 +9,8 @@ 'templates_dir' => __DIR__ . '/templates', 'cache_dir' => __DIR__ . '/../cache', 'auto_escape' => true, - 'cache_enabled' => true + 'cache_enabled' => true, + 'debug' => true ]); // Dados para o template diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index ed76cb0..ef920ee 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -83,55 +83,88 @@ public function __construct(array $config = []) 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}"); } $dependencies = $this->collectTemplateDependencies($absolutePath, [$absolutePath]); + $cacheKey = null; - // Verificar cache if ($this->cacheEnabled) { $cacheKey = $this->generateCacheKey($dependencies); $cachedContent = $this->cacheManager->get($cacheKey); if ($cachedContent !== null) { - return $this->renderer->render($cachedContent, $data, $this); + return $this->renderer->render($cachedContent, $this->prepareRenderData($data), $this); } } - // Ler template - $content = file_get_contents($absolutePath); - if ($content === false) { - throw new ViewException("Unable to read template: {$templatePath}"); + $compiledCode = $this->compileTemplate($absolutePath, $templatePath); + $output = $this->renderer->render($compiledCode, $this->prepareRenderData($data), $this); + + if ($this->cacheEnabled && $cacheKey !== null) { + $this->cacheManager->set($cacheKey, $compiledCode); } - // Resolver herança de templates (extends + blocks) - $content = $this->resolveTemplateInheritance($content, [$absolutePath]); + return $output; + } catch (\Throwable $e) { + throw $this->normalizeRenderException($templatePath, $e); + } + } - // Tokenizar - $tokens = $this->lexer->tokenize($content); + private function compileTemplate(string $absolutePath, string $templatePath): string + { + $content = $this->readTemplateFile($absolutePath, $templatePath); + $content = $this->resolveTemplateInheritance($content, [$absolutePath]); + $tokens = $this->lexer->tokenize($content); + $ast = $this->parser->parse($tokens); - // Fazer parse - $ast = $this->parser->parse($tokens); + return $this->compiler->compile($ast); + } - // Compilar - $compiledCode = $this->compiler->compile($ast); + private function readTemplateFile(string $absolutePath, string $templatePath): string + { + $content = file_get_contents($absolutePath); + if ($content === false) { + throw new ViewException("Unable to read template: {$templatePath}"); + } - // Renderizar - $output = $this->renderer->render($compiledCode, $data, $this); + return $content; + } - // Cachear se habilitado - if ($this->cacheEnabled) { - $this->cacheManager->set($cacheKey, $compiledCode); + private function prepareRenderData(array $data): array + { + return array_merge($this->environment->getGlobals(), $data); + } + + private function normalizeRenderException(string $templatePath, \Throwable $exception): ViewException + { + if ($this->environment->isDebug()) { + if ($exception instanceof ViewException) { + return $exception; } - return $output; - } catch (\Throwable $e) { - throw new ViewException("Error rendering template '{$templatePath}': " . $e->getMessage(), 0, $e); + return new ViewException( + "Error rendering template '{$templatePath}': " . $exception->getMessage(), + 0, + $exception, + ['template' => $templatePath], + 'Template rendering failed.' + ); } + + $safeMessage = $exception instanceof ViewException + ? $exception->getSafeMessage() + : 'Template rendering failed.'; + + return new ViewException( + $safeMessage, + 0, + $exception, + ['template' => $templatePath], + $safeMessage + ); } /** diff --git a/src/Core/View/Environment.php b/src/Core/View/Environment.php index d0ce098..65ac622 100644 --- a/src/Core/View/Environment.php +++ b/src/Core/View/Environment.php @@ -9,10 +9,12 @@ class Environment { private array $config; private array $globals = []; + private bool $debug; public function __construct(array $config = []) { $this->config = $config; + $this->debug = $this->resolveDebugMode($config); } /** @@ -60,4 +62,28 @@ public function getConfig(string $key, $default = null) { return $this->config[$key] ?? $default; } + + public function isDebug(): bool + { + return $this->debug; + } + + public function isProduction(): bool + { + return !$this->debug; + } + + private function resolveDebugMode(array $config): bool + { + if (array_key_exists('debug', $config)) { + return (bool) $config['debug']; + } + + $environment = strtolower((string) ($config['environment'] ?? '')); + if (in_array($environment, ['prod', 'production'], true)) { + return false; + } + + return true; + } } diff --git a/src/Core/View/Exceptions/SyntaxException.php b/src/Core/View/Exceptions/SyntaxException.php index 21a195a..3a4482f 100644 --- a/src/Core/View/Exceptions/SyntaxException.php +++ b/src/Core/View/Exceptions/SyntaxException.php @@ -7,4 +7,67 @@ */ class SyntaxException extends ViewException { + private string $templateFile; + private int $lineNumber; + private int $columnNumber; + private string $snippet; + private string $lintOutput; + + public function __construct( + string $templateFile, + int $lineNumber, + int $columnNumber, + string $snippet = '', + string $lintOutput = '', + int $code = 0, + ?\Throwable $previous = null + ) { + $context = [ + 'template_file' => $templateFile, + 'line' => $lineNumber, + 'column' => $columnNumber, + 'snippet' => $snippet, + 'lint_output' => $lintOutput, + ]; + + $message = "Compiled template syntax error in '{$templateFile}' at line {$lineNumber}, column {$columnNumber}."; + if ($snippet !== '') { + $message .= " Snippet: {$snippet}"; + } + if ($lintOutput !== '') { + $message .= " PHP lint: {$lintOutput}"; + } + + parent::__construct($message, $code, $previous, $context, 'Template syntax error.'); + $this->templateFile = $templateFile; + $this->lineNumber = $lineNumber; + $this->columnNumber = $columnNumber; + $this->snippet = $snippet; + $this->lintOutput = $lintOutput; + } + + public function getTemplateFile(): string + { + return $this->templateFile; + } + + public function getLineNumber(): int + { + return $this->lineNumber; + } + + public function getColumnNumber(): int + { + return $this->columnNumber; + } + + public function getSnippet(): string + { + return $this->snippet; + } + + public function getLintOutput(): string + { + return $this->lintOutput; + } } diff --git a/src/Core/View/Exceptions/ViewException.php b/src/Core/View/Exceptions/ViewException.php index e71c581..09478c6 100644 --- a/src/Core/View/Exceptions/ViewException.php +++ b/src/Core/View/Exceptions/ViewException.php @@ -7,4 +7,35 @@ */ class ViewException extends \Exception { + /** @var array */ + private array $context; + private string $safeMessage; + + /** + * @param array $context + */ + public function __construct( + string $message = "", + int $code = 0, + ?\Throwable $previous = null, + array $context = [], + string $safeMessage = 'Template rendering failed.' + ) { + parent::__construct($message, $code, $previous); + $this->context = $context; + $this->safeMessage = $safeMessage; + } + + /** + * @return array + */ + public function getContext(): array + { + return $this->context; + } + + public function getSafeMessage(): string + { + return $this->safeMessage; + } } diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index 35cec7f..f5499ad 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -56,7 +56,7 @@ public function parse(array $tokens): array if ($this->controlStack !== []) { $openTags = implode(', ', array_map(static fn(array $entry): string => $entry['tag'], $this->controlStack)); - throw new ParserException("Unclosed control tags: {$openTags}"); + throw $this->parserError("Unclosed control tags: {$openTags}"); } return $nodes; @@ -108,7 +108,7 @@ private function parseCloseTag(): ?CloseTagNode if ($name === 'Else') { $current = end($this->controlStack); if ($current === false || $current['tag'] !== 'If' || !$current['hasElse']) { - throw new ParserException('Unexpected closing tag
without an active block'); + throw $this->parserError('Unexpected closing tag without an active block'); } return null; } @@ -119,13 +119,13 @@ private function parseCloseTag(): ?CloseTagNode } if (!in_array($name, ['If', 'Foreach', 'Block'], true)) { - throw new ParserException("Unexpected closing tag "); + throw $this->parserError("Unexpected closing tag "); } $current = end($this->controlStack); if ($current === false || $current['tag'] !== $name) { $openTag = $current['tag'] ?? 'none'; - throw new ParserException("Mismatched closing tag . Current open tag: {$openTag}"); + throw $this->parserError("Mismatched closing tag . Current open tag: {$openTag}"); } array_pop($this->controlStack); @@ -146,7 +146,7 @@ private function parseKeyword(): null private function parseComponentTag(array $token): ComponentNode { if (!(bool) ($token['self_closing'] ?? false)) { - throw new ParserException( + throw $this->parserError( "Component tag <{$token['name']}> must be self-closing (use <{$token['name']} ... />)" ); } @@ -157,12 +157,12 @@ private function parseComponentTag(array $token): ComponentNode private function parseIfTag(string $attributes, bool $isSelfClosing): IfNode { if ($isSelfClosing) { - throw new ParserException(' cannot be self-closing'); + throw $this->parserError(' cannot be self-closing'); } $condition = $this->extractAttributeValue($attributes, 'condition'); if ($condition === '') { - throw new ParserException(' requires a non-empty condition attribute'); + throw $this->parserError(' requires a non-empty condition attribute'); } $this->controlStack[] = ['tag' => 'If', 'hasElse' => false]; @@ -173,21 +173,21 @@ private function parseIfTag(string $attributes, bool $isSelfClosing): IfNode private function parseElseIfTag(string $attributes, bool $isSelfClosing): ElseIfNode { if ($isSelfClosing) { - throw new ParserException(' cannot be self-closing'); + throw $this->parserError(' cannot be self-closing'); } $current = end($this->controlStack); if ($current === false || $current['tag'] !== 'If') { - throw new ParserException(' must be inside an block'); + throw $this->parserError(' must be inside an block'); } if ($current['hasElse']) { - throw new ParserException(' cannot appear after in the same block'); + throw $this->parserError(' cannot appear after in the same block'); } $condition = $this->extractAttributeValue($attributes, 'condition'); if ($condition === '') { - throw new ParserException(' requires a non-empty condition attribute'); + throw $this->parserError(' requires a non-empty condition attribute'); } return new ElseIfNode($condition); @@ -196,16 +196,16 @@ private function parseElseIfTag(string $attributes, bool $isSelfClosing): ElseIf private function parseElseTag(bool $isSelfClosing): ElseNode { if ($isSelfClosing) { - throw new ParserException(' cannot be self-closing'); + throw $this->parserError(' cannot be self-closing'); } $stackIndex = array_key_last($this->controlStack); if ($stackIndex === null || $this->controlStack[$stackIndex]['tag'] !== 'If') { - throw new ParserException(' must be inside an block'); + throw $this->parserError(' must be inside an block'); } if ($this->controlStack[$stackIndex]['hasElse']) { - throw new ParserException('Only one is allowed per block'); + throw $this->parserError('Only one is allowed per block'); } $this->controlStack[$stackIndex]['hasElse'] = true; @@ -216,12 +216,12 @@ private function parseElseTag(bool $isSelfClosing): ElseNode private function parseBlockTag(string $attributes, bool $isSelfClosing): BlockNode { if ($isSelfClosing) { - throw new ParserException(' cannot be self-closing'); + throw $this->parserError(' cannot be self-closing'); } $name = $this->extractAttributeValue($attributes, 'name'); if ($name === '') { - throw new ParserException(' requires a non-empty name attribute'); + throw $this->parserError(' requires a non-empty name attribute'); } $this->controlStack[] = ['tag' => 'Block', 'hasElse' => false]; @@ -232,17 +232,17 @@ private function parseBlockTag(string $attributes, bool $isSelfClosing): BlockNo private function parseForeachTag(string $attributes, bool $isSelfClosing): ForeachNode { if ($isSelfClosing) { - throw new ParserException(' cannot be self-closing'); + throw $this->parserError(' cannot be self-closing'); } $items = $this->extractAttributeValue($attributes, 'items'); $as = $this->extractAttributeValue($attributes, 'as'); if ($items === '') { - throw new ParserException(' requires a non-empty items attribute'); + throw $this->parserError(' requires a non-empty items attribute'); } if ($as === '') { - throw new ParserException(' requires a non-empty as attribute'); + throw $this->parserError(' requires a non-empty as attribute'); } $this->controlStack[] = ['tag' => 'Foreach', 'hasElse' => false]; @@ -325,4 +325,21 @@ private function advance(): void { $this->position++; } + + private function parserError(string $message): ParserException + { + $token = $this->current(); + $tokenType = $token['type'] ?? 'UNKNOWN'; + $tokenPreview = trim((string) ($token['value'] ?? ($token['name'] ?? ''))); + if ($tokenPreview === '') { + $tokenPreview = '(empty)'; + } + if (strlen($tokenPreview) > 80) { + $tokenPreview = substr($tokenPreview, 0, 77) . '...'; + } + + return new ParserException( + sprintf('%s at token #%d [%s: %s]', $message, $this->position, $tokenType, $tokenPreview) + ); + } } diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index b0c1540..12e2c3a 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -2,6 +2,7 @@ namespace Core\View; +use Core\View\Exceptions\ViewException; use Core\View\Exceptions\SyntaxException; /** @@ -50,7 +51,13 @@ private function materializeCompiledTemplate(string $compiledCode): string { if (!is_dir($this->compiledTemplatesDir)) { if (!mkdir($this->compiledTemplatesDir, 0755, true) && !is_dir($this->compiledTemplatesDir)) { - throw new \RuntimeException("Unable to create compiled template directory: {$this->compiledTemplatesDir}"); + throw new ViewException( + "Unable to create compiled template directory: {$this->compiledTemplatesDir}", + 0, + null, + ['compiled_templates_dir' => $this->compiledTemplatesDir], + 'Template rendering failed.' + ); } } @@ -61,13 +68,25 @@ private function materializeCompiledTemplate(string $compiledCode): string $tmpFile = $targetFile . '.tmp.' . bin2hex(random_bytes(6)); $bytes = file_put_contents($tmpFile, $compiledCode, LOCK_EX); if ($bytes === false) { - throw new \RuntimeException("Unable to write compiled template file: {$targetFile}"); + throw new ViewException( + "Unable to write compiled template file: {$targetFile}", + 0, + null, + ['compiled_template_file' => $targetFile], + 'Template rendering failed.' + ); } if (!@rename($tmpFile, $targetFile)) { @unlink($tmpFile); if (!is_file($targetFile)) { - throw new \RuntimeException("Unable to finalize compiled template file: {$targetFile}"); + throw new ViewException( + "Unable to finalize compiled template file: {$targetFile}", + 0, + null, + ['compiled_template_file' => $targetFile], + 'Template rendering failed.' + ); } } } @@ -103,9 +122,11 @@ private function validateCompiledTemplateSyntax(string $templateFile): void [$line, $column, $snippet] = $this->extractErrorLocation($templateFile, $output); throw new SyntaxException( - "Compiled template syntax error in '{$templateFile}' at line {$line}, column {$column}." . - ($snippet !== '' ? " Snippet: {$snippet}" : '') . - ($output !== '' ? " PHP lint: {$output}" : '') + $templateFile, + $line, + $column, + $snippet, + $output ); } From 91e584195dda84bc186a4a0e4e63d94984b1566d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:03:54 +0000 Subject: [PATCH 14/17] Plan template source exceptions --- .tmp-baseline.html | 62 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .tmp-baseline.html diff --git a/.tmp-baseline.html b/.tmp-baseline.html new file mode 100644 index 0000000..f0b7f19 --- /dev/null +++ b/.tmp-baseline.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 c5a117a1858e61567bb6706b10cc9b87ab906b3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:05:27 +0000 Subject: [PATCH 15/17] Track source locations through inheritance --- src/Core/View/Engine.php | 241 ++++++++++++++++--- src/Core/View/Exceptions/SyntaxException.php | 62 +++-- src/Core/View/Lexer.php | 90 ++++++- src/Core/View/Parser.php | 19 +- src/Core/View/Renderer.php | 2 +- 5 files changed, 342 insertions(+), 72 deletions(-) diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index ef920ee..a3d4c6c 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -6,6 +6,7 @@ use Core\View\Cache\FileCacheAdapter; use Core\View\Components\ComponentRegistry; use Core\View\Exceptions\ViewException; +use Core\View\Exceptions\ParserException; use Core\View\Filters\FilterRegistry; use Core\View\NodeVisitor\NodeVisitorInterface; @@ -115,10 +116,14 @@ public function render(string $templatePath, array $data = []): string private function compileTemplate(string $absolutePath, string $templatePath): string { - $content = $this->readTemplateFile($absolutePath, $templatePath); - $content = $this->resolveTemplateInheritance($content, [$absolutePath]); - $tokens = $this->lexer->tokenize($content); - $ast = $this->parser->parse($tokens); + $resolvedTemplate = $this->resolveTemplateInheritanceWithSource($absolutePath, [$absolutePath]); + $tokens = $this->lexer->tokenize($resolvedTemplate['content'], $resolvedTemplate['char_map']); + + try { + $ast = $this->parser->parse($tokens); + } catch (ParserException $e) { + throw $this->augmentParserExceptionWithTemplate($e, $templatePath); + } return $this->compiler->compile($ast); } @@ -268,11 +273,15 @@ private function collectTemplateDependencies(string $absolutePath, array $stack) $parentAbsolutePath = $this->resolveTemplatePath($parentRelativePath); if (!file_exists($parentAbsolutePath)) { - throw new ViewException("Parent template not found: {$parentRelativePath}"); + throw new ViewException( + "Parent template not found: '{$parentRelativePath}' referenced from '{$absolutePath}'" + ); } if (in_array($parentAbsolutePath, $stack, true)) { - throw new ViewException("Circular extends detected: {$parentRelativePath}"); + throw new ViewException( + "Circular extends detected. '{$absolutePath}' references '{$parentRelativePath}' recursively" + ); } return array_merge( @@ -285,32 +294,46 @@ private function collectTemplateDependencies(string $absolutePath, array $stack) * Resolve herança de template via `extends "path";` e sobrescrita de . * * @param string[] $stack + * @return array{content: string, char_map: array} */ - private function resolveTemplateInheritance(string $content, array $stack): string + private function resolveTemplateInheritanceWithSource(string $absolutePath, array $stack): array { + $content = $this->readTemplateFile($absolutePath, $absolutePath); $parentRelativePath = $this->extractParentTemplatePath($content); + if ($parentRelativePath === null) { - return $content; + return [ + 'content' => $content, + 'char_map' => $this->buildCharMap($content, $absolutePath), + ]; } - $parentAbsolutePath = $this->resolveTemplatePath($parentRelativePath); + $parentAbsolutePath = $this->resolveTemplatePath($parentRelativePath); if (!file_exists($parentAbsolutePath)) { - throw new ViewException("Parent template not found: {$parentRelativePath}"); + throw new ViewException( + "Parent template not found: '{$parentRelativePath}' referenced from '{$absolutePath}'" + ); } if (in_array($parentAbsolutePath, $stack, true)) { - throw new ViewException("Circular extends detected: {$parentRelativePath}"); - } - - $parentContent = file_get_contents($parentAbsolutePath); - if ($parentContent === false) { - throw new ViewException("Unable to read parent template: {$parentRelativePath}"); + throw new ViewException( + "Circular extends detected. '{$absolutePath}' references '{$parentRelativePath}' recursively" + ); } - $childContent = preg_replace('/^\s*extends\s+["\']([^"\']+)["\']\s*;[ \t]*\R?/i', '', $content, 1) ?? $content; - $parentResolved = $this->resolveTemplateInheritance($parentContent, [...$stack, $parentAbsolutePath]); - - return $this->mergeBlocks($parentResolved, $childContent); + $parentResolved = $this->resolveTemplateInheritanceWithSource($parentAbsolutePath, [...$stack, $parentAbsolutePath]); + $childWithoutExtends = $this->removeExtendsDirective($content); + $childResolved = [ + 'content' => $childWithoutExtends['content'], + 'char_map' => $this->buildCharMap( + $childWithoutExtends['content'], + $absolutePath, + $childWithoutExtends['line'], + $childWithoutExtends['column'] + ), + ]; + + return $this->mergeBlocksWithSource($parentResolved, $childResolved); } private function extractParentTemplatePath(string $content): ?string @@ -323,31 +346,181 @@ private function extractParentTemplatePath(string $content): ?string } /** - * @return array + * @param array{content: string, char_map: array} $parentResolved + * @param array{content: string, char_map: array} $childResolved + * @return array{content: string, char_map: array} */ - private function extractBlocks(string $content): array + private function mergeBlocksWithSource(array $parentResolved, array $childResolved): array + { + $pattern = '/(.*?)<\/Block>/is'; + $childBlocks = $this->extractBlocksWithSource($childResolved, $pattern); + + preg_match_all($pattern, $parentResolved['content'], $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + if ($matches === []) { + return $parentResolved; + } + + $mergedContent = ''; + $mergedMap = []; + $cursor = 0; + + foreach ($matches as $match) { + $fullMatch = (string) $match[0][0]; + $matchOffset = (int) $match[0][1]; + $matchLength = strlen($fullMatch); + $blockName = (string) $match[1][0]; + $innerContent = (string) $match[2][0]; + $innerOffset = (int) $match[2][1]; + + $prefix = $this->sliceResolvedSegment($parentResolved, $cursor, $matchOffset - $cursor); + $mergedContent .= $prefix['content']; + if ($prefix['char_map'] !== []) { + $mergedMap = array_merge($mergedMap, $prefix['char_map']); + } + + if (isset($childBlocks[$blockName])) { + $replacement = $childBlocks[$blockName]; + } else { + $replacement = $this->sliceResolvedSegment($parentResolved, $innerOffset, strlen($innerContent)); + } + + $mergedContent .= $replacement['content']; + if ($replacement['char_map'] !== []) { + $mergedMap = array_merge($mergedMap, $replacement['char_map']); + } + + $cursor = $matchOffset + $matchLength; + } + + $suffix = $this->sliceResolvedSegment($parentResolved, $cursor, strlen($parentResolved['content']) - $cursor); + $mergedContent .= $suffix['content']; + if ($suffix['char_map'] !== []) { + $mergedMap = array_merge($mergedMap, $suffix['char_map']); + } + + return [ + 'content' => $mergedContent, + 'char_map' => $mergedMap, + ]; + } + + /** + * @param array{content: string, char_map: array} $resolved + * @return array}> + */ + private function extractBlocksWithSource(array $resolved, string $pattern): array { $blocks = []; - preg_match_all('/(.*?)<\/Block>/is', $content, $matches, PREG_SET_ORDER); + preg_match_all($pattern, $resolved['content'], $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); foreach ($matches as $match) { - $blocks[$match[1]] = $match[2]; + $name = (string) $match[1][0]; + $innerContent = (string) $match[2][0]; + $innerOffset = (int) $match[2][1]; + $blocks[$name] = $this->sliceResolvedSegment($resolved, $innerOffset, strlen($innerContent)); } return $blocks; } - private function mergeBlocks(string $parentContent, string $childContent): string + /** + * @param array{content: string, char_map: array} $resolved + * @return array{content: string, char_map: array} + */ + private function sliceResolvedSegment(array $resolved, int $start, int $length): array + { + if ($length <= 0) { + return ['content' => '', 'char_map' => []]; + } + + return [ + 'content' => substr($resolved['content'], $start, $length), + 'char_map' => array_slice($resolved['char_map'], $start, $length), + ]; + } + + /** + * @return array{content: string, line: int, column: int} + */ + private function removeExtendsDirective(string $content): array + { + if (preg_match('/^\s*extends\s+["\']([^"\']+)["\']\s*;[ \t]*\R?/i', $content, $match) !== 1) { + return ['content' => $content, 'line' => 1, 'column' => 1]; + } + + $removed = $match[0]; + $withoutExtends = substr($content, strlen($removed)); + [$line, $column] = $this->advanceCursorByText($removed, 1, 1); + + return [ + 'content' => $withoutExtends, + 'line' => $line, + 'column' => $column, + ]; + } + + /** + * @return array + */ + private function buildCharMap(string $content, string $file, int $startLine = 1, int $startColumn = 1): array { - $childBlocks = $this->extractBlocks($childContent); - - return (string) preg_replace_callback( - '/(.*?)<\/Block>/is', - static function (array $match) use ($childBlocks): string { - $name = $match[1]; - return $childBlocks[$name] ?? $match[2]; - }, - $parentContent + $line = $startLine; + $column = $startColumn; + $map = []; + $length = strlen($content); + + for ($i = 0; $i < $length; $i++) { + $char = $content[$i]; + $map[$i] = [ + 'file' => $file, + 'line' => $line, + 'column' => $column, + ]; + + if ($char === "\n") { + $line++; + $column = 1; + continue; + } + + $column++; + } + + return $map; + } + + /** + * @return array{0:int,1:int} + */ + private function advanceCursorByText(string $text, int $line, int $column): array + { + $length = strlen($text); + for ($i = 0; $i < $length; $i++) { + if ($text[$i] === "\n") { + $line++; + $column = 1; + continue; + } + $column++; + } + + return [$line, $column]; + } + + private function augmentParserExceptionWithTemplate(ParserException $exception, string $templatePath): ParserException + { + $context = $exception->getContext(); + if (isset($context['template_file'], $context['line'], $context['column'])) { + return $exception; + } + + return new ParserException( + "Template parse error in '{$templatePath}': " . $exception->getMessage(), + 0, + $exception, + ['template_file' => $templatePath], + 'Template syntax error.' ); } diff --git a/src/Core/View/Exceptions/SyntaxException.php b/src/Core/View/Exceptions/SyntaxException.php index 3a4482f..cda8ab1 100644 --- a/src/Core/View/Exceptions/SyntaxException.php +++ b/src/Core/View/Exceptions/SyntaxException.php @@ -7,43 +7,53 @@ */ class SyntaxException extends ViewException { - private string $templateFile; - private int $lineNumber; - private int $columnNumber; - private string $snippet; - private string $lintOutput; + private string $templateFile = ''; + private int $lineNumber = 1; + private int $columnNumber = 1; + private string $snippet = ''; + private string $lintOutput = ''; + /** + * @param array $context + */ public function __construct( + string $message = 'Template syntax error.', + int $code = 0, + ?\Throwable $previous = null, + array $context = [], + string $safeMessage = 'Template syntax error.' + ) { + parent::__construct($message, $code, $previous, $context, $safeMessage); + + $this->templateFile = (string) ($context['template_file'] ?? ''); + $this->lineNumber = (int) ($context['line'] ?? 1); + $this->columnNumber = (int) ($context['column'] ?? 1); + $this->snippet = (string) ($context['snippet'] ?? ''); + $this->lintOutput = (string) ($context['lint_output'] ?? ''); + } + + public static function fromLocation( string $templateFile, int $lineNumber, int $columnNumber, string $snippet = '', - string $lintOutput = '', - int $code = 0, - ?\Throwable $previous = null - ) { - $context = [ - 'template_file' => $templateFile, - 'line' => $lineNumber, - 'column' => $columnNumber, - 'snippet' => $snippet, - 'lint_output' => $lintOutput, - ]; - - $message = "Compiled template syntax error in '{$templateFile}' at line {$lineNumber}, column {$columnNumber}."; + string $details = '' + ): self { + $message = "Template syntax error in '{$templateFile}' at line {$lineNumber}, column {$columnNumber}."; if ($snippet !== '') { $message .= " Snippet: {$snippet}"; } - if ($lintOutput !== '') { - $message .= " PHP lint: {$lintOutput}"; + if ($details !== '') { + $message .= " Details: {$details}"; } - parent::__construct($message, $code, $previous, $context, 'Template syntax error.'); - $this->templateFile = $templateFile; - $this->lineNumber = $lineNumber; - $this->columnNumber = $columnNumber; - $this->snippet = $snippet; - $this->lintOutput = $lintOutput; + return new self($message, 0, null, [ + 'template_file' => $templateFile, + 'line' => $lineNumber, + 'column' => $columnNumber, + 'snippet' => $snippet, + 'lint_output' => $details, + ]); } public function getTemplateFile(): string diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index 12c9404..f5df8c7 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -16,6 +16,9 @@ class Lexer private const TOKEN_EXPRESSION = 'EXPRESSION'; private const TOKEN_ATTRIBUTE = 'ATTRIBUTE'; private const TOKEN_KEYWORD = 'KEYWORD'; + /** @var array */ + private array $charMap = []; + private string $content = ''; /** * Tokeniza o conteúdo do template @@ -23,8 +26,10 @@ class Lexer * @param string $content Conteúdo do template * @return array Tokens */ - public function tokenize(string $content): array + public function tokenize(string $content, ?array $charMap = null): array { + $this->content = $content; + $this->charMap = $charMap ?? []; $tokens = []; $length = strlen($content); $pos = 0; @@ -36,7 +41,7 @@ public function tokenize(string $content): array if ($this->isDirectiveLineStart($content, $pos)) { $directive = $this->extractKeywordDirective($content, $pos); if ($directive !== null) { - $tokens[] = $directive; + $tokens[] = $this->withSourceMetadata($directive, $pos); $pos += $directive['length']; continue; } @@ -51,6 +56,7 @@ public function tokenize(string $content): array 'length' => strlen($fullMatch), 'value' => $fullMatch ]; + $tokens[count($tokens) - 1] = $this->withSourceMetadata($tokens[count($tokens) - 1], $pos); $pos += strlen($fullMatch); continue; } @@ -59,7 +65,7 @@ public function tokenize(string $content): array if ($content[$pos] === '<' && preg_match('/^<([A-Z][a-zA-Z0-9]*)/', $slice, $matches)) { // Isso é uma tag customizada $token = $this->extractTag($content, $pos); - $tokens[] = $token; + $tokens[] = $this->withSourceMetadata($token, $pos); $pos += $token['length']; continue; } @@ -67,7 +73,7 @@ public function tokenize(string $content): array // Detectar expressão {{ }} if (strpos($content, '{{', $pos) === $pos) { $token = $this->extractExpression($content, $pos); - $tokens[] = $token; + $tokens[] = $this->withSourceMetadata($token, $pos); $pos += $token['length']; continue; } @@ -75,7 +81,7 @@ public function tokenize(string $content): array // Detectar raw output {! !} if (strpos($content, '{!', $pos) === $pos) { $token = $this->extractRaw($content, $pos); - $tokens[] = $token; + $tokens[] = $this->withSourceMetadata($token, $pos); $pos += $token['length']; continue; } @@ -113,6 +119,7 @@ public function tokenize(string $content): array 'value' => substr($content, $pos, $textLength), 'length' => $textLength ]; + $tokens[count($tokens) - 1] = $this->withSourceMetadata($tokens[count($tokens) - 1], $pos); $pos += $textLength; } } @@ -183,7 +190,7 @@ private function extractKeywordDirective(string $content, int $pos): ?array $cursor++; } - throw new SyntaxException("Unclosed keyword directive at position $pos"); + throw $this->syntaxError('Unclosed keyword directive', $pos); } /** @@ -208,7 +215,7 @@ private function extractTag(string $content, int $pos): array $tagName = substr($content, $nameStart, $cursor - $nameStart); if ($tagName === '') { - throw new SyntaxException("Invalid tag at position $pos"); + throw $this->syntaxError('Invalid tag', $pos); } $attrStart = $cursor; @@ -266,7 +273,7 @@ private function extractTag(string $content, int $pos): array } if ($attrEnd === null) { - throw new SyntaxException("Unclosed tag '<{$tagName}' at position $pos"); + throw $this->syntaxError("Unclosed tag '<{$tagName}'", $pos); } $attrStr = substr($content, $attrStart, $attrEnd - $attrStart); @@ -335,7 +342,7 @@ private function extractExpression(string $content, int $pos): array $cursor++; } - throw new SyntaxException("Unclosed expression at position $pos"); + throw $this->syntaxError('Unclosed expression', $pos); } /** @@ -391,6 +398,69 @@ private function extractRaw(string $content, int $pos): array $cursor++; } - throw new SyntaxException("Unclosed raw output at position $pos"); + throw $this->syntaxError('Unclosed raw output', $pos); + } + + private function withSourceMetadata(array $token, int $offset): array + { + [$line, $column] = $this->resolveLineColumn($offset); + $token['line'] = $line; + $token['column'] = $column; + + if (isset($this->charMap[$offset])) { + $token['source_file'] = $this->charMap[$offset]['file']; + $token['source_line'] = $this->charMap[$offset]['line']; + $token['source_column'] = $this->charMap[$offset]['column']; + } else { + $token['source_file'] = ''; + $token['source_line'] = $line; + $token['source_column'] = $column; + } + + return $token; + } + + /** + * @return array{0:int,1:int} + */ + private function resolveLineColumn(int $offset): array + { + if (isset($this->charMap[$offset])) { + return [$this->charMap[$offset]['line'], $this->charMap[$offset]['column']]; + } + + $prefix = substr($this->content, 0, max(0, $offset)); + $line = substr_count($prefix, "\n") + 1; + $lastBreak = strrpos($prefix, "\n"); + $column = $lastBreak === false ? strlen($prefix) + 1 : strlen($prefix) - $lastBreak; + + return [$line, $column]; + } + + private function syntaxError(string $message, int $offset): SyntaxException + { + [$line, $column] = $this->resolveLineColumn($offset); + $source = $this->charMap[$offset] ?? ['file' => '', 'line' => $line, 'column' => $column]; + + $snippet = ''; + $lineStart = strrpos(substr($this->content, 0, $offset), "\n"); + $lineStart = $lineStart === false ? 0 : $lineStart + 1; + $lineEnd = strpos($this->content, "\n", $offset); + if ($lineEnd === false) { + $lineEnd = strlen($this->content); + } + $snippet = trim(substr($this->content, $lineStart, $lineEnd - $lineStart)); + $templateFile = (string) ($source['file'] ?? ''); + if ($templateFile === '') { + $templateFile = '[unknown template]'; + } + + return SyntaxException::fromLocation( + $templateFile, + (int) ($source['line'] ?? $line), + (int) ($source['column'] ?? $column), + $snippet, + $message + ); } } diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index f5499ad..90d04ed 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -338,8 +338,25 @@ private function parserError(string $message): ParserException $tokenPreview = substr($tokenPreview, 0, 77) . '...'; } + $templateFile = (string) ($token['source_file'] ?? ''); + $line = (int) ($token['source_line'] ?? ($token['line'] ?? 1)); + $column = (int) ($token['source_column'] ?? ($token['column'] ?? 1)); + $location = $templateFile !== '' + ? " in '{$templateFile}' at line {$line}, column {$column}" + : " at line {$line}, column {$column}"; + return new ParserException( - sprintf('%s at token #%d [%s: %s]', $message, $this->position, $tokenType, $tokenPreview) + sprintf('%s%s at token #%d [%s: %s]', $message, $location, $this->position, $tokenType, $tokenPreview), + 0, + null, + [ + 'template_file' => $templateFile, + 'line' => $line, + 'column' => $column, + 'token_type' => $tokenType, + 'snippet' => $tokenPreview, + ], + 'Template syntax error.' ); } } diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 12e2c3a..da39c5b 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -121,7 +121,7 @@ private function validateCompiledTemplateSyntax(string $templateFile): void [$line, $column, $snippet] = $this->extractErrorLocation($templateFile, $output); - throw new SyntaxException( + throw SyntaxException::fromLocation( $templateFile, $line, $column, From 703140562c84be3006f8008990ac7b27400b1511 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:06:36 +0000 Subject: [PATCH 16/17] Improve template-origin exception messages --- README.md | 1 + src/Core/View/Parser.php | 58 +++++++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b596406..33687a7 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Veja [SYNTAX.md](./SYNTAX.md) para documentação completa da sintaxe. - `debug: true` (padrão): mantém mensagens detalhadas para facilitar desenvolvimento. - `debug: false` ou `environment: 'production'`: retorna mensagens seguras e genéricas para produção. - Erros de sintaxe compilada geram `SyntaxException` com contexto interno (arquivo, linha, coluna e snippet), preservado no encadeamento da exceção. +- Em templates com `extends`, as exceções de léxico/parser apontam o arquivo original (ex.: `layouts/base.html`) com linha e coluna exatas. ## Estrutura do Projeto diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index 90d04ed..41e8fa5 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -21,7 +21,7 @@ class Parser { private array $tokens; private int $position = 0; - /** @var array */ + /** @var array */ private array $controlStack = []; /** @@ -55,8 +55,7 @@ public function parse(array $tokens): array } if ($this->controlStack !== []) { - $openTags = implode(', ', array_map(static fn(array $entry): string => $entry['tag'], $this->controlStack)); - throw $this->parserError("Unclosed control tags: {$openTags}"); + throw $this->unclosedControlTagsError(); } return $nodes; @@ -165,7 +164,13 @@ private function parseIfTag(string $attributes, bool $isSelfClosing): IfNode throw $this->parserError(' requires a non-empty condition attribute'); } - $this->controlStack[] = ['tag' => 'If', 'hasElse' => false]; + $this->controlStack[] = [ + 'tag' => 'If', + 'hasElse' => false, + 'file' => (string) ($this->current()['source_file'] ?? ''), + 'line' => (int) ($this->current()['source_line'] ?? ($this->current()['line'] ?? 1)), + 'column' => (int) ($this->current()['source_column'] ?? ($this->current()['column'] ?? 1)), + ]; return new IfNode($condition); } @@ -224,7 +229,13 @@ private function parseBlockTag(string $attributes, bool $isSelfClosing): BlockNo throw $this->parserError(' requires a non-empty name attribute'); } - $this->controlStack[] = ['tag' => 'Block', 'hasElse' => false]; + $this->controlStack[] = [ + 'tag' => 'Block', + 'hasElse' => false, + 'file' => (string) ($this->current()['source_file'] ?? ''), + 'line' => (int) ($this->current()['source_line'] ?? ($this->current()['line'] ?? 1)), + 'column' => (int) ($this->current()['source_column'] ?? ($this->current()['column'] ?? 1)), + ]; return new BlockNode($name); } @@ -245,7 +256,13 @@ private function parseForeachTag(string $attributes, bool $isSelfClosing): Forea throw $this->parserError(' requires a non-empty as attribute'); } - $this->controlStack[] = ['tag' => 'Foreach', 'hasElse' => false]; + $this->controlStack[] = [ + 'tag' => 'Foreach', + 'hasElse' => false, + 'file' => (string) ($this->current()['source_file'] ?? ''), + 'line' => (int) ($this->current()['source_line'] ?? ($this->current()['line'] ?? 1)), + 'column' => (int) ($this->current()['source_column'] ?? ($this->current()['column'] ?? 1)), + ]; return new ForeachNode($items, $as); } @@ -359,4 +376,33 @@ private function parserError(string $message): ParserException 'Template syntax error.' ); } + + private function unclosedControlTagsError(): ParserException + { + $openTags = implode(', ', array_map( + static function (array $entry): string { + $location = $entry['file'] !== '' + ? " in '{$entry['file']}' at line {$entry['line']}, column {$entry['column']}" + : " at line {$entry['line']}, column {$entry['column']}"; + return $entry['tag'] . $location; + }, + $this->controlStack + )); + + $first = $this->controlStack[0]; + + return new ParserException( + "Unclosed control tags: {$openTags}", + 0, + null, + [ + 'template_file' => $first['file'], + 'line' => $first['line'], + 'column' => $first['column'], + 'token_type' => 'TAG', + 'snippet' => $first['tag'], + ], + 'Template syntax error.' + ); + } } From 43b1abdbcf8e472c7b4cfe9ce8a525067cb6c0f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:07:21 +0000 Subject: [PATCH 17/17] Finalize template error location improvements --- .tmp-baseline.html | 62 ---------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 .tmp-baseline.html diff --git a/.tmp-baseline.html b/.tmp-baseline.html deleted file mode 100644 index f0b7f19..0000000 --- a/.tmp-baseline.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - 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 -
- -
-
- -