From 10aac20afb68c6c6da0da2439d71d4ea7d8babf6 Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Wed, 11 Feb 2026 23:42:10 -0600 Subject: [PATCH] feat: implement core Lexer, Parser, and AST nodes (Phases 1-3) --- src/Config.php | 72 ++++++ src/Engine.php | 298 ++++++++++++++++++++++ src/Parser/Lexer.php | 209 ++++++++++++++++ src/Parser/Node/BlockNode.php | 30 +++ src/Parser/Node/ExtendsNode.php | 22 ++ src/Parser/Node/FilterLoadNode.php | 28 +++ src/Parser/Node/ForeachNode.php | 38 +++ src/Parser/Node/IncludeNode.php | 28 +++ src/Parser/Node/Node.php | 25 ++ src/Parser/Node/ParentNode.php | 20 ++ src/Parser/Node/TextNode.php | 22 ++ src/Parser/Node/VariableNode.php | 32 +++ src/Parser/Parser.php | 383 +++++++++++++++++++++++++++++ src/Parser/Token.php | 29 +++ src/Parser/TokenType.php | 26 ++ tests/ConfigTest.php | 60 +++++ tests/LexerTest.php | 105 ++++++++ tests/ParserTest.php | 186 ++++++++++++++ 18 files changed, 1613 insertions(+) create mode 100644 src/Config.php create mode 100644 src/Engine.php create mode 100644 src/Parser/Lexer.php create mode 100644 src/Parser/Node/BlockNode.php create mode 100644 src/Parser/Node/ExtendsNode.php create mode 100644 src/Parser/Node/FilterLoadNode.php create mode 100644 src/Parser/Node/ForeachNode.php create mode 100644 src/Parser/Node/IncludeNode.php create mode 100644 src/Parser/Node/Node.php create mode 100644 src/Parser/Node/ParentNode.php create mode 100644 src/Parser/Node/TextNode.php create mode 100644 src/Parser/Node/VariableNode.php create mode 100644 src/Parser/Parser.php create mode 100644 src/Parser/Token.php create mode 100644 src/Parser/TokenType.php create mode 100644 tests/ConfigTest.php create mode 100644 tests/LexerTest.php create mode 100644 tests/ParserTest.php diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..a803efe --- /dev/null +++ b/src/Config.php @@ -0,0 +1,72 @@ +mode = $overrides['mode'] ?? (getenv('SCAPE_MODE') ?: 'production'); + $this->templatesDir = $overrides['templates_dir'] ?? (getenv('SCAPE_TEMPLATES_DIR') ?: './templates'); + $this->layoutsDir = $overrides['layouts_dir'] ?? (getenv('SCAPE_LAYOUTS_DIR') ?: './templates/layouts'); + $this->partialsDir = $overrides['partials_dir'] ?? (getenv('SCAPE_PARTIALS_DIR') ?: './templates/partials'); + $this->filtersDir = $overrides['filters_dir'] ?? (getenv('SCAPE_FILTERS_DIR') ?: './filters'); + $this->cacheDir = $overrides['cache_dir'] ?? (getenv('SCAPE_CACHE_DIR') ?: './.scape/cache'); + } + + public function getMode(): string + { + return $this->mode; + } + + public function getTemplatesDir(): string + { + return $this->templatesDir; + } + + public function getLayoutsDir(): string + { + return $this->layoutsDir; + } + + public function getPartialsDir(): string + { + return $this->partialsDir; + } + + public function getFiltersDir(): string + { + return $this->filtersDir; + } + + public function getCacheDir(): string + { + return $this->cacheDir; + } + + public function isDebug(): bool + { + return $this->mode === 'debug'; + } +} diff --git a/src/Engine.php b/src/Engine.php new file mode 100644 index 0000000..cb08b3c --- /dev/null +++ b/src/Engine.php @@ -0,0 +1,298 @@ +config = $config; + } else { + $this->config = new Config($config ?? []); + } + + $this->interpreter = new Interpreter($this->config, $this); + } + + /** + * Renders a template with the provided data. + * + * @param string $template The dot-notated path to the template. + * @param array $data The data context for rendering. + * + * @return string The rendered output. + * + * @throws TemplateNotFoundException If the template cannot be found. + */ + public function render(string $template, array $data = []): string + { + try { + $ast = $this->loadAst($template); + } catch (TemplateNotFoundException $e) { + return $this->handle404($template, $e); + } + + // Check for extends + if (!empty($ast) && $ast[0] instanceof \Scape\Parser\Node\ExtendsNode) { + $extendsNode = array_shift($ast); + $layoutPath = $extendsNode->path; + try { + $layoutAst = $this->loadAst($layoutPath, true); + } catch (TemplateNotFoundException $e) { + return $this->handle404($layoutPath, $e); + } + + return $this->interpreter->interpretWithLayout($layoutAst, $ast, $data); + } + + return $this->interpreter->interpret($ast, $data); + } + + /** + * Handles template not found errors. + * + * @param string $template + * @param TemplateNotFoundException $e + * @return string + * @throws TemplateNotFoundException + */ + private function handle404(string $template, TemplateNotFoundException $e): string + { + $fallbackPath = rtrim($this->config->getTemplatesDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '404.scape.php'; + + if (file_exists($fallbackPath)) { + $source = file_get_contents($fallbackPath); + $lexer = new Lexer($source); + $tokens = $lexer->tokenize(); + $parser = new Parser($tokens); + $ast = $parser->parse(); + return $this->interpreter->interpret($ast, ['missing_template' => $template]); + } + + if ($this->config->isDebug()) { + throw $e; + } + + return ""; + } + + /** + * Loads and parses a template into an AST. + * + * @param string $template + * @param bool $isLayout + * @return array + * @throws TemplateNotFoundException + */ + private function loadAst(string $template, bool $isLayout = false): array + { + $filePath = $isLayout ? $this->resolveLayoutPath($template) : $this->resolveTemplatePath($template); + + if (!file_exists($filePath)) { + throw new TemplateNotFoundException("Template '{$template}' not found at '{$filePath}'."); + } + + $cacheDir = $this->config->getCacheDir(); + $cacheKey = md5($filePath . ($isLayout ? ':layout' : '')); + $cachePath = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.ast'; + + if (file_exists($cachePath)) { + $useCache = true; + if (!$this->config->isDebug()) { + // In production, always use cache if it exists + $useCache = true; + } else { + // In debug mode, check mtime + if (filemtime($filePath) > filemtime($cachePath)) { + $useCache = false; + } + } + + if ($useCache) { + $cachedAst = unserialize(file_get_contents($cachePath)); + if (is_array($cachedAst)) { + return $cachedAst; + } + } + } + + $source = file_get_contents($filePath); + $lexer = new Lexer($source); + $tokens = $lexer->tokenize(); + $parser = new Parser($tokens, $isLayout); + $ast = $parser->parse(); + + // Save to cache + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0777, true); + } + file_put_contents($cachePath, serialize($ast)); + + return $ast; + } + + /** + * Resolves a dot-notated template path to a file path. + * + * @param string $template + * @return string + */ + private function resolveTemplatePath(string $template): string + { + $path = str_replace('.', DIRECTORY_SEPARATOR, $template); + return rtrim($this->config->getTemplatesDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php'; + } + + /** + * Resolves a dot-notated layout path to a file path. + * + * @param string $layout + * @return string + */ + private function resolveLayoutPath(string $layout): string + { + $path = str_replace('.', DIRECTORY_SEPARATOR, $layout); + return rtrim($this->config->getLayoutsDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php'; + } + + /** + * Resolves a dot-notated partial path to a file path. + * + * @param string $partial + * @return string + */ + public function resolvePartialPath(string $partial): string + { + $path = str_replace('.', DIRECTORY_SEPARATOR, $partial); + return rtrim($this->config->getPartialsDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php'; + } + + /** + * Loads a filter by its alias. + * + * @param string $name + * @param FilterInterface $filter + */ + public function registerFilter(string $name, FilterInterface $filter): void + { + $this->loadedFilters[$name] = $filter; + } + + /** + * Gets a loaded filter. + * + * @param string $name + * @return FilterInterface + * @throws FilterNotFoundException + */ + public function getFilter(string $name): FilterInterface + { + if (!isset($this->loadedFilters[$name])) { + throw new FilterNotFoundException("Filter '{$name}' is not loaded."); + } + + return $this->loadedFilters[$name]; + } + + /** + * Registers a host provider. + * + * @param HostProviderInterface $provider + */ + public function registerHostProvider(HostProviderInterface $provider): void + { + $this->hostProvider = $provider; + } + + /** + * Gets the host provider. + * + * @return HostProviderInterface|null + */ + public function getHostProvider(): ?HostProviderInterface + { + return $this->hostProvider; + } + + /** + * Internal method for Interpreter to load partial ASTs. + * + * @param string $partial + * @return array + * @throws TemplateNotFoundException + */ + public function loadPartialAst(string $partial): array + { + $filePath = $this->resolvePartialPath($partial); + + if (!file_exists($filePath)) { + throw new TemplateNotFoundException("Partial '{$partial}' not found at '{$filePath}'."); + } + + $cacheDir = $this->config->getCacheDir(); + $cacheKey = md5($filePath . ':partial'); + $cachePath = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.ast'; + + if (file_exists($cachePath)) { + $useCache = true; + if ($this->config->isDebug()) { + if (filemtime($filePath) > filemtime($cachePath)) { + $useCache = false; + } + } + + if ($useCache) { + $cachedAst = unserialize(file_get_contents($cachePath)); + if (is_array($cachedAst)) { + return $cachedAst; + } + } + } + + $source = file_get_contents($filePath); + $lexer = new Lexer($source); + $tokens = $lexer->tokenize(); + $parser = new Parser($tokens, false); + $ast = $parser->parse(); + + // Save to cache + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0777, true); + } + file_put_contents($cachePath, serialize($ast)); + + return $ast; + } +} diff --git a/src/Parser/Lexer.php b/src/Parser/Lexer.php new file mode 100644 index 0000000..be0a6a6 --- /dev/null +++ b/src/Parser/Lexer.php @@ -0,0 +1,209 @@ + TokenType::RAW_START, + '}}}' => TokenType::RAW_END, + '{{' => TokenType::INTERPOLATION_START, + '}}' => TokenType::INTERPOLATION_END, + '{(' => TokenType::LOGIC_START, + ')}' => TokenType::LOGIC_END, + '{[' => TokenType::BLOCK_START, + ']}' => TokenType::BLOCK_END, + ]; + + private string $input; + private int $position = 0; + private int $line = 1; + private int $length; + + /** + * Lexer constructor. + * + * @param string $input The template string to tokenize. + */ + public function __construct(string $input) + { + $this->input = $input; + $this->length = strlen($input); + } + + /** + * Tokenizes the input string. + * + * @return Token[] + */ + public function tokenize(): array + { + $tokens = []; + + while ($this->position < $this->length) { + $char = $this->input[$this->position]; + + if ($char === '{' && $this->peek(1) !== '') { + $tokens = array_merge($tokens, $this->tokenizeTag()); + } else { + $tokens[] = $this->tokenizeText(); + } + } + + $tokens[] = new Token(TokenType::EOF, '', $this->line); + + return $tokens; + } + + /** + * Tokenizes a tag and its contents. + * + * @return Token[] + */ + private function tokenizeTag(): array + { + $tagType = null; + $startTag = ''; + + // Check for 3-char tags first ({{{) + $triple = substr($this->input, $this->position, 3); + if ($triple === '{{{') { + $startTag = $triple; + $tagType = TokenType::RAW_START; + } else { + // Check for 2-char tags + $double = substr($this->input, $this->position, 2); + if (isset(self::TAG_MAP[$double])) { + $startTag = $double; + $tagType = self::TAG_MAP[$double]; + } + } + + if ($tagType === null) { + return [$this->tokenizeText()]; + } + + $tokens = []; + $tokens[] = new Token($tagType, $startTag, $this->line); + $this->position += strlen($startTag); + + // Find the matching end tag + $endTag = match($tagType) { + TokenType::RAW_START => '}}}', + TokenType::INTERPOLATION_START => '}}', + TokenType::LOGIC_START => ')}', + TokenType::BLOCK_START => ']}', + default => '', + }; + + $endPos = strpos($this->input, $endTag, $this->position); + + if ($endPos === false) { + // Malformed tag, treat as text (though in a real engine we might throw a SyntaxException) + // For now, we'll just consume until EOF + $content = substr($this->input, $this->position); + $tokens[] = new Token(TokenType::TEXT, $content, $this->line); + $this->updateLineCount($content); + $this->position = $this->length; + return $tokens; + } + + $content = substr($this->input, $this->position, $endPos - $this->position); + $tokens[] = new Token(TokenType::TEXT, $content, $this->line); + $this->updateLineCount($content); + $this->position = $endPos; + + $tokens[] = new Token(self::TAG_MAP[$endTag], $endTag, $this->line); + $this->position += strlen($endTag); + + // Special Rule: Logic and Block tags consume one trailing newline + if ($tagType === TokenType::LOGIC_START || $tagType === TokenType::BLOCK_START) { + if ($this->peek() === "\r") { + $this->position++; + if ($this->peek() === "\n") { + $this->position++; + } + $this->line++; + } elseif ($this->peek() === "\n") { + $this->position++; + $this->line++; + } + } + + return $tokens; + } + + /** + * Tokenizes a block of plain text. + * + * @return Token + */ + private function tokenizeText(): Token + { + $nextTagPos = strpos($this->input, '{', $this->position); + + if ($nextTagPos === false) { + $content = substr($this->input, $this->position); + $this->position = $this->length; + } else { + // Peek to see if it's a real tag or just a stray '{' + $peek = substr($this->input, $nextTagPos, 2); + if (isset(self::TAG_MAP[$peek]) || substr($this->input, $nextTagPos, 3) === '{{{') { + $content = substr($this->input, $this->position, $nextTagPos - $this->position); + $this->position = $nextTagPos; + } else { + // Not a tag, consume the '{' and continue + $content = substr($this->input, $this->position, $nextTagPos - $this->position + 1); + $this->position = $nextTagPos + 1; + } + } + + // Determine the starting line for this text token. If the text begins with a newline, + // the visible content effectively starts on the next line; record that for better diagnostics. + $lineForToken = $this->line; + if ($content !== '') { + if ($content[0] === "\n") { + $lineForToken++; + } elseif ($content[0] === "\r" && (strlen($content) > 1 && $content[1] === "\n")) { + $lineForToken++; + } + } + + $token = new Token(TokenType::TEXT, $content, $lineForToken); + $this->updateLineCount($content); + return $token; + } + + /** + * Peeks at the character at a relative offset. + * + * @param int $offset + * @return string + */ + private function peek(int $offset = 0): string + { + if ($this->position + $offset >= $this->length) { + return ''; + } + return $this->input[$this->position + $offset]; + } + + /** + * Updates the internal line counter based on newlines in the content. + * + * @param string $content + */ + private function updateLineCount(string $content): void + { + $this->line += substr_count($content, "\n"); + } +} diff --git a/src/Parser/Node/BlockNode.php b/src/Parser/Node/BlockNode.php new file mode 100644 index 0000000..d32da27 --- /dev/null +++ b/src/Parser/Node/BlockNode.php @@ -0,0 +1,30 @@ +tokens = $tokens; + $this->isLayout = $isLayout; + } + + /** + * Parses the tokens into an array of AST nodes. + * + * @return Node[] + * @throws SyntaxException + */ + public function parse(): array + { + $nodes = []; + while (!$this->isAtEnd()) { + $nodes[] = $this->parseNode(); + } + return $nodes; + } + + /** + * @return Node + * @throws SyntaxException + */ + private function parseNode(): Node + { + $token = $this->peek(); + + return match ($token->type) { + TokenType::TEXT => $this->parseText(), + TokenType::INTERPOLATION_START => $this->parseVariable(false), + TokenType::RAW_START => $this->parseVariable(true), + TokenType::LOGIC_START => $this->parseLogic(), + TokenType::BLOCK_START => $this->parseBlockTag(), + default => throw new SyntaxException("Unexpected token: {$token->type->value} at line {$token->line}"), + }; + } + + private function parseText(): TextNode + { + $token = $this->consume(TokenType::TEXT); + return new TextNode($token->value, $token->line); + } + + private function parseVariable(bool $isRaw): VariableNode + { + $startType = $isRaw ? TokenType::RAW_START : TokenType::INTERPOLATION_START; + $endType = $isRaw ? TokenType::RAW_END : TokenType::INTERPOLATION_END; + + $startToken = $this->consume($startType); + $expression = ''; + + while (!$this->check($endType) && !$this->isAtEnd()) { + $expression .= $this->advance()->value; + } + + $this->consume($endType, "Expected closing tag for variable interpolation at line " . $startToken->line); + + $trimmedExpression = trim($expression); + $isRawFromFilter = false; + + // Handle nested pipes in arguments (very basic) + // We split by | but only if it's not inside quotes + $parts = []; + $currentPart = ''; + $inQuote = false; + $quoteChar = ''; + for ($i = 0; $i < strlen($trimmedExpression); $i++) { + $char = $trimmedExpression[$i]; + if (($char === "'" || $char === '"') && ($i === 0 || $trimmedExpression[$i-1] !== '\\')) { + if (!$inQuote) { + $inQuote = true; + $quoteChar = $char; + } elseif ($char === $quoteChar) { + $inQuote = false; + } + } + if ($char === '|' && !$inQuote) { + $parts[] = $currentPart; + $currentPart = ''; + } else { + $currentPart .= $char; + } + } + $parts[] = $currentPart; + + $path = trim(array_shift($parts)); + $filters = []; + + foreach ($parts as $part) { + $part = trim($part); + if (empty($part)) continue; + + if (preg_match('/^([\w:]+)(?:\((.*)\))?$/', $part, $matches)) { + $filterName = $matches[1]; + if ($filterName === 'raw') { + $isRawFromFilter = true; + continue; + } + $argsString = $matches[2] ?? ''; + $args = []; + if ($argsString !== '') { + // Simple comma-separated args + $args = []; + $argsString = trim($argsString); + if ($argsString !== '') { + // Improved regex to handle quoted strings with commas + $args = []; + preg_match_all('/\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*"|[^,]+/', $argsString, $argMatches); + foreach ($argMatches[0] as $match) { + $match = trim($match); + // If it starts with comma, remove it + if (str_starts_with($match, ',')) { + $match = ltrim(substr($match, 1)); + } + // If it ends with comma, remove it + if (str_ends_with($match, ',')) { + $match = rtrim(substr($match, 0, -1)); + } + $match = trim($match); + if ($match !== '') { + $args[] = $match; + } + } + } + } + $filters[] = [ + 'name' => $filterName, + 'args' => $args + ]; + } + } + + return new VariableNode($trimmedExpression, $path, $filters, $isRaw || $isRawFromFilter, $startToken->line); + } + + private function parseLogic(): Node + { + $startToken = $this->consume(TokenType::LOGIC_START); + $content = ''; + while (!$this->check(TokenType::LOGIC_END) && !$this->isAtEnd()) { + $content .= $this->advance()->value; + } + $this->consume(TokenType::LOGIC_END, "Expected ')}' at line " . $startToken->line); + + $content = trim($content); + + if (str_starts_with($content, 'foreach ')) { + return $this->parseForeach($content, $startToken->line); + } + + if (str_starts_with($content, 'uses ')) { + $path = trim(substr($content, 5)); + return new FilterLoadNode($path, true, $startToken->line); + } + + if (preg_match('/^load_filter\([\'"](.+?)[\'"]\)$/', $content, $matches)) { + return new FilterLoadNode($matches[1], false, $startToken->line); + } + + throw new SyntaxException("Unknown logic tag: '{( $content )}' at line {$startToken->line}"); + } + + private function parseForeach(string $content, int $line): ForeachNode + { + // {( foreach item in collection )} + // {( foreach key, item in collection )} + if (preg_match('/^foreach\s+(?:(?P\w+)\s*,\s*)?(?P\w+)\s+in\s+(?P.+)$/', $content, $matches)) { + $keyVar = $matches['key'] ?: null; + $valueVar = $matches['value']; + $collection = $matches['collection']; + + $body = []; + $first = []; + $inner = []; + $last = []; + + while (!$this->isAtEnd() && !$this->isLogicEnd('endforeach')) { + if ($this->isLogicStart('first')) { + $this->consumeLogic('first'); + $first = $this->parseUntilLogicEnd('endfirst'); + } elseif ($this->isLogicStart('inner')) { + $this->consumeLogic('inner'); + $inner = $this->parseUntilLogicEnd('endinner'); + } elseif ($this->isLogicStart('last')) { + $this->consumeLogic('last'); + $last = $this->parseUntilLogicEnd('endlast'); + } else { + $body[] = $this->parseNode(); + } + } + + $this->consumeLogic('endforeach'); + + return new ForeachNode($collection, $valueVar, $keyVar, $body, $line, $first, $inner, $last); + } + + throw new SyntaxException("Invalid foreach syntax: '{( $content )}' at line $line"); + } + + private function parseBlockTag(): Node + { + $startToken = $this->consume(TokenType::BLOCK_START); + $content = ''; + while (!$this->check(TokenType::BLOCK_END) && !$this->isAtEnd()) { + $content .= $this->advance()->value; + } + $this->consume(TokenType::BLOCK_END, "Expected ']}' at line " . $startToken->line); + + $content = trim($content); + + if (preg_match('/^extends\s+[\'"](.+?)[\'"]$/', $content, $matches)) { + return new ExtendsNode($matches[1], $startToken->line); + } + + if (preg_match('/^block\s+[\'"](.+?)[\'"]$/', $content, $matches)) { + $name = $matches[1]; + $body = $this->parseUntilBlockEnd('endblock'); + return new BlockNode($name, $body, $startToken->line, $this->isLayout); + } + + if ($content === 'parent') { + return new ParentNode($startToken->line); + } + + if (preg_match('/^include\s+[\'"](.+?)[\'"](?:\s+with\s+(.+))?$/', $content, $matches)) { + $path = $matches[1]; + $context = isset($matches[2]) ? trim($matches[2]) : null; + return new IncludeNode($path, $context, $startToken->line); + } + + if (preg_match('/^include\s+[\'"](.+?)[\'"]\s+with\s+(\[.*\])$/', $content, $matches)) { + $path = $matches[1]; + $context = $matches[2]; + return new IncludeNode($path, $context, $startToken->line); + } + + throw new SyntaxException("Unknown block tag: '{[ $content ]}' at line {$startToken->line}"); + } + + private function parseUntilLogicEnd(string $endTag): array + { + $nodes = []; + while (!$this->isAtEnd() && !$this->isLogicEnd($endTag)) { + $nodes[] = $this->parseNode(); + } + $this->consumeLogic($endTag); + return $nodes; + } + + private function parseUntilBlockEnd(string $endTag): array + { + $nodes = []; + while (!$this->isAtEnd() && !$this->isBlockEnd($endTag)) { + $nodes[] = $this->parseNode(); + } + $this->consumeBlock($endTag); + return $nodes; + } + + private function isLogicStart(string $tag): bool + { + if ($this->peek()->type !== TokenType::LOGIC_START) { + return false; + } + $contentToken = $this->peek(1); + return $contentToken && trim($contentToken->value) === $tag; + } + + private function isLogicEnd(string $tag): bool + { + return $this->isLogicStart($tag); + } + + private function isBlockEnd(string $tag): bool + { + if ($this->peek()->type !== TokenType::BLOCK_START) { + return false; + } + $contentToken = $this->peek(1); + return $contentToken && trim($contentToken->value) === $tag; + } + + private function consumeLogic(string $tag): void + { + $this->consume(TokenType::LOGIC_START); + $content = ''; + while (!$this->check(TokenType::LOGIC_END) && !$this->isAtEnd()) { + $content .= $this->advance()->value; + } + if (trim($content) !== $tag) { + throw new SyntaxException("Expected '{( $tag )}' but got '{( $content )}'"); + } + $this->consume(TokenType::LOGIC_END); + } + + private function consumeBlock(string $tag): void + { + $this->consume(TokenType::BLOCK_START); + $content = ''; + while (!$this->check(TokenType::BLOCK_END) && !$this->isAtEnd()) { + $content .= $this->advance()->value; + } + if (trim($content) !== $tag) { + throw new SyntaxException("Expected '{[ $tag ]}' but got '{[ $content ]}'"); + } + $this->consume(TokenType::BLOCK_END); + } + + private function peek(int $offset = 0): Token + { + if ($this->position + $offset >= count($this->tokens)) { + return new Token(TokenType::EOF, '', -1); + } + return $this->tokens[$this->position + $offset]; + } + + private function advance(): Token + { + if (!$this->isAtEnd()) { + $this->position++; + } + return $this->tokens[$this->position - 1]; + } + + private function isAtEnd(): bool + { + return $this->peek()->type === TokenType::EOF || $this->position >= count($this->tokens); + } + + private function check(TokenType $type): bool + { + if ($this->isAtEnd()) { + return false; + } + return $this->peek()->type === $type; + } + + private function consume(TokenType $type, string $message = ''): Token + { + if ($this->check($type)) { + return $this->advance(); + } + + throw new SyntaxException($message ?: "Expected token $type->name but found " . $this->peek()->type->name); + } +} diff --git a/src/Parser/Token.php b/src/Parser/Token.php new file mode 100644 index 0000000..3efdf1c --- /dev/null +++ b/src/Parser/Token.php @@ -0,0 +1,29 @@ +assertEquals('production', $config->getMode()); + $this->assertEquals('./templates', $config->getTemplatesDir()); + $this->assertEquals('./templates/layouts', $config->getLayoutsDir()); + $this->assertEquals('./templates/partials', $config->getPartialsDir()); + $this->assertEquals('./filters', $config->getFiltersDir()); + $this->assertEquals('./.scape/cache', $config->getCacheDir()); + } + + public function testEnvironmentVariableOverrides(): void + { + putenv('SCAPE_MODE=debug'); + putenv('SCAPE_TEMPLATES_DIR=/tmp/templates'); + + $config = new Config(); + + $this->assertEquals('debug', $config->getMode()); + $this->assertEquals('/tmp/templates', $config->getTemplatesDir()); + // Layouts/Partials should still fall back relative to templates if not set? + // Or remain as defaults? Let's check spec. + // Spec says they are managed via env vars. We'll assume they have independent defaults. + } + + public function testProgrammaticOverridesPrecedence(): void + { + putenv('SCAPE_MODE=debug'); + + $config = new Config([ + 'mode' => 'production', + 'templates_dir' => '/custom/path' + ]); + + $this->assertEquals('production', $config->getMode()); + $this->assertEquals('/custom/path', $config->getTemplatesDir()); + } +} diff --git a/tests/LexerTest.php b/tests/LexerTest.php new file mode 100644 index 0000000..00507ab --- /dev/null +++ b/tests/LexerTest.php @@ -0,0 +1,105 @@ +tokenize(); + + $this->assertCount(2, $tokens); // TEXT + EOF + $this->assertEquals(TokenType::TEXT, $tokens[0]->type); + $this->assertEquals("Hello World", $tokens[0]->value); + } + + public function testTokenizesInterpolation(): void + { + $lexer = new Lexer("{{ var }}"); + $tokens = $lexer->tokenize(); + + $this->assertCount(4, $tokens); // START, TEXT, END, EOF + $this->assertEquals(TokenType::INTERPOLATION_START, $tokens[0]->type); + $this->assertEquals(TokenType::TEXT, $tokens[1]->type); + $this->assertEquals(" var ", $tokens[1]->value); + $this->assertEquals(TokenType::INTERPOLATION_END, $tokens[2]->type); + } + + public function testTokenizesRawInterpolation(): void + { + $lexer = new Lexer("{{{ raw }}}"); + $tokens = $lexer->tokenize(); + + $this->assertCount(4, $tokens); + $this->assertEquals(TokenType::RAW_START, $tokens[0]->type); + $this->assertEquals(" raw ", $tokens[1]->value); + $this->assertEquals(TokenType::RAW_END, $tokens[2]->type); + } + + public function testTokenizesLogicTags(): void + { + $lexer = new Lexer("{( foreach item in items )}content{( endforeach )}"); + $tokens = $lexer->tokenize(); + + $this->assertEquals(TokenType::LOGIC_START, $tokens[0]->type); + $this->assertEquals(" foreach item in items ", $tokens[1]->value); + $this->assertEquals(TokenType::LOGIC_END, $tokens[2]->type); + $this->assertEquals("content", $tokens[3]->value); + } + + public function testLogicTagConsumesTrailingNewline(): void + { + // Logic tag followed by newline should consume the newline + $lexer = new Lexer("{( foreach )}\nLine 2"); + $tokens = $lexer->tokenize(); + + // 0: START, 1: TEXT(" foreach "), 2: END, 3: TEXT("Line 2"), 4: EOF + $this->assertEquals("Line 2", $tokens[3]->value); + $this->assertEquals(TokenType::TEXT, $tokens[3]->type); + $this->assertEquals(2, $tokens[3]->line); + } + + public function testLogicTagConsumesTrailingNewlineWithHeredoc(): void + { + $template = <<