diff --git a/phpstan.neon b/phpstan.neon index 7013f6993..5db9012da 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,4 +1,8 @@ parameters: + excludePaths: + analyse: + - src/Latte/Compiler/TagParser.php + level: 5 paths: diff --git a/src/Latte/Compiler/Block.php b/src/Latte/Compiler/Block.php index 6dc9a61cb..39995882d 100644 --- a/src/Latte/Compiler/Block.php +++ b/src/Latte/Compiler/Block.php @@ -26,6 +26,7 @@ final class Block /** @var ParameterNode[] */ public array $parameters = []; + public VariableScope $variables; public function __construct( diff --git a/src/Latte/Compiler/PrintContext.php b/src/Latte/Compiler/PrintContext.php index e204f72ae..e5001b762 100644 --- a/src/Latte/Compiler/PrintContext.php +++ b/src/Latte/Compiler/PrintContext.php @@ -77,10 +77,14 @@ final class PrintContext /** @var Escaper[] */ private array $escaperStack = []; + /** @var VariableScope[] */ + private array $scopeStack = []; + public function __construct(string $contentType = ContentType::Html) { $this->escaperStack[] = new Escaper($contentType); + $this->scopeStack[] = new VariableScope; } @@ -160,9 +164,28 @@ public function getEscaper(): Escaper } + public function beginVariableScope(): VariableScope + { + return $this->scopeStack[] = clone end($this->scopeStack); + } + + + public function restoreVariableScope(): void + { + array_pop($this->scopeStack); + } + + + public function getVariableScope(): VariableScope + { + return end($this->scopeStack); + } + + public function addBlock(Block $block): void { $block->escaping = $this->getEscaper()->export(); + $block->variables = clone $this->getVariableScope(); $block->method = 'block' . ucfirst(trim(preg_replace('#\W+#', '_', $block->name->print($this)), '_')); $lower = strtolower($block->method); $used = $this->blocks + ['block' => 1]; diff --git a/src/Latte/Compiler/TemplateGenerator.php b/src/Latte/Compiler/TemplateGenerator.php index 11a319fa6..c522437b3 100644 --- a/src/Latte/Compiler/TemplateGenerator.php +++ b/src/Latte/Compiler/TemplateGenerator.php @@ -11,6 +11,8 @@ use Latte; use Latte\ContentType; +use Latte\Essential\Blueprint; +use Nette\PhpGenerator as Php; /** @@ -38,20 +40,24 @@ public function generate( string $className, ?string $comment = null, bool $strictMode = false, + array $filters = [], ): string { $context = new PrintContext($node->contentType); - $code = $node->main->print($context); - $code = self::buildParams($code, [], '$ʟ_args', $context); - $this->addMethod('main', $code, 'array $ʟ_args'); + $scope = $context->getVariableScope(); + $this->addMethod('main', ''); $head = (new NodeTraverser)->traverse($node->head, fn(Node $node) => $node instanceof Nodes\TextNode ? new Nodes\NopNode : $node); $code = $head->print($context); if ($code || $context->paramsExtraction) { $code .= 'return get_defined_vars();'; - $code = self::buildParams($code, $context->paramsExtraction, '$this->params', $context); + $code = self::buildParams($code, $context->paramsExtraction, '$this->params', $context, $scope); $this->addMethod('prepare', $code, '', 'array'); } + $code = $node->main->print($context); + $code = self::buildParams($code, [], '$ʟ_args', $context, $context->getVariableScope()); + $this->addMethod('main', $code, 'array $ʟ_args'); + if ($node->contentType !== ContentType::Html) { $this->addConstant('ContentType', $node->contentType); } @@ -75,13 +81,18 @@ public function generate( . ($method['body'] ? "\t\t$method[body]\n" : '') . "\t}"; } + $comment .= "\n@property Filters$className \$filters"; + $comment = str_replace('*/', '* /', $comment); + $comment = str_replace("\n", "\n * ", "/**\n" . trim($comment)) . "\n */\n"; + $code = "generateStub($node, 'Filters' . $className, $filters); $code = PhpHelpers::optimizeEcho($code); $code = PhpHelpers::reformatCode($code); @@ -100,7 +111,7 @@ private function generateBlocks(array $blocks, PrintContext $context): void : [$block->method, $block->escaping]; } - $body = $this->buildParams($block->content, $block->parameters, '$ʟ_args', $context); + $body = self::buildParams($block->content, $block->parameters, '$ʟ_args', $context, $block->variables); if (!$block->isDynamic() && str_contains($body, '$')) { $embedded = $block->tag->name === 'block' && is_int($block->layer) && $block->layer; $body = 'extract(' . ($embedded ? 'end($this->varStack)' : '$this->params') . ');' . $body; @@ -121,8 +132,49 @@ private function generateBlocks(array $blocks, PrintContext $context): void } - private function buildParams(string $body, array $params, string $cont, PrintContext $context): string + private function generateStub(Node $node, string $className, $filters): string { + if (!class_exists(Php\ClassType::class)) { + return ''; + } + + $used = []; + (new NodeTraverser)->traverse($node, function (Node $node) use (&$used) { + if ($node instanceof Nodes\Php\FilterNode) { + $used[$node->name->name] = true; + } + }); + + $class = new Php\ClassType($className); + $filters = array_intersect_key($filters, $used); + foreach ($filters as $name => $callback) { + $func = (new Php\Factory)->fromCallable($callback); + $type = Blueprint::printType($func->getReturnType(), $func->isReturnNullable(), null) ?: 'mixed'; + $params = []; + $list = $func->getParameters(); + foreach ($list as $param) { + $variadic = $func->isVariadic() && $param === end($list); + $params[] = (Blueprint::printType($param->getType(), $param->isNullable(), null) ?: 'mixed') + . ($variadic ? '...' : ''); + } + + $class->addComment('@property callable(' . implode(', ', $params) . "): $type \$$name"); + } + + return (string) $class; + } + + + /** + * @param Nodes\Php\ParameterNode[] $params + */ + private static function buildParams( + string $body, + array $params, + string $cont, + PrintContext $context, + VariableScope $scope, + ): string { if (!str_contains($body, '$') && !str_contains($body, 'get_defined_vars()')) { return $body; } @@ -130,7 +182,8 @@ private function buildParams(string $body, array $params, string $cont, PrintCon $res = []; foreach ($params as $i => $param) { $res[] = $context->format( - '%node = %raw[%dump] ?? %raw[%dump] ?? %node;', + '%raw%node = %raw[%dump] ?? %raw[%dump] ?? %node;', + $param->type ? VariableScope::printComment($param->var->name, $param->type->type) . ' ' : '', $param->var, $cont, $i, @@ -143,7 +196,10 @@ private function buildParams(string $body, array $params, string $cont, PrintCon $extract = $params ? implode('', $res) . 'unset($ʟ_args);' : "extract($cont);" . (str_contains($cont, '$this') ? '' : "unset($cont);"); - return $extract . "\n\n" . $body; + + return $extract . "\n" + . $scope->extractTypes() . "\n\n" + . $body; } diff --git a/src/Latte/Compiler/VariableScope.php b/src/Latte/Compiler/VariableScope.php new file mode 100644 index 000000000..0d77ec94c --- /dev/null +++ b/src/Latte/Compiler/VariableScope.php @@ -0,0 +1,51 @@ +types[$name] = $this->printComment($name, $type); + } + + + public function addExpression(Nodes\Php\ExpressionNode $expr, ?Nodes\Php\SuperiorTypeNode $type): string + { + return $expr instanceof Nodes\Php\Expression\VariableNode && is_string($expr->name) + ? $this->addVariable($expr->name, $type?->type) + : ''; + } + + + public static function printComment(string $name, ?string $type): string + { + if (!$type) { + return ''; + } + $str = '@var ' . $type . ' $' . $name; + return '/** ' . str_replace('*/', '* /', $str) . ' */'; + } + + + public function extractTypes(): string + { + return implode('', $this->types) . "\n"; + } +} diff --git a/src/Latte/Engine.php b/src/Latte/Engine.php index af8621efd..f04b43132 100644 --- a/src/Latte/Engine.php +++ b/src/Latte/Engine.php @@ -191,6 +191,7 @@ public function generate(TemplateNode $node, string $name): string $this->getTemplateClass($name), $comment, $this->strictTypes, + $this->getFilters(), ); } diff --git a/src/Latte/Essential/Blueprint.php b/src/Latte/Essential/Blueprint.php index 51622e7a0..19914417b 100644 --- a/src/Latte/Essential/Blueprint.php +++ b/src/Latte/Essential/Blueprint.php @@ -95,7 +95,7 @@ public function addFunctions(Php\ClassType $class, array $funcs): void } - private function printType(?string $type, bool $nullable, ?Php\PhpNamespace $namespace): string + public static function printType(?string $type, bool $nullable, ?Php\PhpNamespace $namespace): string { if ($type === null) { return ''; @@ -123,7 +123,7 @@ public function printParameters( $list = $function->getParameters(); foreach ($list as $param) { $variadic = $function->isVariadic() && $param === end($list); - $params[] = ltrim($this->printType($param->getType(), $param->isNullable(), $namespace) . ' ') + $params[] = ltrim(self::printType($param->getType(), $param->isNullable(), $namespace) . ' ') . ($param->isReference() ? '&' : '') . ($variadic ? '...' : '') . '$' . $param->getName() diff --git a/src/Latte/Essential/Nodes/BlockNode.php b/src/Latte/Essential/Nodes/BlockNode.php index d1a982cc3..acfea4c43 100644 --- a/src/Latte/Essential/Nodes/BlockNode.php +++ b/src/Latte/Essential/Nodes/BlockNode.php @@ -74,14 +74,20 @@ public static function create(Tag $tag, TemplateParser $parser): \Generator public function print(PrintContext $context): string { - if (!$this->block) { - return $this->printFilter($context); + $context->beginVariableScope(); + try { + if (!$this->block) { + return $this->printFilter($context); - } elseif ($this->block->isDynamic()) { - return $this->printDynamic($context); - } + } elseif ($this->block->isDynamic()) { + return $this->printDynamic($context); - return $this->printStatic($context); + } else { + return $this->printStatic($context); + } + } finally { + $context->restoreVariableScope(); + } } @@ -91,7 +97,9 @@ private function printFilter(PrintContext $context): string <<<'XX' ob_start(fn() => '') %line; try { - (function () { extract(func_get_arg(0)); + (function () { + extract(func_get_arg(0)); + %raw %node })(get_defined_vars()); } finally { @@ -101,6 +109,7 @@ private function printFilter(PrintContext $context): string XX, $this->position, + $context->getVariableScope()->extractTypes(), $this->content, $context->getEscaper()->export(), $this->modifier, diff --git a/src/Latte/Essential/Nodes/ForeachNode.php b/src/Latte/Essential/Nodes/ForeachNode.php index 338a89646..b3c3f888e 100644 --- a/src/Latte/Essential/Nodes/ForeachNode.php +++ b/src/Latte/Essential/Nodes/ForeachNode.php @@ -88,6 +88,12 @@ private static function parseArguments(TagParser $parser, self $node): void public function print(PrintContext $context): string { + $scope = $context->getVariableScope(); + if ($this->key) { + $scope->addExpression($this->key, null); + } + $scope->addExpression($this->value, null); + $content = $this->content->print($context); $iterator = $this->else || ($this->iterator ?? preg_match('#\$iterator\W|\Wget_defined_vars\W#', $content)); diff --git a/src/Latte/Essential/Nodes/TemplateTypeNode.php b/src/Latte/Essential/Nodes/TemplateTypeNode.php index aa3fb4678..03298864b 100644 --- a/src/Latte/Essential/Nodes/TemplateTypeNode.php +++ b/src/Latte/Essential/Nodes/TemplateTypeNode.php @@ -13,6 +13,7 @@ use Latte\Compiler\Nodes\StatementNode; use Latte\Compiler\PrintContext; use Latte\Compiler\Tag; +use Latte\Compiler\Token; /** @@ -20,19 +21,42 @@ */ class TemplateTypeNode extends StatementNode { + public string $class; + + public static function create(Tag $tag): static { if (!$tag->isInHead()) { throw new CompileException('{templateType} is allowed only in template header.', $tag->position); } $tag->expectArguments('class name'); - $tag->parser->parseExpression(); - return new static; + $token = $tag->parser->stream->consume(Token::Php_Identifier, Token::Php_NameQualified, Token::Php_NameFullyQualified); + if (!class_exists($token->text)) { + throw new CompileException("Class '$token->text' used in {templateType} doesn't exist.", $token->position); + } + + $node = new static; + $node->class = $token->text; + return $node; } public function print(PrintContext $context): string { + $scope = $context->getVariableScope(); + $rc = new \ReflectionClass($this->class); + foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + $type = $this->parseAnnotation($property->getDocComment() ?: '') ?: (string) $property->getType(); + $scope->addVariable($property->getName(), $type); + } + return ''; } + + + private function parseAnnotation(string $comment): ?string + { + $comment = trim($comment, '/*'); + return preg_match('#@var ([^$]+)#', $comment, $m) ? trim($m[1]) : null; + } } diff --git a/src/Latte/Essential/Nodes/VarNode.php b/src/Latte/Essential/Nodes/VarNode.php index c3eea61f3..8b7561a37 100644 --- a/src/Latte/Essential/Nodes/VarNode.php +++ b/src/Latte/Essential/Nodes/VarNode.php @@ -13,6 +13,7 @@ use Latte\Compiler\Nodes\Php\Expression\VariableNode; use Latte\Compiler\Nodes\Php\ExpressionNode; use Latte\Compiler\Nodes\Php\Scalar\NullNode; +use Latte\Compiler\Nodes\Php\SuperiorTypeNode; use Latte\Compiler\Nodes\StatementNode; use Latte\Compiler\PrintContext; use Latte\Compiler\Tag; @@ -27,7 +28,7 @@ class VarNode extends StatementNode { public bool $default; - /** @var AssignNode[] */ + /** @var array{AssignNode, ?SuperiorTypeNode}[] */ public array $assignments = []; @@ -46,14 +47,14 @@ private static function parseAssignments(Tag $tag, bool $default): array $stream = $tag->parser->stream; $res = []; do { - $tag->parser->parseType(); + $type = $tag->parser->parseType(); $save = $stream->getIndex(); $expr = $stream->is(Token::Php_Variable) ? $tag->parser->parseExpression() : null; if ($expr instanceof VariableNode) { - $res[] = new AssignNode($expr, new NullNode); + $res[] = [new AssignNode($expr, new NullNode), $type]; } elseif ($expr instanceof AssignNode && (!$default || $expr->var instanceof VariableNode)) { - $res[] = $expr; + $res[] = [$expr, $type]; } else { $stream->seek($save); $stream->throwUnexpectedException(addendum: ' in ' . $tag->getNotation()); @@ -66,27 +67,29 @@ private static function parseAssignments(Tag $tag, bool $default): array public function print(PrintContext $context): string { - $res = []; + $scope = $context->getVariableScope(); + $res = $types = []; + if ($this->default) { - foreach ($this->assignments as $assign) { - assert($assign->var instanceof VariableNode); - if ($assign->var->name instanceof ExpressionNode) { - $var = $assign->var->name->print($context); - } else { - $var = $context->encodeString($assign->var->name); - } + foreach ($this->assignments as [$assign, $type]) { + $var = $assign->var->name instanceof ExpressionNode + ? $assign->var->name->print($context) + : $context->encodeString($assign->var->name); $res[] = $var . ' => ' . $assign->expr->print($context); + $types[] = $scope->addExpression($var, $type); } return $context->format( - 'extract([%raw], EXTR_SKIP) %line;', + 'extract([%raw], EXTR_SKIP) %line;%raw ', implode(', ', $res), $this->position, + implode('', $types), ); } - foreach ($this->assignments as $assign) { - $res[] = $assign->print($context); + foreach ($this->assignments as [$assign, $type]) { + $comment = $scope->addExpression($assign->var, $type); + $res[] = $comment . $assign->print($context); } return $context->format( @@ -99,8 +102,11 @@ public function print(PrintContext $context): string public function &getIterator(): \Generator { - foreach ($this->assignments as &$assign) { + foreach ($this->assignments as [&$assign, &$type]) { yield $assign; + if ($type) { + yield $type; + } } } } diff --git a/src/Latte/Essential/Nodes/VarTypeNode.php b/src/Latte/Essential/Nodes/VarTypeNode.php index 2585a513b..dde40d006 100644 --- a/src/Latte/Essential/Nodes/VarTypeNode.php +++ b/src/Latte/Essential/Nodes/VarTypeNode.php @@ -9,6 +9,8 @@ namespace Latte\Essential\Nodes; +use Latte\Compiler\Nodes\Php\Expression\VariableNode; +use Latte\Compiler\Nodes\Php\SuperiorTypeNode; use Latte\Compiler\Nodes\StatementNode; use Latte\Compiler\PrintContext; use Latte\Compiler\Tag; @@ -20,17 +22,36 @@ */ class VarTypeNode extends StatementNode { + public VariableNode $variable; + public SuperiorTypeNode $type; + + public static function create(Tag $tag): static { $tag->expectArguments(); - $tag->parser->parseType(); - $tag->parser->stream->consume(Token::Php_Variable); - return new static; + $type = $tag->parser->parseType(); + if (!$type) { + $tag->parser->stream->throwUnexpectedException(); + } + $token = $tag->parser->stream->consume(Token::Php_Variable); + + $node = new static; + $node->type = $type; + $node->variable = new VariableNode(substr($token->text, 1)); + return $node; } public function print(PrintContext $context): string { - return ''; + $scope = $context->getVariableScope(); + return $scope->addExpression($this->variable, $this->type) . "\n"; + } + + + public function &getIterator(): \Generator + { + yield $this->variable; + yield $this->type; } } diff --git a/tests/tags/templateType.phpt b/tests/tags/templateType.phpt index 847cb8e79..5b7789ab3 100644 --- a/tests/tags/templateType.phpt +++ b/tests/tags/templateType.phpt @@ -11,6 +11,17 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; +class TemplateClass +{ + public $noType; + public int $intType; + public int|bool $intBoolType; + /** @var array */ + public array $arrayType; + private int $private; +} + + $latte = new Latte\Engine; $latte->setLoader(new Latte\Loaders\StringLoader); @@ -20,10 +31,19 @@ Assert::exception( 'Missing class name in {templateType} (at column 1)', ); +Assert::exception( + fn() => $latte->compile('{templateType AA\BBB}'), + Latte\CompileException::class, + "Class 'AA\BBB' used in {templateType} doesn't exist (at column 15)", +); + Assert::exception( fn() => $latte->compile('{if true}{templateType stdClass}{/if}'), Latte\CompileException::class, '{templateType} is allowed only in template header (at column 10)', ); -Assert::noError(fn() => $latte->compile('{templateType stdClass}')); +Assert::contains( + '/** @var int $intType *//** @var int|bool $intBoolType *//** @var array $arrayType */', + $latte->compile('{templateType TemplateClass}'), +); diff --git a/tests/tags/var.default.phpt b/tests/tags/var.default.phpt index 627dd6392..648813f01 100644 --- a/tests/tags/var.default.phpt +++ b/tests/tags/var.default.phpt @@ -25,7 +25,7 @@ test('{var ...}', function () use ($latte) { // types Assert::contains('$temp->var1 = 123 /*', $latte->compile('{var int $temp->var1 = 123}')); Assert::contains('$temp->var1 = 123 /*', $latte->compile('{var null|int|string[] $temp->var1 = 123}')); - Assert::contains('$var1 = 123; $var2 = \'nette framework\' /*', ws($latte->compile('{var int|string[] $var1 = 123, ?class $var2 = "nette framework"}'))); + Assert::contains('/** @var int|string[] $var1 */ $var1 = 123; /** @var ?class $var2 */ $var2 = \'nette framework\' /* line 1 */;', ws($latte->compile('{var int|string[] $var1 = 123, ?class $var2 = "nette framework"}'))); Assert::contains('$var1 = 123; $var2 = 456 /*', ws($latte->compile('{var A\B $var1 = 123, ?A\B $var2 = 456}'))); Assert::contains('$var1 = 123; $var2 = 456 /*', ws($latte->compile('{var \A\B $var1 = 123, ?\A\B $var2 = 456}'))); diff --git a/tests/tags/varType.phpt b/tests/tags/varType.phpt index a3306d9c2..8c32741cc 100644 --- a/tests/tags/varType.phpt +++ b/tests/tags/varType.phpt @@ -35,13 +35,25 @@ Assert::exception( Assert::exception( fn() => $latte->compile('{varType $var type}'), Latte\CompileException::class, - "Unexpected 'type', expecting end of tag in {varType} (at column 15)", + "Unexpected '\$vartype' (at column 10)", ); -Assert::noError(fn() => $latte->compile('{varType type $var}')); +Assert::contains( + '/** @var type $var */', + $latte->compile('{varType type $var}'), +); -Assert::noError(fn() => $latte->compile('{varType ?\Nm\Class $var}')); +Assert::contains( + '/** @var ?\Nm\Class $var */', + $latte->compile('{varType ?\Nm\Class $var}'), +); -Assert::noError(fn() => $latte->compile('{varType int|null $var}')); +Assert::contains( + '/** @var int|null $var */', + $latte->compile('{varType int|null $var}'), +); -Assert::noError(fn() => $latte->compile('{varType array{0: int, 1: int} $var}')); +Assert::contains( + '/** @var array{0:int,1:int} $var */', + $latte->compile('{varType array{0: int, 1: int} $var}'), +);