diff --git a/packages/router/src/Exceptions/DevelopmentException.php b/packages/router/src/Exceptions/DevelopmentException.php index 8ffa2d6da..9cb242712 100644 --- a/packages/router/src/Exceptions/DevelopmentException.php +++ b/packages/router/src/Exceptions/DevelopmentException.php @@ -90,8 +90,30 @@ private function enhanceStacktraceForViewCompilation(ViewCompilationFailed $exce return $stacktrace; } - $lines = explode("\n", $exception->content); - $errorLine = $previous->getLine(); + $hasSourceLocation = $exception->sourcePath && $exception->sourceLine && Filesystem\is_file($exception->sourcePath); + + $errorPath = $hasSourceLocation + ? $exception->sourcePath + : $exception->path; + + $errorLine = $exception->sourceLine ?? $previous->getLine(); + + $firstFrame = $stacktrace->frames[0] ?? null; + + if ( + $firstFrame instanceof Frame + && $firstFrame->absoluteFile === $errorPath + && $firstFrame->line === $errorLine + && $firstFrame->class === TempestViewRenderer::class + && $firstFrame->function === 'renderCompiled' + ) { + return $stacktrace; + } + + $lines = $hasSourceLocation + ? explode(PHP_EOL, Filesystem\read_file($exception->sourcePath)) + : explode(PHP_EOL, $exception->content); + $contextLines = 5; $startLine = max(1, $errorLine - $contextLines); $endLine = min(count($lines), $errorLine + $contextLines); @@ -111,8 +133,8 @@ function: 'renderCompiled', lines: $snippetLines, highlightedLine: $errorLine, ), - absoluteFile: $exception->path, - relativeFile: to_relative_path(root_path(), $exception->path), + absoluteFile: $errorPath, + relativeFile: to_relative_path(root_path(), $errorPath), arguments: [], index: 1, )); diff --git a/packages/view/src/CompiledView.php b/packages/view/src/CompiledView.php new file mode 100644 index 000000000..34210f268 --- /dev/null +++ b/packages/view/src/CompiledView.php @@ -0,0 +1,17 @@ + $lineMap + */ + public function __construct( + public string $content, + public ?string $sourcePath, + public array $lineMap, + ) {} +} diff --git a/packages/view/src/Element.php b/packages/view/src/Element.php index a6b3031cf..a97a2564e 100644 --- a/packages/view/src/Element.php +++ b/packages/view/src/Element.php @@ -42,4 +42,9 @@ public function getChildren(): array; * @return T|null */ public function unwrap(string $elementClass): ?Element; + + /** + * @return string[] An array of import statements + */ + public function getImports(): array; } diff --git a/packages/view/src/Elements/CollectionElement.php b/packages/view/src/Elements/CollectionElement.php index d2106965f..33e6986dc 100644 --- a/packages/view/src/Elements/CollectionElement.php +++ b/packages/view/src/Elements/CollectionElement.php @@ -10,21 +10,17 @@ final class CollectionElement implements Element { use IsElement; - public function __construct( - private readonly array $elements, - ) {} - - /** @return \Tempest\View\Element[] */ - public function getElements(): array + /** @param Element[] $elements */ + public function __construct(array $elements) { - return $this->elements; + $this->setChildren($elements); } public function compile(): string { $compiled = []; - foreach ($this->elements as $element) { + foreach ($this->getChildren() as $element) { $compiled[] = $element->compile(); } diff --git a/packages/view/src/Elements/ElementFactory.php b/packages/view/src/Elements/ElementFactory.php index 4d63a88fa..2ee353519 100644 --- a/packages/view/src/Elements/ElementFactory.php +++ b/packages/view/src/Elements/ElementFactory.php @@ -11,6 +11,7 @@ use Tempest\View\Parser\Token; use Tempest\View\Parser\TokenType; use Tempest\View\Slot; +use Tempest\View\ViewCache; use Tempest\View\ViewConfig; final class ElementFactory @@ -22,6 +23,7 @@ final class ElementFactory public function __construct( private readonly ViewConfig $viewConfig, private readonly Environment $environment, + private readonly ViewCache $viewCache, ) {} public function setViewCompiler(TempestViewCompiler $compiler): self @@ -40,15 +42,7 @@ public function withIsHtml(bool $isHtml): self return $clone; } - public function make(Token $token): ?Element - { - return $this->makeElement( - token: $token, - parent: null, - ); - } - - private function makeElement(Token $token, ?Element $parent): ?Element + public function make(Token $token, Element $parent): ?Element { if ( $token->type === TokenType::OPEN_TAG_END @@ -59,6 +53,12 @@ private function makeElement(Token $token, ?Element $parent): ?Element return null; } + $attributes = $token->htmlAttributes; + + foreach ($token->phpAttributes as $index => $content) { + $attributes[] = new PhpAttribute((string) $index, $content); + } + if ($token->type === TokenType::CONTENT) { $text = $token->compile(); @@ -66,37 +66,33 @@ private function makeElement(Token $token, ?Element $parent): ?Element return null; } - return new TextElement(text: $text); - } - - if ($token->type === TokenType::WHITESPACE) { - return new WhitespaceElement($token->content); - } - - if (! $token->tag || $token->type === TokenType::COMMENT || $token->type === TokenType::PHP) { - return new RawElement(token: $token, tag: null, content: $token->compile()); - } - - $attributes = $token->htmlAttributes; - - foreach ($token->phpAttributes as $index => $content) { - $attributes[] = new PhpAttribute((string) $index, $content); - } - - if ($token->tag === 'code' || $token->tag === 'pre') { - return new RawElement( + $element = new TextElement(text: $text); + } elseif ($token->type === TokenType::WHITESPACE) { + $element = new WhitespaceElement($token->content); + } elseif ($token->type !== TokenType::PHP && (! $token->tag || $token->type === TokenType::COMMENT)) { + $element = new RawElement( + token: $token, + tag: null, + content: $token->compile(), + ); + } elseif ($token->tag === 'code' || $token->tag === 'pre') { + $element = new RawElement( token: $token, tag: $token->tag, content: $token->compileChildren(), attributes: $attributes, ); - } - - if ($viewComponentClass = $this->viewConfig->viewComponents[$token->tag] ?? null) { + } elseif ($token->type === TokenType::PHP) { + $element = new PhpElement( + token: $token, + content: $token->compile(), + ); + } elseif ($viewComponentClass = $this->viewConfig->viewComponents[$token->tag] ?? null) { $element = new ViewComponentElement( token: $token, environment: $this->environment, compiler: $this->compiler, + viewCache: $this->viewCache, viewComponent: $viewComponentClass, attributes: $attributes, ); @@ -120,23 +116,15 @@ private function makeElement(Token $token, ?Element $parent): ?Element ); } - $children = []; + $element->setParent($parent); foreach ($token->children as $child) { - $childElement = $this->clone()->makeElement( + $this->clone()->make( token: $child, - parent: $parent, + parent: $element, ); - - if ($childElement === null) { - continue; - } - - $children[] = $childElement; } - $element->setChildren($children); - return $element; } diff --git a/packages/view/src/Elements/IsElement.php b/packages/view/src/Elements/IsElement.php index 7aa66e030..415566d21 100644 --- a/packages/view/src/Elements/IsElement.php +++ b/packages/view/src/Elements/IsElement.php @@ -121,6 +121,8 @@ public function setParent(?Element $parent): self { $this->parent = $parent; + $this->parent->setChildren([...$this->parent->getChildren(), $this]); + return $this; } @@ -141,10 +143,7 @@ public function setChildren(array $children): self $previous = null; foreach ($children as $child) { - $child - ->setParent($this) - ->setPrevious($previous); - + $child->setPrevious($previous); $previous = $child; } @@ -170,4 +169,13 @@ public function unwrap(string $elementClass): ?Element return null; } + + public function getImports(): array + { + if ($this->parent) { + return $this->parent->getImports(); + } + + return []; + } } diff --git a/packages/view/src/Elements/PhpElement.php b/packages/view/src/Elements/PhpElement.php new file mode 100644 index 000000000..a75cb3577 --- /dev/null +++ b/packages/view/src/Elements/PhpElement.php @@ -0,0 +1,31 @@ +content; + } + + public function getImports(): array + { + preg_match_all('/^\s*use .*;/m', $this->content, $matches); + + return $matches[0]; + } +} diff --git a/packages/view/src/Elements/RootElement.php b/packages/view/src/Elements/RootElement.php new file mode 100644 index 000000000..ec54f92a3 --- /dev/null +++ b/packages/view/src/Elements/RootElement.php @@ -0,0 +1,59 @@ +children as $element) { + $compiled[] = $element->compile(); + } + + return implode($compiled); + } + + public function getImports(): array + { + $imports = []; + + $this->mergeImports($imports, $this->inheritedImports); + + foreach ($this->children as $child) { + if ($child instanceof PhpElement) { + $this->mergeImports($imports, $child->getImports()); + } + } + + return array_values($imports); + } + + public function setInheritedImports(array $imports): self + { + $this->inheritedImports = $imports; + + return $this; + } + + private function mergeImports(array &$imports, array $candidates): void + { + foreach ($candidates as $import) { + $import = trim($import); + + if ($import === '') { + continue; + } + + $imports[$import] = $import; + } + } +} diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index dbe953dd5..4f42d0627 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -14,6 +14,7 @@ use Tempest\View\Parser\TempestViewParser; use Tempest\View\Parser\Token; use Tempest\View\Slot; +use Tempest\View\ViewCache; use Tempest\View\ViewComponent; use Tempest\View\WithToken; @@ -36,6 +37,7 @@ public function __construct( public readonly Token $token, private readonly Environment $environment, private readonly TempestViewCompiler $compiler, + private readonly ViewCache $viewCache, private readonly ViewComponent $viewComponent, array $attributes, ) { @@ -103,31 +105,13 @@ public function compile(): string $compiled = $compiled ->prepend( sprintf( - '', + '', $this->dataAttributes->isNotEmpty() ? ', ' . $this->dataAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ') : '', $this->expressionAttributes->isNotEmpty() ? ', ' . $this->expressionAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ') : '', $this->scopedVariables->isNotEmpty() ? ', ' . $this->scopedVariables->map(fn (string $name) => "\${$name}")->implode(', ') : '', ), ) - ->append( - sprintf( - 'currentView?->data ?? []) %s %s %s) ?>', - $this->exportAttributesArray(), - ViewObjectExporter::export($slots), - $this->scopedVariables->isNotEmpty() - ? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ') - : '', - $this->dataAttributes->isNotEmpty() - ? ', ' . $this->dataAttributes->map(fn (mixed $value, string $key) => "{$key}: " . ViewObjectExporter::exportValue($value))->implode(', ') - : '', - $this->expressionAttributes->isNotEmpty() - ? ', ' . $this->expressionAttributes->map(fn (mixed $value, string $key) => "{$key}: " . $value)->implode(', ') - : '', - $this->scopedVariables->isNotEmpty() - ? ', ' . $this->scopedVariables->map(fn (string $name) => "{$name}: \${$name}")->implode(', ') - : '', - ), - ); + ->append('replaceRegex( regex: '/[\w-]+)")?((\s*\/>)|>(?(.|\n)*?)<\/x-slot>)/', @@ -151,7 +135,11 @@ public function compile(): string $slotElement = $this->getSlotElement($slot->name); - $compiled = $slotElement?->compile() ?? ''; + if ($slotElement === null) { + return $default; + } + + $compiled = $this->compiler->compileElement($slotElement); // There's no default slot content, but there's a default value in the view component if (trim($compiled) === '') { @@ -162,7 +150,39 @@ public function compile(): string }, ); - return $this->compiler->compile($compiled->toString()); + $compiledView = $this->compiler->compileWithSourceMap( + $compiled->toString(), + sourcePath: $this->viewComponent->file, + prependImports: $this->getImports(), + ); + + $cacheKey = sprintf('%s:%s', $this->viewComponent->file, hash('xxh64', $compiledView->content)); + + $cachePath = $this->viewCache->getCachedViewPath( + $cacheKey, + fn () => $compiledView->content, + ); + + $this->viewCache->saveSourceMap($cachePath, $compiledView->sourcePath, $compiledView->lineMap); + + return sprintf( + 'includeViewComponent(%1$s)(attributes: %2$s, slots: %3$s, scopedVariables: [%4$s] + ($scopedVariables ?? $this->currentView?->data ?? []) %5$s %6$s %7$s); ?>', + var_export($cachePath, true), + $this->exportAttributesArray(), + ViewObjectExporter::export($slots), + $this->scopedVariables->isNotEmpty() + ? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ') + : '', + $this->dataAttributes->isNotEmpty() + ? ', ' . $this->dataAttributes->map(fn (mixed $value, string $key) => "{$key}: " . ViewObjectExporter::exportValue($value))->implode(', ') + : '', + $this->expressionAttributes->isNotEmpty() + ? ', ' . $this->expressionAttributes->map(fn (mixed $value, string $key) => "{$key}: " . $value)->implode(', ') + : '', + $this->scopedVariables->isNotEmpty() + ? ', ' . $this->scopedVariables->map(fn (string $name) => "{$name}: \${$name}")->implode(', ') + : '', + ); } private function getSlotElement(string $name): SlotElement|CollectionElement|null @@ -258,4 +278,21 @@ private function exportAttributesArray(): string return sprintf('new \%s([%s])', ImmutableArray::class, implode(', ', $entries)); } + + public function getImports(): array + { + $imports = []; + + if ($this->parent) { + $imports = [...$imports, ...$this->parent->getImports()]; + } + + foreach ($this->getChildren() as $child) { + if ($child instanceof PhpElement) { + $imports = [...$imports, ...$child->getImports()]; + } + } + + return $imports; + } } diff --git a/packages/view/src/Exceptions/ViewCompilationFailed.php b/packages/view/src/Exceptions/ViewCompilationFailed.php index 7cfeb24c2..0b5273e5b 100644 --- a/packages/view/src/Exceptions/ViewCompilationFailed.php +++ b/packages/view/src/Exceptions/ViewCompilationFailed.php @@ -11,20 +11,27 @@ final class ViewCompilationFailed extends Exception implements ProvidesContext { public function __construct( - private(set) string $path, - private(set) string $content, + private(set) readonly string $path, + private(set) readonly string $content, Throwable $previous, + private(set) readonly ?string $sourcePath = null, + private(set) readonly ?int $sourceLine = null, ) { parent::__construct( - message: sprintf('View could not be compiled: %s.', lcfirst($previous->getMessage())), + message: sprintf($previous->getMessage()), previous: $previous, ); + + $this->file = $this->sourcePath ?? $this->file; + $this->line = $this->sourceLine ?? $this->line; } public function context(): array { return [ 'path' => $this->path, + 'sourcePath' => $this->sourcePath, + 'sourceLine' => $this->sourceLine, ]; } } diff --git a/packages/view/src/Initializers/ElementFactoryInitializer.php b/packages/view/src/Initializers/ElementFactoryInitializer.php index f6eb55b6a..a6b7fd58d 100644 --- a/packages/view/src/Initializers/ElementFactoryInitializer.php +++ b/packages/view/src/Initializers/ElementFactoryInitializer.php @@ -7,6 +7,7 @@ use Tempest\Container\Singleton; use Tempest\Core\Environment; use Tempest\View\Elements\ElementFactory; +use Tempest\View\ViewCache; use Tempest\View\ViewConfig; final class ElementFactoryInitializer implements Initializer @@ -17,6 +18,7 @@ public function initialize(Container $container): ElementFactory return new ElementFactory( viewConfig: $container->get(ViewConfig::class), environment: $container->get(Environment::class), + viewCache: $container->get(ViewCache::class), ); } } diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index b9aca25e1..0a7b21d70 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -8,12 +8,16 @@ use Tempest\Support\Filesystem; use Tempest\View\Attribute; use Tempest\View\Attributes\AttributeFactory; +use Tempest\View\CompiledView; use Tempest\View\Element; use Tempest\View\Elements\ElementFactory; +use Tempest\View\Elements\RootElement; use Tempest\View\Exceptions\ViewNotFound; use Tempest\View\Exceptions\XmlDeclarationCouldNotBeParsed; use Tempest\View\ShouldBeRemoved; use Tempest\View\View; +use Tempest\View\WithToken; +use Tempest\View\WrapsElement; use function Tempest\Support\arr; use function Tempest\Support\path; @@ -27,6 +31,10 @@ '?>', ]; + private const string SOURCE_PATH_MARKER = '__tempest_source_path='; + + private const string SOURCE_LINE_MARKER = '__tempest_source_line='; + public function __construct( private ElementFactory $elementFactory, private AttributeFactory $attributeFactory, @@ -35,11 +43,19 @@ public function __construct( ) {} public function compile(string|View $view): string + { + return $this->compileWithSourceMap($view)->content; + } + + public function compileWithSourceMap(string|View $view, ?string $sourcePath = null, array $prependImports = []): CompiledView { $this->elementFactory->setViewCompiler($this); + $prependImports = $this->normalizeImports($prependImports); + // 1. Retrieve template - $template = $this->retrieveTemplate($view); + [$template, $resolvedSourcePath] = $this->retrieveTemplate($view); + $sourcePath ??= $resolvedSourcePath; // Check for XML declarations when short_open_tag is enabled if (ini_get('short_open_tag') && str_contains($template, needle: 'removeComments($template); // 3. Parse AST - $ast = $this->parseAst($template); + $ast = $this->parseAst($template, $sourcePath); // 4. Map to elements - $elements = $this->mapToElements($ast); + $rootElement = $this->mapToElements($ast); + + if ($prependImports !== []) { + $rootElement->setInheritedImports($prependImports); + } // 5. Apply attributes - $elements = $this->applyAttributes($elements); + $rootElement = $this->applyAttributes($rootElement); // 6. Compile to PHP - $compiled = $this->compileElements($elements); + $compiled = $this->compileElement($rootElement); // 7. Cleanup compiled PHP - $cleaned = $this->cleanupCompiled($compiled); + [$cleaned, $lineMap] = $this->cleanupCompiled($compiled, $sourcePath, $rootElement->getImports()); - return $cleaned; + return new CompiledView( + content: $cleaned, + sourcePath: $sourcePath, + lineMap: $lineMap, + ); } private function removeComments(string $template): string @@ -74,12 +98,13 @@ private function removeComments(string $template): string ->toString(); } - private function retrieveTemplate(string|View $view): string + /** @return array{string, string|null} */ + private function retrieveTemplate(string|View $view): array { $path = $view instanceof View ? $view->path : $view; if (! str_ends_with($path, '.php')) { - return $path; + return [$path, null]; } $searchPathOptions = [ @@ -97,67 +122,55 @@ private function retrieveTemplate(string|View $view): string ->toArray(), ]; + $searchPath = null; + foreach ($searchPathOptions as $searchPath) { if (Filesystem\is_file($searchPath)) { break; } } - if (! Filesystem\is_file($searchPath)) { + if (! $searchPath || ! Filesystem\is_file($searchPath)) { throw new ViewNotFound($path); } - return Filesystem\read_file($searchPath); + return [Filesystem\read_file($searchPath), $searchPath]; } - private function parseAst(string $template): TempestViewAst + private function parseAst(string $template, ?string $sourcePath = null): TempestViewAst { - $tokens = new TempestViewLexer($template)->lex(); + $tokens = new TempestViewLexer($template, $sourcePath)->lex(); return new TempestViewParser($tokens)->parse(); } - /** - * @return Element[] - */ - private function mapToElements(TempestViewAst $ast): array + private function mapToElements(TempestViewAst $ast): RootElement { - $elements = []; - $elementFactory = $this->elementFactory->withIsHtml($ast->isHtml); - foreach ($ast as $token) { - $element = $elementFactory->make($token); - - if ($element === null) { - continue; - } + $rootElement = new RootElement(); - $elements[] = $element; + foreach ($ast as $token) { + $elementFactory->make($token, $rootElement); } - return $elements; + return $rootElement; } - /** - * @param Element[] $elements - * @return Element[] - */ - private function applyAttributes(array $elements): array + private function applyAttributes(Element $parentElement): Element { $appliedElements = []; $previous = null; - foreach ($elements as $element) { - $children = $this->applyAttributes($element->getChildren()); - $element->setChildren($children); + foreach ($parentElement->getChildren() as $childElement) { + $this->applyAttributes($childElement); - $element->setPrevious($previous); + $childElement->setPrevious($previous); $shouldBeRemoved = false; - foreach ($element->getAttributes() as $name => $value) { + foreach ($childElement->getAttributes() as $name => $value) { // TODO: possibly refactor attribute construction to ElementFactory? if ($value instanceof Attribute) { $attribute = $value; @@ -165,7 +178,7 @@ private function applyAttributes(array $elements): array $attribute = $this->attributeFactory->make($name); } - $element = $attribute->apply($element); + $childElement = $attribute->apply($childElement); if ($shouldBeRemoved === false && $attribute instanceof ShouldBeRemoved) { $shouldBeRemoved = true; @@ -176,20 +189,34 @@ private function applyAttributes(array $elements): array continue; } - $appliedElements[] = $element; + $appliedElements[] = $childElement; - $previous = $element; + $previous = $childElement; } - return $appliedElements; + $parentElement->setChildren($appliedElements); + + return $parentElement; } - /** @param \Tempest\View\Element[] $elements */ - private function compileElements(array $elements): string + public function compileElement(Element $rootElement): string { $compiled = arr(); + $sourcePath = null; + + foreach ($rootElement->getChildren() as $element) { + if ($sourceLocation = $this->resolveSourceLocation($element)) { + if ($sourceLocation['sourcePath'] !== $sourcePath) { + $sourcePath = $sourceLocation['sourcePath']; + + if ($sourcePath !== null) { + $compiled[] = self::sourcePathMarker($sourcePath); + } + } + + $compiled[] = self::sourceLineMarker($sourceLocation['sourceLine']); + } - foreach ($elements as $element) { $compiled[] = $element->compile(); } @@ -198,7 +225,43 @@ private function compileElements(array $elements): string ->toString(); } - private function cleanupCompiled(string $compiled): string + private static function sourcePathMarker(string $sourcePath): string + { + return sprintf('', self::SOURCE_PATH_MARKER, base64_encode($sourcePath)); + } + + private static function sourceLineMarker(int $sourceLine): string + { + return sprintf('', self::SOURCE_LINE_MARKER, $sourceLine); + } + + /** @return array{sourcePath: string|null, sourceLine: int}|null */ + private function resolveSourceLocation(Element $element): ?array + { + if ($element instanceof WithToken) { + return [ + 'sourcePath' => $element->token->sourcePath, + 'sourceLine' => $element->token->line, + ]; + } + + if ($element instanceof WrapsElement) { + return $this->resolveSourceLocation($element->getWrappingElement()); + } + + foreach ($element->getChildren() as $child) { + if ($sourceLocation = $this->resolveSourceLocation($child)) { + return $sourceLocation; + } + } + + return null; + } + + /** + * @return array{string, array} + */ + private function cleanupCompiled(string $compiled, ?string $sourcePath, array $importsToPrepend = []): array { // Remove strict type declarations $compiled = str($compiled)->replace('declare(strict_types=1);', ''); @@ -206,6 +269,10 @@ private function cleanupCompiled(string $compiled): string // Cleanup and bundle imports $imports = arr(); + foreach ($this->normalizeImports($importsToPrepend) as $import) { + $imports[$import] = $import; + } + $compiled = $compiled->replaceRegex("/^\s*use (function )?.*;/m", function (array $matches) use (&$imports) { // The import contains escaped slashes, meaning it's a var_exported string; we can ignore those if (str_contains($matches[0], '\\\\')) { @@ -231,6 +298,179 @@ private function cleanupCompiled(string $compiled): string // Remove empty PHP blocks $compiled = $compiled->replaceRegex('/<\?php\s*\?>/', ''); - return $compiled->toString(); + return $this->extractSourceMap($compiled->toString(), $sourcePath); + } + + private function normalizeImports(array $imports): array + { + $normalized = []; + + foreach ($imports as $import) { + $import = trim($import); + + if ($import === '') { + continue; + } + + $normalized[$import] = $import; + } + + return array_values($normalized); + } + + /** + * @return array{string, array} + */ + private function extractSourceMap(string $compiled, ?string $sourcePath): array + { + $compiledLines = explode("\n", $compiled); + + $cleanedLines = []; + $lineMap = []; + $currentSourcePath = $sourcePath; + $sourceLine = null; + $compiledLine = 0; + + foreach ($compiledLines as $line) { + if (preg_match(sprintf('/^\s*<\?php \/\*%s(?[a-zA-Z0-9\+\/=]+)\*\/ \?>\s*$/', self::SOURCE_PATH_MARKER), $line, $matches) === 1) { + $decodedPath = base64_decode($matches['path'], true); + $currentSourcePath = $decodedPath === false ? null : $decodedPath; + continue; + } + + if (preg_match(sprintf('/^\s*<\?php \/\*%s(?\d+)\*\/ \?>\s*$/', self::SOURCE_LINE_MARKER), $line, $matches) === 1) { + $sourceLine = $currentSourcePath !== null + ? (int) $matches['line'] + : null; + + continue; + } + + $compiledLine++; + $cleanedLines[] = $line; + + if ($sourceLine === null || $currentSourcePath === null) { + continue; + } + + $lineMap[$compiledLine] = [ + 'sourcePath' => $currentSourcePath, + 'sourceLine' => $sourceLine, + ]; + + $sourceLine++; + } + + return [ + implode("\n", $cleanedLines), + $this->compressLineMap($lineMap), + ]; + } + + /** + * @param array $lineMap + * @return array + */ + private function compressLineMap(array $lineMap): array + { + if ($lineMap === []) { + return []; + } + + ksort($lineMap); + + $entries = []; + $currentRange = null; + + foreach ($lineMap as $compiledLine => $sourceLocation) { + $lineMapping = $this->createLineMapping($compiledLine, $sourceLocation); + + if ($currentRange === null) { + $currentRange = $this->startLineMapRange($lineMapping); + continue; + } + + if ($this->canExtendLineMapRange($currentRange, $lineMapping)) { + $currentRange = $this->extendLineMapRange($currentRange, $lineMapping); + continue; + } + + $entries[] = $this->createLineMapEntry($currentRange); + $currentRange = $this->startLineMapRange($lineMapping); + } + + $entries[] = $this->createLineMapEntry($currentRange); + + return $entries; + } + + /** + * @param array{sourcePath: string, sourceLine: int} $sourceLocation + * @return array{compiledLine: int, sourcePath: string, sourceLine: int} + */ + private function createLineMapping(int $compiledLine, array $sourceLocation): array + { + return [ + 'compiledLine' => $compiledLine, + 'sourcePath' => $sourceLocation['sourcePath'], + 'sourceLine' => $sourceLocation['sourceLine'], + ]; + } + + /** + * @param array{compiledLine: int, sourcePath: string, sourceLine: int} $lineMapping + * @return array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} + */ + private function startLineMapRange(array $lineMapping): array + { + return [ + 'compiledStartLine' => $lineMapping['compiledLine'], + 'compiledEndLine' => $lineMapping['compiledLine'], + 'sourcePath' => $lineMapping['sourcePath'], + 'sourceStartLine' => $lineMapping['sourceLine'], + 'sourceEndLine' => $lineMapping['sourceLine'], + ]; + } + + /** + * @param array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} $range + * @param array{compiledLine: int, sourcePath: string, sourceLine: int} $lineMapping + */ + private function canExtendLineMapRange(array $range, array $lineMapping): bool + { + return ( + $lineMapping['compiledLine'] + === ($range['compiledEndLine'] + 1) + && $lineMapping['sourcePath'] === $range['sourcePath'] + && $lineMapping['sourceLine'] + === ($range['sourceEndLine'] + 1) + ); + } + + /** + * @param array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} $range + * @param array{compiledLine: int, sourcePath: string, sourceLine: int} $lineMapping + * @return array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} + */ + private function extendLineMapRange(array $range, array $lineMapping): array + { + $range['compiledEndLine'] = $lineMapping['compiledLine']; + $range['sourceEndLine'] = $lineMapping['sourceLine']; + + return $range; + } + + /** + * @param array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} $range + * @return array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int} + */ + private function createLineMapEntry(array $range): array + { + return [ + 'compiledStartLine' => $range['compiledStartLine'], + 'compiledEndLine' => $range['compiledEndLine'], + 'sourcePath' => $range['sourcePath'], + 'sourceStartLine' => $range['sourceStartLine'], + ]; } } diff --git a/packages/view/src/Parser/TempestViewLexer.php b/packages/view/src/Parser/TempestViewLexer.php index e6d7eced8..1f5c8931d 100644 --- a/packages/view/src/Parser/TempestViewLexer.php +++ b/packages/view/src/Parser/TempestViewLexer.php @@ -8,10 +8,13 @@ final class TempestViewLexer private int $position = 0; + private int $line = 1; + private ?string $current; public function __construct( private readonly string $html, + private readonly ?string $sourcePath = null, ) { $this->current = $this->html[$this->position] ?? null; } @@ -70,6 +73,7 @@ private function consume(int $length = 1): string { $buffer = substr($this->html, $this->position, $length); $this->position += $length; + $this->line += substr_count($buffer, "\n"); $this->current = $this->html[$this->position] ?? null; return $buffer; @@ -94,20 +98,31 @@ private function consumeIncluding(string $search): string return $this->consumeUntil($search) . $this->consume(strlen($search)); } + private function makeToken(string $content, TokenType $type, int $line): Token + { + return new Token( + content: $content, + type: $type, + line: $line, + sourcePath: $this->sourcePath, + ); + } + private function lexTag(): array { + $tagLine = $this->line; $tag = $this->consumeWhile('consumeIncluding('>'); - $tokens[] = new Token($tag, TokenType::CLOSING_TAG); + $tokens[] = $this->makeToken($tag, TokenType::CLOSING_TAG, $tagLine); } elseif ($this->seekIgnoringWhitespace() === '/' || str_ends_with($tag, '/')) { $tag .= $this->consumeIncluding('>'); - $tokens[] = new Token($tag, TokenType::SELF_CLOSING_TAG); + $tokens[] = $this->makeToken($tag, TokenType::SELF_CLOSING_TAG, $tagLine); } else { - $tokens[] = new Token($tag, TokenType::OPEN_TAG_START); + $tokens[] = $this->makeToken($tag, TokenType::OPEN_TAG_START, $tagLine); while ($this->seek() !== null && $this->seekIgnoringWhitespace() !== '>' && $this->seekIgnoringWhitespace() !== '/') { if ($this->seekIgnoringWhitespace(2) === 'line; $attributeName = $this->consumeWhile(self::WHITESPACE); $attributeName .= $this->consumeUntil(self::WHITESPACE . '=/>'); @@ -125,9 +141,10 @@ private function lexTag(): array $attributeName .= $this->consume(); } - $tokens[] = new Token( + $tokens[] = $this->makeToken( content: $attributeName, type: TokenType::ATTRIBUTE_NAME, + line: $attributeLine, ); if ($hasValue) { @@ -135,25 +152,33 @@ private function lexTag(): array ? '\'' : '"'; + $attributeValueLine = $this->line; $attributeValue = $this->consumeIncluding($quote); $attributeValue .= $this->consumeIncluding($quote); - $tokens[] = new Token( + $tokens[] = $this->makeToken( content: $attributeValue, type: TokenType::ATTRIBUTE_VALUE, + line: $attributeValueLine, ); } } if ($this->seekIgnoringWhitespace() === '>') { - $tokens[] = new Token( + $openTagEndLine = $this->line; + + $tokens[] = $this->makeToken( content: $this->consumeIncluding('>'), type: TokenType::OPEN_TAG_END, + line: $openTagEndLine, ); } elseif ($this->seekIgnoringWhitespace() === '/') { - $tokens[] = new Token( + $selfClosingTagEndLine = $this->line; + + $tokens[] = $this->makeToken( content: $this->consumeIncluding('>'), type: TokenType::SELF_CLOSING_TAG_END, + line: $selfClosingTagEndLine, ); } } @@ -163,6 +188,7 @@ private function lexTag(): array private function lexXml(): Token { + $line = $this->line; $buffer = ''; while ($this->seek(2) !== '?>' && $this->current !== null) { @@ -171,11 +197,12 @@ private function lexXml(): Token $buffer .= $this->consume(2); - return new Token($buffer, TokenType::XML); + return $this->makeToken($buffer, TokenType::XML, $line); } private function lexPhp(): Token { + $line = $this->line; $buffer = ''; while ($this->seek(2) !== '?>' && $this->current !== null) { @@ -184,18 +211,20 @@ private function lexPhp(): Token $buffer .= $this->consume(2); - return new Token($buffer, TokenType::PHP); + return $this->makeToken($buffer, TokenType::PHP, $line); } private function lexContent(): Token { + $line = $this->line; $buffer = $this->consumeUntil('<'); - return new Token($buffer, TokenType::CONTENT); + return $this->makeToken($buffer, TokenType::CONTENT, $line); } private function lexComment(): Token { + $line = $this->line; $buffer = ''; while ($this->seek(3) !== '-->' && $this->current !== null) { @@ -204,37 +233,45 @@ private function lexComment(): Token $buffer .= $this->consume(3); - return new Token($buffer, TokenType::COMMENT); + return $this->makeToken($buffer, TokenType::COMMENT, $line); } private function lexDoctype(): Token { + $line = $this->line; $buffer = $this->consumeIncluding('>'); - return new Token($buffer, TokenType::DOCTYPE); + return $this->makeToken($buffer, TokenType::DOCTYPE, $line); } private function lexWhitespace(): Token { + $line = $this->line; $buffer = $this->consumeWhile(self::WHITESPACE); - return new Token($buffer, TokenType::WHITESPACE); + return $this->makeToken($buffer, TokenType::WHITESPACE, $line); } private function lexCharacterData(): array { + $characterDataOpenLine = $this->line; + $tokens = [ - new Token($this->consumeIncluding('makeToken($this->consumeIncluding('line; + while ($this->seek(3) !== ']]>' && $this->current !== null) { $buffer .= $this->consume(); } - $tokens[] = new Token($buffer, TokenType::CONTENT); - $tokens[] = new Token($this->consume(3), TokenType::CHARACTER_DATA_CLOSE); + $tokens[] = $this->makeToken($buffer, TokenType::CONTENT, $contentLine); + + $characterDataCloseLine = $this->line; + $tokens[] = $this->makeToken($this->consume(3), TokenType::CHARACTER_DATA_CLOSE, $characterDataCloseLine); return $tokens; } diff --git a/packages/view/src/Parser/Token.php b/packages/view/src/Parser/Token.php index 939dadd56..007196160 100644 --- a/packages/view/src/Parser/Token.php +++ b/packages/view/src/Parser/Token.php @@ -29,6 +29,8 @@ final class Token public function __construct( public readonly string $content, public readonly TokenType $type, + public readonly int $line = 1, + public readonly ?string $sourcePath = null, ) { $this->tag = (match ($this->type) { TokenType::OPEN_TAG_START => str($this->content) diff --git a/packages/view/src/Renderers/TempestViewRenderer.php b/packages/view/src/Renderers/TempestViewRenderer.php index 26429037e..54340940c 100644 --- a/packages/view/src/Renderers/TempestViewRenderer.php +++ b/packages/view/src/Renderers/TempestViewRenderer.php @@ -4,6 +4,8 @@ namespace Tempest\View\Renderers; +use Closure; +use ErrorException; use Stringable; use Tempest\Container\Container; use Tempest\Core\Environment; @@ -25,6 +27,9 @@ final class TempestViewRenderer implements ViewRenderer { private ?View $currentView = null; + /** @var array */ + private array $includedViewComponents = []; + public function __construct( private readonly TempestViewCompiler $compiler, private readonly ViewCache $viewCache, @@ -38,10 +43,12 @@ public static function make( Environment $environment = Environment::PRODUCTION, ): self { $viewConfig ??= new ViewConfig(); + $viewCache ??= ViewCache::create(enabled: false); $elementFactory = new ElementFactory( $viewConfig, $environment, + $viewCache, ); $compiler = new TempestViewCompiler( @@ -51,8 +58,6 @@ public static function make( $elementFactory->setViewCompiler($compiler); - $viewCache ??= ViewCache::create(enabled: false); - return new self( compiler: $compiler, viewCache: $viewCache, @@ -77,16 +82,32 @@ public function render(string|View $view): string $this->validateView($view); + $compiledView = null; + $path = $this->viewCache->getCachedViewPath( path: $view->path, - compiledView: fn () => $this->compiler->compile($view), + compiledView: function () use (&$compiledView, $view): string { + $compiledView = $this->compiler->compileWithSourceMap($view); + + return $compiledView->content; + }, ); + if ($compiledView !== null) { + $this->viewCache->saveSourceMap($path, $compiledView->sourcePath, $compiledView->lineMap); + } + $view = $this->processView($view); return $this->renderCompiled($view, $path); } + public function includeViewComponent(string $path): Closure + { + /** @var Closure */ + return $this->includedViewComponents[$path] ??= include $path; + } + private function processView(View $view): View { foreach ($this->viewConfig->viewProcessors as $viewProcessorClass) { @@ -114,16 +135,31 @@ private function renderCompiled(View $_view, string $_path): string extract($_data, flags: EXTR_SKIP); + set_error_handler(static function (int $code, string $message, string $filename, int $line): bool { + throw new ErrorException( + message: $message, + code: $code, + filename: $filename, + line: $line, + ); + }); + try { include $_path; } catch (Throwable $throwable) { ob_end_clean(); // clean buffer before rendering exception + $sourceLocation = $this->resolveSourceLocationFromThrowable($throwable, $_path); + throw new ViewCompilationFailed( path: $_path, - content: Filesystem\read_file($_path), + content: Filesystem\is_file($_path) ? Filesystem\read_file($_path) : '', previous: $throwable, + sourcePath: $sourceLocation['path'] ?? null, + sourceLine: $sourceLocation['line'] ?? null, ); + } finally { + restore_error_handler(); } $this->currentView = null; @@ -152,4 +188,82 @@ private function validateView(View $view): void throw new ViewVariableWasReserved('slots'); } } + + /** @return array{path: string, line: int}|null */ + private function resolveSourceLocation(string $compiledPath, int $compiledLine): ?array + { + $sourceMap = $this->viewCache->getSourceMap($compiledPath); + + if ($sourceMap === null) { + return null; + } + + $sourceLocation = $this->resolveSourceLine( + $compiledLine, + $sourceMap['sourcePath'], + $sourceMap['lineMap'], + ); + + if ($sourceLocation === null) { + return null; + } + + return [ + 'path' => $sourceLocation['path'], + 'line' => $sourceLocation['line'], + ]; + } + + /** @return array{path: string, line: int}|null */ + private function resolveSourceLocationFromThrowable(Throwable $throwable, string $compiledPath): ?array + { + $sourceLocation = $this->resolveSourceLocation($throwable->getFile(), $throwable->getLine()); + + if ($sourceLocation !== null) { + return $sourceLocation; + } + + foreach ($throwable->getTrace() as $frame) { + $framePath = $frame['file'] ?? null; + $frameLine = $frame['line'] ?? null; + + if (! is_string($framePath) || ! is_int($frameLine)) { + continue; + } + + $sourceLocation = $this->resolveSourceLocation($framePath, $frameLine); + + if ($sourceLocation !== null) { + return $sourceLocation; + } + } + + return $this->resolveSourceLocation($compiledPath, 1); + } + + /** + * @param array $lineMap + * @return array{path: string, line: int}|null + */ + private function resolveSourceLine(int $compiledLine, ?string $defaultSourcePath, array $lineMap): ?array + { + foreach ($lineMap as $entry) { + if ($compiledLine < $entry['compiledStartLine'] || $compiledLine > $entry['compiledEndLine']) { + continue; + } + + $sourcePath = $entry['sourcePath'] ?? $defaultSourcePath; + + if (! is_string($sourcePath)) { + return null; + } + + return [ + 'path' => $sourcePath, + 'line' => $entry['sourceStartLine'] + ($compiledLine - $entry['compiledStartLine']), + ]; + } + + return null; + } } diff --git a/packages/view/src/ViewCache.php b/packages/view/src/ViewCache.php index fe0db3e46..83199b864 100644 --- a/packages/view/src/ViewCache.php +++ b/packages/view/src/ViewCache.php @@ -5,6 +5,7 @@ namespace Tempest\View; use Closure; +use Tempest\Support\Filesystem; use Throwable; use function Tempest\internal_storage_path; @@ -49,6 +50,53 @@ public function getCachedViewPath(string $path, Closure $compiledView): string return path($this->pool->directory, $cacheItem->getKey() . '.php')->toString(); } + /** + * @param array $lineMap + */ + public function saveSourceMap(string $compiledViewPath, ?string $sourcePath, array $lineMap): void + { + $sourceMapPath = $this->getSourceMapPath($compiledViewPath); + + $sourceMap = [ + 'sourcePath' => $sourcePath, + 'lineMap' => $lineMap, + ]; + + Filesystem\write_file( + $sourceMapPath, + sprintf("}|null + */ + public function getSourceMap(string $compiledViewPath): ?array + { + $sourceMapPath = $this->getSourceMapPath($compiledViewPath); + + if (! Filesystem\is_file($sourceMapPath)) { + return null; + } + + $sourceMap = include $sourceMapPath; + + if (! is_array($sourceMap)) { + return null; + } + + return $sourceMap; + } + + private function getSourceMapPath(string $compiledViewPath): string + { + if (str_ends_with($compiledViewPath, '.php')) { + return substr($compiledViewPath, offset: 0, length: -4) . '.map.php'; + } + + return $compiledViewPath . '.map.php'; + } + private static function getCachePath(): string { try { diff --git a/packages/view/tests/Fixtures/standalone-error-component-usage.view.php b/packages/view/tests/Fixtures/standalone-error-component-usage.view.php new file mode 100644 index 000000000..9161191c8 --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-error-component-usage.view.php @@ -0,0 +1 @@ + diff --git a/packages/view/tests/Fixtures/standalone-error-slot-usage.view.php b/packages/view/tests/Fixtures/standalone-error-slot-usage.view.php new file mode 100644 index 000000000..8bcb38da5 --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-error-slot-usage.view.php @@ -0,0 +1,3 @@ + + + diff --git a/packages/view/tests/Fixtures/standalone-error.view.php b/packages/view/tests/Fixtures/standalone-error.view.php new file mode 100644 index 000000000..6e284bb52 --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-error.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/packages/view/tests/Fixtures/standalone-undefined-variable.view.php b/packages/view/tests/Fixtures/standalone-undefined-variable.view.php new file mode 100644 index 000000000..e904d14db --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-undefined-variable.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/packages/view/tests/Fixtures/x-standalone-error-component.view.php b/packages/view/tests/Fixtures/x-standalone-error-component.view.php new file mode 100644 index 000000000..b6619a8fa --- /dev/null +++ b/packages/view/tests/Fixtures/x-standalone-error-component.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/packages/view/tests/StandaloneViewRendererTest.php b/packages/view/tests/StandaloneViewRendererTest.php index dc39898f4..857e67992 100644 --- a/packages/view/tests/StandaloneViewRendererTest.php +++ b/packages/view/tests/StandaloneViewRendererTest.php @@ -2,7 +2,9 @@ namespace Tempest\View\Tests; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\Exceptions\ViewComponentPathWasInvalid; use Tempest\View\Exceptions\ViewComponentPathWasNotFound; use Tempest\View\Exceptions\XmlDeclarationCouldNotBeParsed; @@ -119,6 +121,76 @@ public function test_xml_declaration_with_short_open_tag(): void $renderer->render(''); } + #[Test] + public function test_maps_source_path_and_line_for_view_errors(): void + { + $renderer = TempestViewRenderer::make(); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-error.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/standalone-error.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + + #[Test] + public function test_maps_source_path_and_line_for_undefined_variable_errors(): void + { + $renderer = TempestViewRenderer::make(); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-undefined-variable.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/standalone-undefined-variable.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + + #[Test] + public function test_maps_source_path_and_line_for_component_errors(): void + { + $viewConfig = new ViewConfig()->addViewComponents( + __DIR__ . '/Fixtures/x-standalone-error-component.view.php', + ); + + $renderer = + TempestViewRenderer::make( + viewConfig: $viewConfig, + ); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-error-component-usage.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/x-standalone-error-component.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + + #[Test] + public function test_maps_source_path_and_line_for_slot_content_errors(): void + { + $viewConfig = new ViewConfig()->addViewComponents( + __DIR__ . '/Fixtures/x-standalone-base.view.php', + ); + + $renderer = + TempestViewRenderer::make( + viewConfig: $viewConfig, + ); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-error-slot-usage.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/standalone-error-slot-usage.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + protected function assertSnippetsMatch(string $expected, string $actual): void { $expected = str_replace([PHP_EOL, ' '], '', $expected); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9858fc847..1d997881e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -110,3 +110,17 @@ parameters: path: tests/Integration/Database/GroupedWhereMethodsTest.php count: 1 # Intentional: assertion keeps explicit null-check semantics in grouped-where integration test + - + identifier: function.notFound + paths: + - packages/view/tests/Fixtures/standalone-error-slot-usage.view.php + - packages/view/tests/Fixtures/standalone-error.view.php + - packages/view/tests/Fixtures/x-standalone-error-component.view.php + - tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php + - tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php + # Intentional: these fixtures validate source-path stack traces for view compilation errors + - + identifier: variable.undefined + path: packages/view/tests/Fixtures/standalone-undefined-variable.view.php + count: 1 + # Intentional: this fixture validates undefined variable source mapping in view compilation errors diff --git a/src/Tempest/Framework/Testing/View/ViewTester.php b/src/Tempest/Framework/Testing/View/ViewTester.php index 246ba1b1d..6b383d90a 100644 --- a/src/Tempest/Framework/Testing/View/ViewTester.php +++ b/src/Tempest/Framework/Testing/View/ViewTester.php @@ -37,12 +37,12 @@ public function render(string|View $view, mixed ...$params): string /** * Registers a view component for testing purposes. */ - public function registerViewComponent(string $name, string $html, string $file = '', bool $isVendor = false): self + public function registerViewComponent(string $name, string $html, ?string $file = null, bool $isVendor = false): self { $viewComponent = new ViewComponent( name: $name, contents: $html, - file: $file, + file: $file ?? $name . '.view.php', isVendorComponent: $isVendor, ); diff --git a/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php b/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php index 4bf52ccad..0d731e18a 100644 --- a/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php +++ b/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php @@ -61,7 +61,6 @@ public function test_it_can_alter_a_table_definition(): void MigrationModel::get(new PrimaryKey(3))->name, ); - /** @var User $user */ $user = User::create( name: 'Test', email: 'test@example.com', diff --git a/tests/Integration/View/ElementFactoryTest.php b/tests/Integration/View/ElementFactoryTest.php index 529a2ca46..c9f1aa76a 100644 --- a/tests/Integration/View/ElementFactoryTest.php +++ b/tests/Integration/View/ElementFactoryTest.php @@ -7,6 +7,7 @@ use Tempest\View\Element; use Tempest\View\Elements\ElementFactory; use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\RootElement; use Tempest\View\Elements\TextElement; use Tempest\View\Elements\WhitespaceElement; use Tempest\View\Parser\TempestViewParser; @@ -38,11 +39,13 @@ public function test_parental_relations(): void $elementFactory = $this->container->get(ElementFactory::class); - $a = $elementFactory->make(iterator_to_array($ast)[0]); + $root = new RootElement(); + $elementFactory->make(iterator_to_array($ast)[0], $root); + $a = $root->getChildren()[0]; $this->assertInstanceOf(GenericElement::class, $a); $this->assertCount(1, $this->withoutWhitespace($a->getChildren())); - $this->assertNull($a->getParent()); + $this->assertInstanceOf(RootElement::class, $a->getParent()); $b = $this->withoutWhitespace($a->getChildren())[0]; $this->assertInstanceOf(GenericElement::class, $b); diff --git a/tests/Integration/View/Fixtures/missing-import-view.view.php b/tests/Integration/View/Fixtures/missing-import-view.view.php new file mode 100644 index 000000000..109de38cd --- /dev/null +++ b/tests/Integration/View/Fixtures/missing-import-view.view.php @@ -0,0 +1,3 @@ + + {{ uri(HomeController::class) }} + diff --git a/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php b/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php new file mode 100644 index 000000000..6ae9fd82f --- /dev/null +++ b/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php @@ -0,0 +1,4 @@ + + + diff --git a/tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php b/tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php new file mode 100644 index 000000000..39e68ade4 --- /dev/null +++ b/tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Integration/View/Fixtures/x-missing-import.view.php b/tests/Integration/View/Fixtures/x-missing-import.view.php new file mode 100644 index 000000000..d16798d96 --- /dev/null +++ b/tests/Integration/View/Fixtures/x-missing-import.view.php @@ -0,0 +1 @@ +
diff --git a/tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php b/tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php new file mode 100644 index 000000000..d0b4f2b05 --- /dev/null +++ b/tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index 6ef4dbeb3..5b1a47103 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -8,14 +8,22 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Tempest\Core\Environment; +use Tempest\Http\GenericRequest; +use Tempest\Http\Method; use Tempest\Http\Session\FormSession; +use Tempest\Router\Exceptions\DevelopmentException; +use Tempest\Router\Exceptions\HtmlExceptionRenderer; use Tempest\Validation\FailingRule; use Tempest\Validation\Rules\IsAlphaNumeric; use Tempest\Validation\Rules\IsBetween; use Tempest\Validation\Validator; use Tempest\View\Exceptions\DataAttributeWasInvalid; +use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\Exceptions\ViewVariableWasReserved; +use Tempest\View\GenericView; +use Tempest\View\Renderers\TempestViewRenderer; use Tempest\View\ViewCache; +use Tempest\View\ViewConfig; use Tests\Tempest\Fixtures\Views\Chapter; use Tests\Tempest\Fixtures\Views\DocsView; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -1023,9 +1031,187 @@ public function test_capture_outer_scope_view_component_variables(): void public function test_fallthrough_attribute_without_value(): void { - $this->view->registerViewComponent('x-test', '
hi
'); + $this->view->registerViewComponent('x-test', '
hi
'); $this->assertSnippetsMatch('', $this->view->render('')); $this->assertSnippetsMatch('
hi
', $this->view->render('')); } + + public function test_imports_in_slots_from_root_node(): void + { + $this->view->registerViewComponent('x-test', '
'); + + $html = $this->view->render(<<<'HTML' + + + {{ uri(HomeController::class) }} + HTML); + + $this->assertSame('
/
', $html); + } + + public function test_combined_imports_from_root_node_and_view_component(): void + { + $this->view->registerViewComponent('x-parent', <<<'HTML' +
+ HTML); + + $this->view->registerViewComponent('x-child', <<<'HTML' + +
+ HTML); + + $html = $this->view->render(<<<'HTML' + + + + {{ uri(HomeController::class) }} + + HTML); + + $this->assertSnippetsMatch('
/
', $html); + } + + public function test_exception_for_missing_imports(): void + { + $this->assertException( + ViewCompilationFailed::class, + function (): void { + $this->view->render(view(__DIR__ . '/Fixtures/missing-import-view.view.php')); + }, + function (ViewCompilationFailed $exception): void { + $this->assertStringContainsString('missing-import-view.view.php', $exception->getFile()); + $this->assertSame(2, $exception->getLine()); + }, + ); + } + + #[Test] + public function does_not_duplicate_view_compilation_frames_in_stacktrace(): void + { + $this->container->singleton(Environment::class, Environment::LOCAL); + $this->container->singleton(GenericRequest::class, new GenericRequest( + method: Method::GET, + uri: '/', + )); + + $viewRenderer = TempestViewRenderer::make(); + $exceptionRenderer = $this->container->get(HtmlExceptionRenderer::class); + + $this->assertException( + ViewCompilationFailed::class, + function () use ($viewRenderer): void { + $viewRenderer->render(view(__DIR__ . '/Fixtures/stacktrace-standalone-error.view.php')); + }, + function (ViewCompilationFailed $exception) use ($exceptionRenderer): void { + $response = $exceptionRenderer->render($exception); + + $this->assertInstanceOf(DevelopmentException::class, $response); + $this->assertInstanceOf(GenericView::class, $response->body); + + $hydration = json_decode($response->body->data['hydration'], associative: true, flags: JSON_THROW_ON_ERROR); + $stacktrace = json_decode($hydration['stacktrace'], associative: true, flags: JSON_THROW_ON_ERROR); + + $renderCompiledFrames = array_values(array_filter( + $stacktrace['applicationFrames'], + fn (array $frame): bool => ($frame['class'] ?? null) === TempestViewRenderer::class && ($frame['function'] ?? null) === 'renderCompiled', + )); + + $this->assertCount(1, $renderCompiledFrames); + }, + ); + } + + #[Test] + public function maps_component_source_line_in_view_compilation_stacktrace(): void + { + $this->container->singleton(Environment::class, Environment::LOCAL); + $this->container->singleton(GenericRequest::class, new GenericRequest( + method: Method::GET, + uri: '/', + )); + + $this->container->get(ViewConfig::class)->addViewComponents( + __DIR__ . '/Fixtures/x-stacktrace-error-component.view.php', + ); + + $exceptionRenderer = $this->container->get(HtmlExceptionRenderer::class); + + $this->assertException( + ViewCompilationFailed::class, + function (): void { + $this->view->render(view(__DIR__ . '/Fixtures/stacktrace-component-imported-usage.view.php')); + }, + function (ViewCompilationFailed $exception) use ($exceptionRenderer): void { + $response = $exceptionRenderer->render($exception); + + $this->assertInstanceOf(DevelopmentException::class, $response); + $this->assertInstanceOf(GenericView::class, $response->body); + + $hydration = json_decode($response->body->data['hydration'], associative: true, flags: JSON_THROW_ON_ERROR); + $stacktrace = json_decode($hydration['stacktrace'], associative: true, flags: JSON_THROW_ON_ERROR); + + $renderCompiledFrames = array_values(array_filter( + $stacktrace['applicationFrames'], + fn (array $frame): bool => ($frame['class'] ?? null) === TempestViewRenderer::class && ($frame['function'] ?? null) === 'renderCompiled', + )); + + $this->assertCount(1, $renderCompiledFrames); + $this->assertSame('tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php', $renderCompiledFrames[0]['relativeFile']); + $this->assertSame(2, $renderCompiledFrames[0]['line']); + }, + ); + } + + public function test_imports_with_nested_view_components(): void + { + $this->view->registerViewComponent('x-card', <<<'HTML' +
+ HTML); + + $this->view->registerViewComponent('x-footer', <<<'HTML' + + HTML); + + $html = $this->view->render(<<<'HTML' + + + {{ uri(HomeController::class) }} + + HTML); + + $this->assertSnippetsMatch('
/
', $html); + } + + public function test_imports_in_nested_html_elements(): void + { + $this->view->registerViewComponent('x-a', '
">'); + $this->view->registerViewComponent('x-b', '
">'); + + $html = $this->view->render(<<<'HTML' + + +
+ + {{ uri(HomeController::class) }} + +
+
+ HTML); + + $this->assertSnippetsMatch('
/
">
">', $html); + } }