diff --git a/.gitignore b/.gitignore index f74e5cd..d3391cd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ phpunit.xml psalm.xml vendor .php-cs-fixer.cache - diff --git a/composer.json b/composer.json index 7ffa38e..aefeb2f 100644 --- a/composer.json +++ b/composer.json @@ -16,17 +16,20 @@ ], "require": { "php": "^8.1", - "symfony/yaml": "^6.0 || ^7.0" + "ext-mbstring": "*" }, "require-dev": { "laravel/pint": "^1.2", - "pestphp/pest": "^2.6", - "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest": "^2.7", + "pestphp/pest-plugin-arch": "^2.2", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.10", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10.57", + "phpstan/phpstan-deprecation-rules": "^1.1", "spatie/invade": "^2.0", "spatie/ray": "^1.28", - "symfony/console": "^6.3 || ^7.0" + "symfony/console": "^6.3 || ^7.0", + "symfony/yaml": "^6.0 || ^7.0" }, "suggest": { "ext-yaml": "Used to parse YAML files with better performance than symfony/yaml" @@ -47,7 +50,8 @@ "test-coverage": "vendor/bin/pest --coverage", "format": "vendor/bin/pint", "lint": "vendor/bin/phpstan analyse", - "benchmark": "vendor/bin/phpbench run --report=aggregate" + "benchmark": "vendor/bin/phpbench run --report=aggregate", + "profile": "vendor/bin/phpbench xdebug:profile" }, "config": { "sort-packages": true, diff --git a/locales/en.yml b/locales/en.yml deleted file mode 100644 index 4ccf32c..0000000 --- a/locales/en.yml +++ /dev/null @@ -1,33 +0,0 @@ ---- -errors: - syntax: - tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}" - assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" - capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" - case: "Syntax Error in 'case' - Valid syntax: case [condition]" - case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" - case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " - cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]" - for: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]" - for_invalid_in: "For loops require an 'in' clause" - for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset" - if: "Syntax Error in tag 'if' - Valid syntax: if [expression]" - include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]" - inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character" - invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" - render: "Syntax error in tag 'render' - Template name must be a quoted string" - table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" - tag_never_closed: "'%{block_name}' tag was never closed" - tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{regexp}" - unexpected_else: "%{block_name} tag does not expect 'else' tag" - unexpected_outer_tag: "Unexpected outer '%{tag}' tag" - unknown_tag: "Unknown tag '%{tag}'" - variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{regexp}" - argument: - include: "Argument error in tag 'include' - Illegal template name" - disabled: - tag: "%{tag} usage is not allowed in this context" - stack: - nesting_too_deep: "Nesting too deep" - runtime: - partial_not_loaded: "The partial '%{partial}' has not be loaded during parsing" diff --git a/performance/CompiledThemeTestTemplate.php b/performance/CompiledThemeTestTemplate.php index 645016f..d24284b 100644 --- a/performance/CompiledThemeTestTemplate.php +++ b/performance/CompiledThemeTestTemplate.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\Performance; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplateFactory; @@ -33,7 +33,7 @@ public function render(array $assigns = []): void } } - protected function buildContext(array $assigns = []): Context + protected function buildContext(array $assigns = []): RenderContext { return $this->factory->newRenderContext( staticEnvironment: $assigns, diff --git a/performance/Shopify/CommentFormTag.php b/performance/Shopify/CommentFormTag.php index 1004f4f..062339d 100644 --- a/performance/Shopify/CommentFormTag.php +++ b/performance/Shopify/CommentFormTag.php @@ -3,45 +3,49 @@ namespace Keepsuit\Liquid\Performance\Shopify; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TagBlock; class CommentFormTag extends TagBlock { - protected const Syntax = '/('.Regex::VariableSignature.'+)/'; - protected string $variableName; protected array $attributes; + protected BodyNode $body; + public static function tagName(): string { return 'form'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + assert($context->body !== null); + $this->body = $context->body; + + $variableName = $context->params->expression(); + $this->variableName = match (true) { + $variableName instanceof VariableLookup, is_string($variableName) => (string) $variableName, + default => throw new SyntaxException('Invalid variable name'), + }; + + $this->attributes = []; - if (preg_match(self::Syntax, $this->markup, $matches)) { - $this->variableName = $matches[1]; - $this->attributes = []; - } else { - throw new SyntaxException("Syntax Error in 'comment_form' - Valid syntax: comment_form [article]"); - } + $context->params->assertEnd(); return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $article = $context->get($this->variableName); assert(is_array($article)); - $context->stack(function (Context $context) { + $context->stack(function (RenderContext $context) { $context->set('form', [ 'posted_successfully?' => $context->getRegister('posted_successfully'), 'errors' => $context->get('comment.errors'), @@ -51,7 +55,7 @@ public function render(Context $context): string ]); }); - return $this->wrapInForm($article, parent::render($context)); + return $this->wrapInForm($article, $this->body->render($context)); } protected function wrapInForm(array $article, string $input): string diff --git a/performance/Shopify/CustomFilters.php b/performance/Shopify/CustomFilters.php index 3418eb4..9fd15b7 100644 --- a/performance/Shopify/CustomFilters.php +++ b/performance/Shopify/CustomFilters.php @@ -168,7 +168,7 @@ public function urlForType(string $type): string public function productImgUrl(string $url, string $style = 'small'): string { - if (! preg_match('/\Aproducts\/([\w\-_]+)\.(\w{2,4})/', $url, $matches)) { + if (preg_match('/\Aproducts\/([\w\-_]+)\.(\w{2,4})/', $url, $matches) === 0) { throw new InvalidArgumentException('filter "size" can only be called on product images'); } @@ -215,7 +215,7 @@ protected function toHandle(string $input): string $result = $input; $result = strtolower($result); $result = str_replace(['\'', '"', '()', '[]'], '', $result); - $result = preg_replace('/\W+/', '-', $result) ?? ''; + $result = preg_replace('/\W+/', '-', $result) ?? $result; return trim($result, '-'); } diff --git a/performance/Shopify/Database.php b/performance/Shopify/Database.php index 5f9ba9f..d84ef6a 100644 --- a/performance/Shopify/Database.php +++ b/performance/Shopify/Database.php @@ -3,7 +3,7 @@ namespace Keepsuit\Liquid\Performance\Shopify; use Keepsuit\Liquid\Support\Arr; -use Keepsuit\Liquid\Support\YamlParser; +use Symfony\Component\Yaml\Yaml; class Database { @@ -17,7 +17,7 @@ public static function tables(): array return static::$tables; } - $database = YamlParser::parseFile(static::DATABASE_FILE_PATH); + $database = (array) Yaml::parseFile(static::DATABASE_FILE_PATH); foreach ($database['products'] as $product) { $collections = array_filter( diff --git a/performance/Shopify/PaginateTag.php b/performance/Shopify/PaginateTag.php index a7b1be5..236260d 100644 --- a/performance/Shopify/PaginateTag.php +++ b/performance/Shopify/PaginateTag.php @@ -4,51 +4,56 @@ use Keepsuit\Liquid\Exceptions\InvalidArgumentException; use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Range; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Parse\TokenType; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TagBlock; class PaginateTag extends TagBlock { - protected const Syntax = '/('.Regex::QuotedFragment.')\s*(by\s*(\d+))?/'; + protected VariableLookup|string $collectionName; - protected string $collectionName; - - protected int $pageSize; + protected int $pageSize = 20; protected array $attributes; + protected BodyNode $body; + public static function tagName(): string { return 'paginate'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + assert($context->body !== null); + $this->body = $context->body; - if (preg_match(self::Syntax, $this->markup, $matches)) { - $this->collectionName = $matches[1]; - $this->pageSize = isset($matches[2]) ? (int) $matches[3] : 20; - } else { - throw new SyntaxException("Syntax Error in tag 'paginate' - Valid syntax: paginate [collection] by number"); + $collectionName = $context->params->expression(); + $this->collectionName = match (true) { + $collectionName instanceof VariableLookup, is_string($collectionName) => (string) $collectionName, + default => throw new SyntaxException('Invalid collection name'), + }; + + if ($context->params->idOrFalse('by')) { + $this->pageSize = (int) $context->params->consume(TokenType::Number)->data; } - $this->attributes = ['window_size' => 3]; - preg_match_all(sprintf('/%s/', Regex::TagAttributes), $this->markup, $attributeMatches, PREG_SET_ORDER); - foreach ($attributeMatches as $matches) { - $this->attributes[$matches[1]] = $this->parseExpression($parseContext, $matches[2]); + if (! $context->params->isEnd()) { + dd('paginate', $context->params->current()); } + $context->params->assertEnd(); + return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { - return $context->stack(function (Context $context) { + return $context->stack(function (RenderContext $context) { $currentPage = $context->get('current_page'); $collection = $context->get($this->collectionName); @@ -90,7 +95,7 @@ public function render(Context $context): string $context->set('paginate', $pagination); - return parent::render($context); + return $this->body->render($context); }); } diff --git a/performance/ThemeRunner.php b/performance/ThemeRunner.php index 3a216f8..bd457f6 100644 --- a/performance/ThemeRunner.php +++ b/performance/ThemeRunner.php @@ -21,7 +21,11 @@ class ThemeRunner public function __construct( protected TemplateFactory $templateFactory ) { - $files = glob(__DIR__.'/tests/**/*.liquid') ?: []; + $files = glob(__DIR__.'/tests/**/*.liquid'); + + if ($files === false) { + throw new \RuntimeException('Could not find any tests'); + } $this->tests = Arr::compact(Arr::map($files, function (string $path) { if (basename($path) === 'theme.liquid') { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 00cf27b..0ea1f8c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,25 +5,35 @@ parameters: count: 3 path: performance/Shopify/CustomFilters.php + - + message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" + count: 1 + path: performance/Shopify/Database.php + - message: "#^Cannot access offset 'articles' on mixed\\.$#" count: 1 path: performance/Shopify/Database.php - - message: "#^Strict comparison using \\!\\=\\= between null and null will always evaluate to false\\.$#" + message: "#^Cannot access offset 'collections' on mixed\\.$#" count: 1 - path: src/Profiler/Profiler.php + path: performance/Shopify/Database.php - - message: "#^Access to protected property Spatie\\\\Invade\\\\Invader\\\\:\\:\\$scopes\\.$#" - count: 2 - path: tests/Stubs/ContextDrop.php + message: "#^Cannot access offset 'id' on mixed\\.$#" + count: 1 + path: performance/Shopify/Database.php - - message: "#^Method Keepsuit\\\\Liquid\\\\Tests\\\\Stubs\\\\ContextDrop\\:\\:loopPos\\(\\) should return int\\|null but returns mixed\\.$#" + message: "#^Parameter \\#1 \\$array of static method Keepsuit\\\\Liquid\\\\Support\\\\Arr\\:\\:first\\(\\) expects array, mixed given\\.$#" count: 1 - path: tests/Stubs/ContextDrop.php + path: performance/Shopify/Database.php + + - + message: "#^Strict comparison using \\!\\=\\= between null and null will always evaluate to false\\.$#" + count: 1 + path: src/Profiler/Profiler.php - message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" diff --git a/phpstan.neon b/phpstan.neon index 8ee4faf..5851ea5 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -17,3 +17,4 @@ parameters: disableCheckMissingIterableValueType: false ignoreErrors: + - '#Method .+ should return .+ but returns mixed#' diff --git a/src/Concerns/ContextAware.php b/src/Concerns/ContextAware.php index f26217e..b828916 100644 --- a/src/Concerns/ContextAware.php +++ b/src/Concerns/ContextAware.php @@ -2,13 +2,13 @@ namespace Keepsuit\Liquid\Concerns; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; trait ContextAware { - protected Context $context; + protected RenderContext $context; - public function setContext(Context $context): void + public function setContext(RenderContext $context): void { $this->context = $context; } diff --git a/src/Condition/Condition.php b/src/Condition/Condition.php index adbe960..1f59635 100644 --- a/src/Condition/Condition.php +++ b/src/Condition/Condition.php @@ -3,8 +3,8 @@ namespace Keepsuit\Liquid\Condition; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; -use Keepsuit\Liquid\Nodes\BlockBodySection; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; class Condition implements HasParseTreeVisitorChildren @@ -18,7 +18,7 @@ class Condition implements HasParseTreeVisitorChildren protected ?Condition $childCondition = null; - public ?BlockBodySection $attachment = null; + public ?BodyNode $body = null; public function __construct( protected mixed $left = null, @@ -58,9 +58,9 @@ public function or(Condition $childCondition): Condition return $childCondition; } - public function attach(?BlockBodySection $attachment): Condition + public function body(?BodyNode $body): Condition { - $this->attachment = $attachment; + $this->body = $body; return $this; } @@ -70,7 +70,7 @@ public function else(): bool return false; } - public function evaluate(Context $context): bool + public function evaluate(RenderContext $context): bool { $result = $this->interpretCondition($this->left, $this->right, $this->operator, $context); @@ -91,11 +91,11 @@ public function parseTreeVisitorChildren(): array $this->left, $this->right, $this->childCondition, - $this->attachment, + $this->body, ]); } - protected function interpretCondition(mixed $left, mixed $right, ?string $operator, Context $context): bool + protected function interpretCondition(mixed $left, mixed $right, ?string $operator, RenderContext $context): bool { if ($operator === null) { $result = $context->evaluate($left); diff --git a/src/Condition/ElseCondition.php b/src/Condition/ElseCondition.php index ff51314..07bb3aa 100644 --- a/src/Condition/ElseCondition.php +++ b/src/Condition/ElseCondition.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\Condition; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; class ElseCondition extends Condition { @@ -16,7 +16,7 @@ public function else(): bool return true; } - public function evaluate(Context $context): bool + public function evaluate(RenderContext $context): bool { return true; } diff --git a/src/Contracts/CanBeEvaluated.php b/src/Contracts/CanBeEvaluated.php index e37beb1..3130385 100644 --- a/src/Contracts/CanBeEvaluated.php +++ b/src/Contracts/CanBeEvaluated.php @@ -2,9 +2,9 @@ namespace Keepsuit\Liquid\Contracts; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; interface CanBeEvaluated { - public function evaluate(Context $context): mixed; + public function evaluate(RenderContext $context): mixed; } diff --git a/src/Contracts/CanBeRendered.php b/src/Contracts/CanBeRendered.php index aa8da1f..dddb4c0 100644 --- a/src/Contracts/CanBeRendered.php +++ b/src/Contracts/CanBeRendered.php @@ -2,9 +2,9 @@ namespace Keepsuit\Liquid\Contracts; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; interface CanBeRendered { - public function render(Context $context): string; + public function render(RenderContext $context): string; } diff --git a/src/Contracts/IsContextAware.php b/src/Contracts/IsContextAware.php index 8134042..664789d 100644 --- a/src/Contracts/IsContextAware.php +++ b/src/Contracts/IsContextAware.php @@ -2,9 +2,9 @@ namespace Keepsuit\Liquid\Contracts; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; interface IsContextAware { - public function setContext(Context $context): void; + public function setContext(RenderContext $context): void; } diff --git a/src/Exceptions/StackLevelException.php b/src/Exceptions/StackLevelException.php index f1a396d..a084c22 100644 --- a/src/Exceptions/StackLevelException.php +++ b/src/Exceptions/StackLevelException.php @@ -4,4 +4,8 @@ class StackLevelException extends LiquidException { + public static function nestingTooDeep(): StackLevelException + { + return new self('Nesting too deep'); + } } diff --git a/src/Exceptions/SyntaxException.php b/src/Exceptions/SyntaxException.php index f68dd58..d497c9d 100644 --- a/src/Exceptions/SyntaxException.php +++ b/src/Exceptions/SyntaxException.php @@ -2,58 +2,34 @@ namespace Keepsuit\Liquid\Exceptions; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; +use Keepsuit\Liquid\Parse\LexerOptions; +use Keepsuit\Liquid\Parse\Token; use Keepsuit\Liquid\Parse\TokenType; class SyntaxException extends LiquidException { public ?string $tagName = null; - public static function missingTagTerminator(string $token, ParseContext $parseContext): self + public static function missingTagTerminator(): self { - return new SyntaxException($parseContext->locale->translate('errors.syntax.tag_termination', [ - 'token' => $token, - 'regexp' => stripslashes(Regex::TagEnd), - ])); + return new SyntaxException(sprintf('Tag was not properly terminated with: %s', LexerOptions::TagBlockEnd->value)); } - public static function tagNeverClosed(?string $tagName, ParseContext $parseContext): SyntaxException + public static function tagBlockNeverClosed(?string $tagName): SyntaxException { - return new SyntaxException($parseContext->locale->translate('errors.syntax.tag_never_closed', [ - 'block_name' => $tagName, - ])); + return new SyntaxException(sprintf("'%s' tag was never closed", $tagName)); } - public static function missingVariableTerminator(string $token, ParseContext $parseContext): SyntaxException + public static function missingVariableTerminator(): SyntaxException { - return new SyntaxException($parseContext->locale->translate('errors.syntax.variable_termination', [ - 'token' => $token, - 'regexp' => stripcslashes(Regex::VariableEnd), - ])); + return new SyntaxException(sprintf('Variable was not properly terminated with: %s', LexerOptions::TagVariableEnd->value)); } - public static function unexpectedOuterTag(ParseContext $parseContext, string $tagName): SyntaxException - { - return new SyntaxException($parseContext->locale->translate('errors.syntax.unexpected_outer_tag', [ - 'tag' => $tagName, - ])); - } - - public static function unknownTag(ParseContext $parseContext, string $tagName, string $blockTagName, ?string $blockDelimiter = null): SyntaxException + public static function unknownTag(string $tagName, ?string $blockTagName = null): SyntaxException { $exception = match (true) { - $tagName === 'else' => new SyntaxException($parseContext->locale->translate('errors.syntax.unexpected_else', [ - 'block_name' => $blockTagName, - ])), - str_starts_with($tagName, 'end') && $blockTagName !== '' => new SyntaxException($parseContext->locale->translate('errors.syntax.invalid_delimiter', [ - 'tag' => $tagName, - 'block_name' => $blockTagName, - 'block_delimiter' => $blockDelimiter ?? 'end'.$blockTagName, - ])), - default => new SyntaxException($parseContext->locale->translate('errors.syntax.unknown_tag', [ - 'tag' => $tagName, - ])), + $blockTagName !== null && str_starts_with($tagName, 'end') => new SyntaxException(sprintf("'%s' is not a valid delimiter for %s tag. use end%s", $tagName, $blockTagName, $blockTagName)), + default => new SyntaxException(sprintf("Unknown tag '%s'", $tagName)), }; $exception->tagName = $tagName; @@ -70,6 +46,15 @@ public static function unexpectedTokenType(TokenType $expectedToken, TokenType $ )); } + public static function unexpectedIdentifier(string $expected, string $given): SyntaxException + { + return new SyntaxException(sprintf( + 'Expected identifier %s, got %s', + $expected, + $given + )); + } + public static function invalidExpression(string $expression): SyntaxException { return new SyntaxException(sprintf('%s is not a valid expression', $expression)); @@ -80,6 +65,20 @@ public static function unexpectedCharacter(string $character): SyntaxException return new SyntaxException(sprintf('Unexpected character %s', $character)); } + public static function unexpectedEndOfTemplate(): SyntaxException + { + return new SyntaxException('Unexpected end of template'); + } + + public static function unexpectedToken(Token $token): SyntaxException + { + return new SyntaxException(sprintf( + 'Unexpected token %s: "%s"', + $token->type->toString(), + $token->data, + )); + } + protected function messagePrefix(): string { return 'Liquid syntax error'; diff --git a/src/Exceptions/TagDisabledException.php b/src/Exceptions/TagDisabledException.php index 03960c1..5f0c3d0 100644 --- a/src/Exceptions/TagDisabledException.php +++ b/src/Exceptions/TagDisabledException.php @@ -2,12 +2,10 @@ namespace Keepsuit\Liquid\Exceptions; -use Keepsuit\Liquid\Support\I18n; - class TagDisabledException extends LiquidException { - public function __construct(string $tagName, I18n $locale) + public function __construct(string $tagName) { - parent::__construct($locale->translate('errors.disabled.tag', ['tag' => $tagName])); + parent::__construct(sprintf('%s usage is not allowed in this context', $tagName)); } } diff --git a/src/Exceptions/TranslationException.php b/src/Exceptions/TranslationException.php deleted file mode 100644 index 0b49aef..0000000 --- a/src/Exceptions/TranslationException.php +++ /dev/null @@ -1,21 +0,0 @@ -\n", $input ?? '') ?? ''; + return preg_replace('/\r?\n/', "
\n", $input ?? '') ?? $input ?? ''; } /** @@ -538,7 +542,7 @@ public function stripHtml(?string $input): string $STRIP_HTML_TAGS = '/<[\S\s]*?>/m'; $STRIP_HTLM_BLOCKS = '/(()|()|())/m'; - return preg_replace([$STRIP_HTLM_BLOCKS, $STRIP_HTML_TAGS], '', $input ?? '') ?? ''; + return preg_replace([$STRIP_HTLM_BLOCKS, $STRIP_HTML_TAGS], '', $input ?? '') ?? $input ?? ''; } /** @@ -546,7 +550,7 @@ public function stripHtml(?string $input): string */ public function stripNewlines(?string $input): string { - return preg_replace('/\r?\n/', '', $input ?? '') ?? ''; + return preg_replace('/\r?\n/', '', $input ?? '') ?? $input ?? ''; } /** diff --git a/src/Nodes/BlockBodySection.php b/src/Nodes/BlockBodySection.php deleted file mode 100644 index 026d305..0000000 --- a/src/Nodes/BlockBodySection.php +++ /dev/null @@ -1,154 +0,0 @@ - */ - protected array $nodeList = [], - ) { - } - - public function startDelimiter(): ?BlockBodySectionDelimiter - { - return $this->start; - } - - public function endDelimiter(): ?BlockBodySectionDelimiter - { - return $this->end; - } - - /** - * @return array - */ - public function nodeList(): array - { - return $this->nodeList; - } - - public function setStart(?BlockBodySectionDelimiter $start): BlockBodySection - { - $this->start = $start; - - return $this; - } - - public function setEnd(?BlockBodySectionDelimiter $end): BlockBodySection - { - $this->end = $end; - - return $this; - } - - public function pushNode(Variable|Tag|string $node): BlockBodySection - { - $this->nodeList[] = $node; - - return $this; - } - - /** - * @param array $nodeList - */ - public function setNodeList(array $nodeList): BlockBodySection - { - $this->nodeList = $nodeList; - - return $this; - } - - /** - * @throws LiquidException - */ - public function render(Context $context): string - { - $context->resourceLimits->incrementRenderScore(count($this->nodeList)); - - $output = ''; - - foreach ($this->nodeList as $node) { - if (is_string($node)) { - $output .= $node; - - continue; - } - - try { - if ($node instanceof Tag) { - $node->ensureTagIsEnabled($context); - } - - $output .= $this->renderNode($context, $node); - } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { - $context->handleError($exception, $node->lineNumber); - } catch (\Throwable $exception) { - $output .= $context->handleError($exception, $node->lineNumber); - } - - if ($context->hasInterrupt()) { - break; - } - } - - $context->resourceLimits->incrementWriteScore($output); - - return $output; - } - - protected function renderNode(Context $context, Variable|Tag $node): string - { - if ($context->getProfiler() !== null) { - return $context->getProfiler()->profileNode( - templateName: $context->getTemplateName(), - renderFunction: fn () => $node->render($context), - code: $node->raw(), - lineNumber: $node->lineNumber, - ); - } - - return $node->render($context); - } - - public function blank(): bool - { - foreach ($this->nodeList as $node) { - if (is_string($node) && Str::blank($node)) { - continue; - } - - if (is_string($node) || $node instanceof Variable) { - return false; - } - - if ($node->blank()) { - continue; - } - - return false; - } - - return true; - } - - public function removeBlankStrings(): void - { - if (! $this->blank()) { - throw new \RuntimeException('Cannot remove blank strings from non-blank section'); - } - - $this->nodeList = array_filter($this->nodeList, fn ($node) => ! is_string($node)); - } -} diff --git a/src/Nodes/BlockBodySectionDelimiter.php b/src/Nodes/BlockBodySectionDelimiter.php deleted file mode 100644 index c7dba86..0000000 --- a/src/Nodes/BlockBodySectionDelimiter.php +++ /dev/null @@ -1,12 +0,0 @@ - */ + protected array $children = [], + ) { + } + + /** + * @return array + */ + public function children(): array + { + return $this->children; + } + + public function pushChild(Node $node): BodyNode + { + $this->children[] = $node; + + return $this; + } + + /** + * @param array $children + */ + public function setChildren(array $children): BodyNode + { + $this->children = $children; + + return $this; + } + + /** + * @throws LiquidException + */ + public function render(RenderContext $context): string + { + $context->resourceLimits->incrementRenderScore(count($this->children)); + + $output = ''; + + foreach ($this->children as $node) { + try { + if ($node instanceof Tag) { + $node->ensureTagIsEnabled($context); + } + + $output .= $this->renderChild($context, $node); + } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { + $context->handleError($exception, $node->lineNumber); + } catch (\Throwable $exception) { + $output .= $context->handleError($exception, $node->lineNumber); + } + + if ($context->hasInterrupt()) { + break; + } + } + + $context->resourceLimits->incrementWriteScore($output); + + return $output; + } + + protected function renderChild(RenderContext $context, Node $node): string + { + if ($context->getProfiler() !== null) { + return $context->getProfiler()->profileNode( + node: $node, + context: $context, + templateName: $context->getTemplateName(), + ); + } + + return $node->render($context); + } + + public function blank(): bool + { + foreach ($this->children as $node) { + if ($node->blank()) { + continue; + } + + return false; + } + + return true; + } + + public function removeBlankStrings(): void + { + if (! $this->blank()) { + throw new \RuntimeException('Cannot remove blank strings from non-blank section'); + } + + $this->children = array_filter($this->children, fn (Node $node) => ! ($node instanceof Text)); + } + + public function parseTreeVisitorChildren(): array + { + return $this->children; + } +} diff --git a/src/Nodes/Document.php b/src/Nodes/Document.php index ce113e7..9ace887 100644 --- a/src/Nodes/Document.php +++ b/src/Nodes/Document.php @@ -4,59 +4,36 @@ use Keepsuit\Liquid\Contracts\CanBeRendered; use Keepsuit\Liquid\Exceptions\LiquidException; -use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Parse\BlockParser; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; -use Keepsuit\Liquid\Tag; +use Keepsuit\Liquid\Render\RenderContext; class Document implements CanBeRendered { public function __construct( - protected BlockBodySection $body, + protected BodyNode $body, ) { } /** * @throws LiquidException */ - public static function parse(ParseContext $parseContext, Tokenizer $tokenizer): Document - { - try { - $bodySections = BlockParser::forDocument()->parse($tokenizer, $parseContext); - } catch (SyntaxException $exception) { - if (in_array($exception->tagName, ['else', 'end'])) { - $exception = SyntaxException::unexpectedOuterTag($parseContext, $exception->tagName ?? ''); - } - - $parseContext->handleError($exception); - } catch (\Throwable $exception) { - $parseContext->handleError($exception); - } - - return new Document( - body: $bodySections[0] ?? new BlockBodySection() - ); - } - - /** - * @throws LiquidException - */ - public function render(Context $context): string + public function render(RenderContext $context): string { if ($context->getProfiler() !== null) { - return $context->getProfiler()->profile($context->getTemplateName(), fn () => $this->body->render($context)); + return $context->getProfiler()->profile( + node: $this->body, + context: $context, + templateName: $context->getTemplateName() + ); } return $this->body->render($context); } /** - * @return array + * @return array */ - public function nodeList(): array + public function children(): array { - return $this->body->nodeList(); + return $this->body->children(); } } diff --git a/src/Nodes/Node.php b/src/Nodes/Node.php new file mode 100644 index 0000000..342c962 --- /dev/null +++ b/src/Nodes/Node.php @@ -0,0 +1,35 @@ +lineNumber; + } + + public function setLineNumber(?int $lineNumber): static + { + $this->lineNumber = $lineNumber; + + return $this; + } + + /** + * @return array + */ + public function children(): array + { + return []; + } +} diff --git a/src/Nodes/Range.php b/src/Nodes/Range.php index b04fd51..3f0ce0f 100644 --- a/src/Nodes/Range.php +++ b/src/Nodes/Range.php @@ -2,10 +2,10 @@ namespace Keepsuit\Liquid\Nodes; -use Keepsuit\Liquid\Contracts\CanBeRendered; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; +use Keepsuit\Liquid\Render\RenderContext; -class Range implements CanBeRendered +class Range extends Node implements HasParseTreeVisitorChildren { public function __construct( public readonly int $start, @@ -13,7 +13,7 @@ public function __construct( ) { } - public function render(Context $context): string + public function render(RenderContext $context): string { return sprintf('%d..%d', $this->start, $this->end); } @@ -22,4 +22,9 @@ public function toArray(): array { return range($this->start, $this->end); } + + public function parseTreeVisitorChildren(): array + { + return [$this->start, $this->end]; + } } diff --git a/src/Nodes/RangeLookup.php b/src/Nodes/RangeLookup.php index 50b5747..58feac3 100644 --- a/src/Nodes/RangeLookup.php +++ b/src/Nodes/RangeLookup.php @@ -5,8 +5,7 @@ use Keepsuit\Liquid\Contracts\CanBeEvaluated; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Parse\Expression; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; class RangeLookup implements CanBeEvaluated, HasParseTreeVisitorChildren { @@ -16,20 +15,12 @@ final public function __construct( ) { } - public static function parse(string $startMarkup, string $endMarkup): static - { - $startObject = Expression::parse($startMarkup); - $endObject = Expression::parse($endMarkup); - - return new static($startObject, $endObject); - } - public function parseTreeVisitorChildren(): array { return [$this->start, $this->end]; } - public function evaluate(Context $context): mixed + public function evaluate(RenderContext $context): mixed { $start = $this->toInteger($context->evaluate($this->start)); $end = $this->toInteger($context->evaluate($this->end)); @@ -52,4 +43,15 @@ protected function toInteger(mixed $value): int })), }; } + + public function toString(): string + { + // @phpstan-ignore-next-line + return sprintf('(%s..%s)', $this->start, $this->end); + } + + public function __toString(): string + { + return $this->toString(); + } } diff --git a/src/Nodes/Raw.php b/src/Nodes/Raw.php new file mode 100644 index 0000000..52f032f --- /dev/null +++ b/src/Nodes/Raw.php @@ -0,0 +1,29 @@ +value; + } + + public function blank(): bool + { + return false; + } + + public function parseTreeVisitorChildren(): array + { + return [$this->value]; + } +} diff --git a/src/Nodes/TagParseContext.php b/src/Nodes/TagParseContext.php new file mode 100644 index 0000000..f78d2fc --- /dev/null +++ b/src/Nodes/TagParseContext.php @@ -0,0 +1,30 @@ +parseContext = $parseContext; + + return $this; + } + + public function getParseContext(): ParseContext + { + return $this->parseContext; + } +} diff --git a/src/Nodes/Text.php b/src/Nodes/Text.php new file mode 100644 index 0000000..aa08b22 --- /dev/null +++ b/src/Nodes/Text.php @@ -0,0 +1,30 @@ +value; + } + + public function blank(): bool + { + return Str::blank($this->value); + } + + public function parseTreeVisitorChildren(): array + { + return [$this->value]; + } +} diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index 1fe01aa..b621d78 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -5,88 +5,24 @@ use Keepsuit\Liquid\Contracts\CanBeEvaluated; use Keepsuit\Liquid\Contracts\CanBeRendered; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; -use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Parse\Expression; -use Keepsuit\Liquid\Parse\Parser; -use Keepsuit\Liquid\Parse\TokenType; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Parse\ExpressionParser; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; -class Variable implements CanBeEvaluated, CanBeRendered, HasParseTreeVisitorChildren +/** + * @phpstan-import-type Expression from ExpressionParser + */ +class Variable extends Node implements CanBeEvaluated, HasParseTreeVisitorChildren { - /** - * @throws SyntaxException - */ public function __construct( - protected mixed $name, - /** @var array */ - protected array $filters = [], - protected string $markup = '', - public readonly ?int $lineNumber = null + /** @var Expression $name */ + public readonly mixed $name, + /** @var array}> */ + public readonly array $filters = [], ) { } - public static function fromMarkup(string $markup, ?int $lineNumber = null): Variable - { - try { - $variable = static::fromParser(new Parser($markup), $lineNumber); - } catch (SyntaxException $exception) { - $exception->markupContext = sprintf('{{%s}}', $markup); - throw $exception; - } - - $variable->markup = $markup; - - return $variable; - } - - public static function fromParser(Parser $parser, ?int $lineNumber = null): Variable - { - if ($parser->look(TokenType::EndOfString)) { - return new Variable( - name: null, - filters: [], - lineNumber: $lineNumber, - ); - } - - $markup = $parser->toString(); - - $name = static::parseExpression($parser->expression()); - - $filters = []; - while ($parser->consumeOrFalse(TokenType::Pipe)) { - $filterName = $parser->consume(TokenType::Identifier); - $filterArgs = $parser->consumeOrFalse(TokenType::Colon) ? static::parseFilterArgs($parser) : []; - $filters[] = static::parseFilterExpressions($filterName, $filterArgs); - } - - $parser->consume(TokenType::EndOfString); - - return new Variable( - name: $name, - filters: $filters, - markup: $markup, - lineNumber: $lineNumber, - ); - } - - public function name(): mixed - { - return $this->name; - } - - public function filters(): array - { - return $this->filters; - } - - public function raw(): string - { - return $this->markup; - } - - public function render(Context $context): string + public function render(RenderContext $context): string { $output = $this->evaluate($context); @@ -122,7 +58,7 @@ public function parseTreeVisitorChildren(): array return [$this->name, ...Arr::flatten($this->filters)]; } - public function evaluate(Context $context): mixed + public function evaluate(RenderContext $context): mixed { $output = $context->evaluate($this->name); @@ -139,48 +75,11 @@ public function evaluate(Context $context): mixed return $output; } - protected static function parseFilterArgs(Parser $parser): array - { - $filterArgs = [$parser->argument()]; - while ($parser->consumeOrFalse(TokenType::Comma)) { - $filterArgs[] = $parser->argument(); - } - - return $filterArgs; - } - - /** - * @param array> $filterArgs - * @return array{0:string, 1:array, 2:array} - */ - protected static function parseFilterExpressions(string $filterName, array $filterArgs): array - { - $parsedArgs = []; - $parsedNamedArgs = []; - - foreach ($filterArgs as $arg) { - if (is_array($arg)) { - foreach ($arg as $key => $value) { - $parsedNamedArgs[$key] = static::parseExpression($value); - } - } else { - $parsedArgs[] = static::parseExpression($arg); - } - } - - return [$filterName, $parsedArgs, $parsedNamedArgs]; - } - - protected static function evaluateFilterExpressions(Context $context, array $filterArgs): array + protected static function evaluateFilterExpressions(RenderContext $context, array $filterArgs): array { return array_map( fn (mixed $value) => $context->evaluate($value), $filterArgs ); } - - protected static function parseExpression(string $markup): mixed - { - return Expression::parse($markup); - } } diff --git a/src/Nodes/VariableLookup.php b/src/Nodes/VariableLookup.php index 429fa22..b02676d 100644 --- a/src/Nodes/VariableLookup.php +++ b/src/Nodes/VariableLookup.php @@ -5,83 +5,78 @@ use Keepsuit\Liquid\Contracts\CanBeEvaluated; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Contracts\IsContextAware; -use Keepsuit\Liquid\Parse\Expression; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\Parse\LexerOptions; +use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\Support\Str; class VariableLookup implements CanBeEvaluated, HasParseTreeVisitorChildren { const FILTER_METHODS = ['size', 'first', 'last']; - public readonly mixed $name; - - /** - * @var string[] - */ - public readonly array $lookups; - /** * @var int[] */ - protected array $lookupFilters = []; + public readonly array $lookupFilters; public function __construct( - protected string $markup + public readonly string $name, + /** @var string[] */ + public readonly array $lookups = [], ) { - $lookups = static::markupLookup($markup); - - $this->name = static::parseVariableName(array_shift($lookups)); - - $newLookups = []; - foreach ($lookups as $i => $lookup) { + $lookupFilters = []; + foreach ($this->lookups as $i => $lookup) { if (in_array($lookup, self::FILTER_METHODS)) { - $this->lookupFilters[] = $i; + $lookupFilters[] = $i; } - - $newLookups[$i] = self::parseVariableName($lookup); } - $this->lookups = $newLookups; + $this->lookupFilters = $lookupFilters; } - protected static function markupLookup(string $markup): array + public static function fromMarkup(string $markup): VariableLookup { - if (preg_match_all(sprintf('/%s/', Regex::VariableParser), $markup, $matches) === false) { - return []; + $variable = Str::beforeFirst($markup, ['.', '[']); + + $lookupsString = substr($markup, strlen($variable)); + + if ($lookupsString === '') { + return new VariableLookup($variable); } - return $matches[0]; - } + $count = preg_match_all(LexerOptions::variableLookupRegex(), $lookupsString, $matches); - protected static function parseVariableName(?string $name): mixed - { - return match (true) { - $name === null => null, - static::isWrappedInSquareBrackets($name) => Expression::parse(substr($name, 1, -1)), - default => $name, - }; + if ($count === 0) { + throw new SyntaxException('Invalid variable lookup: '.$lookupsString); + } + + $lookups = []; + foreach (range(0, $count - 1) as $i) { + $lookups[] = $matches[1][$i] ?: $matches[2][$i] ?: $matches[3][$i] ?: $matches[4][$i]; + } + + return new VariableLookup($variable, $lookups); } - protected static function isWrappedInSquareBrackets(string $name): bool + public function toString(): string { - return str_starts_with($name, '[') && str_ends_with($name, ']'); + if ($this->lookups === []) { + return $this->name; + } + + return sprintf('%s.%s', $this->name, implode('.', $this->lookups)); } public function __toString(): string { - if (! is_string($this->name)) { - // TODO: Implement VariableLookup Serialization. - throw new \RuntimeException('VariableLookup Serialization is not supported yet.'); - } - - return $this->name; + return $this->toString(); } public function parseTreeVisitorChildren(): array { - return $this->lookups; + return [$this->name, ...$this->lookups]; } - public function evaluate(Context $context): mixed + public function evaluate(RenderContext $context): mixed { $name = $context->evaluate($this->name); assert(is_string($name)); @@ -111,7 +106,7 @@ public function evaluate(Context $context): mixed return $object; } - protected function applyFilter(Context $context, mixed $object, string $filter): mixed + protected function applyFilter(RenderContext $context, mixed $object, string $filter): mixed { return match ($filter) { 'size' => $context->applyFilter('size', $object), diff --git a/src/Parse/ArgumentParser.php b/src/Parse/ArgumentParser.php new file mode 100644 index 0000000..0adcd5d --- /dev/null +++ b/src/Parse/ArgumentParser.php @@ -0,0 +1,34 @@ +|Expression + */ +class ArgumentParser +{ + public function __construct( + protected TokenStream $tokenStream + ) { + } + + /** + * @return Argument + */ + public function parseArgument(): mixed + { + if ( + $this->tokenStream->look(TokenType::Identifier) + && $this->tokenStream->look(TokenType::Colon, 1) + ) { + $identifier = $this->tokenStream->consume(TokenType::Identifier); + $this->tokenStream->consume(TokenType::Colon); + + return [$identifier->data => $this->tokenStream->expression()]; + } + + return $this->tokenStream->expression(); + } +} diff --git a/src/Parse/BlockParser.php b/src/Parse/BlockParser.php deleted file mode 100644 index 0546dc2..0000000 --- a/src/Parse/BlockParser.php +++ /dev/null @@ -1,279 +0,0 @@ -subTagsHandler = $subTagsHandler; - - return $this; - } - - /** - * @return array - * - * @throws SyntaxException - */ - public function parse(Tokenizer $tokenizer, ParseContext $parseContext): array - { - $parseContext->lineNumber = $tokenizer->getStartLineNumber(); - - if ($tokenizer->forLiquidTag) { - return $this->parseForLiquidTag($tokenizer, $parseContext); - } - - return $this->parseForDocument($tokenizer, $parseContext); - } - - protected function endTag(): ?string - { - if ($this->tagName === null) { - return null; - } - - return 'end'.$this->tagName; - } - - /** - * @return array - * - * @throws SyntaxException - */ - protected function parseForDocument(Tokenizer $tokenizer, ParseContext $parseContext): array - { - $sections = []; - $section = new BlockBodySection( - start: $this->tagName ? new BlockBodySectionDelimiter($this->tagName, $this->markup ?? '') : null, - end: null, - ); - $sections[] = $section; - - foreach ($tokenizer->shift() as $token) { - if ($token === '') { - continue; - } - - if (str_starts_with($token, self::TAGSTART)) { - $section->setNodeList(self::whitespaceHandler($token, $parseContext, $section->nodeList())); - - if (preg_match(self::FULL_TOKEN, $token, $matches) !== 1) { - $this->handleInvalidTagToken($token, $parseContext); - - continue; - } - - $tagName = $matches[2]; - $markup = $matches[4]; - - if ($parseContext->lineNumber !== null) { - $parseContext->lineNumber += substr_count($matches[1], PHP_EOL) + substr_count($matches[3], PHP_EOL); - } - - /** @var class-string|null $tagClass */ - $tagClass = $parseContext->tagRegistry->get($tagName) ?? null; - - if ($tagClass !== null) { - $tag = (new $tagClass($markup, $parseContext->lineNumber)); - $tag->parse($parseContext, $tokenizer); - $section->pushNode($tag); - - continue; - } - - if ($this->isBlockEndTag($tagName)) { - return $sections; - } - - $this->handleUnknownTag($tagName, $parseContext); - - $section->setEnd(new BlockBodySectionDelimiter($tagName, $markup)); - - $section = new BlockBodySection( - start: $section->endDelimiter(), - ); - $sections[] = $section; - } elseif (str_starts_with($token, self::VARSTART)) { - $section->setNodeList(static::whitespaceHandler($token, $parseContext, $section->nodeList())); - $section->pushNode(static::createVariable($token, $parseContext)); - } else { - if ($parseContext->trimWhitespace) { - $token = ltrim($token); - } - $parseContext->trimWhitespace = false; - $section->pushNode($token); - } - - $parseContext->lineNumber = $tokenizer->getEndLineNumber(); - } - - if ($section->endDelimiter() === null && $this->endTag() !== null) { - throw SyntaxException::tagNeverClosed($this->tagName, $parseContext); - } - - return $sections; - } - - /** - * @return array - * - * @throws SyntaxException - */ - protected function parseForLiquidTag(Tokenizer $tokenizer, ParseContext $parseContext): array - { - $sections = []; - $section = new BlockBodySection( - start: $this->tagName ? new BlockBodySectionDelimiter($this->tagName, $this->markup ?? '') : null, - end: null, - ); - $sections[] = $section; - - foreach ($tokenizer->shift() as $token) { - if ($token === '' || preg_match(self::WHITESPACE_OR_NOTHING, $token) === 1) { - $parseContext->lineNumber = $tokenizer->getEndLineNumber(); - - continue; - } - - if (preg_match(self::LIQUID_TAG_TOKEN, $token, $matches) !== 1) { - throw SyntaxException::unknownTag($parseContext, $token, 'liquid'); - } - - $tagName = $matches[1]; - $markup = $matches[2]; - - /** @var class-string|null $tagClass */ - $tagClass = $parseContext->tagRegistry->get($tagName) ?? null; - - if ($tagClass !== null) { - $tag = (new $tagClass($markup, $parseContext->lineNumber)); - $tag->parse($parseContext, $tokenizer); - $section->pushNode($tag); - - continue; - } - - if ($this->isBlockEndTag($tagName)) { - return $sections; - } - - $this->handleUnknownTag($tagName, $parseContext, forLiquid: true); - - $section->setEnd(new BlockBodySectionDelimiter($tagName, $markup)); - - $section = new BlockBodySection( - start: $section->endDelimiter(), - ); - $sections[] = $section; - - $parseContext->lineNumber = $tokenizer->getEndLineNumber(); - } - - if ($section->endDelimiter() === null && $this->endTag() !== null) { - $parseContext->lineNumber = $tokenizer->getEndLineNumber() - 1; - throw SyntaxException::tagNeverClosed($this->tagName, $parseContext); - } - - return $sections; - } - - protected static function whitespaceHandler(string $token, ParseContext $parseContext, array $nodeList): array - { - if (strlen($token) < 3) { - return $nodeList; - } - - if ($token[2] === Regex::WhitespaceControl) { - $previousToken = $nodeList[count($nodeList) - 1] ?? null; - - if (is_string($previousToken)) { - $nodeList[count($nodeList) - 1] = rtrim($previousToken); - } - } - - $parseContext->trimWhitespace = $token[strlen($token) - 3] === Regex::WhitespaceControl; - - return $nodeList; - } - - protected static function createVariable(string $token, ParseContext $parseContext): Variable - { - if (preg_match(static::CONTENT_OF_VARIABLE, $token, $matches) === 1) { - return Variable::fromMarkup($matches[1], $parseContext->lineNumber); - } - - throw SyntaxException::missingVariableTerminator($token, $parseContext); - } - - protected function handleInvalidTagToken(string $token, ParseContext $parseContext): void - { - if (str_ends_with($token, '%}')) { - $this->handleUnknownTag($token, $parseContext); - - return; - } - - throw SyntaxException::missingTagTerminator($token, $parseContext); - } - - /** - * @throws SyntaxException - */ - protected function handleUnknownTag(string $tagName, ParseContext $parseContext, bool $forLiquid = false): void - { - $handler = $this->subTagsHandler; - - if ($handler !== null && $handler($tagName)) { - return; - } - - if ($forLiquid) { - throw SyntaxException::unknownTag($parseContext, $tagName, blockTagName: 'liquid', blockDelimiter: '%}'); - } - - throw SyntaxException::unknownTag($parseContext, $tagName, $this->tagName ?? ''); - } - - protected function isBlockEndTag(string $tagName): bool - { - return $tagName === $this->endTag(); - } -} diff --git a/src/Parse/Expression.php b/src/Parse/Expression.php deleted file mode 100644 index 8e567d0..0000000 --- a/src/Parse/Expression.php +++ /dev/null @@ -1,61 +0,0 @@ -...) to avoid pathological backtracing from - * malicious input as described in https://github.com/Shopify/liquid/issues/1357 - */ - protected const RANGES_REGEX = '/\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/'; - - protected const LITERALS = [ - 'nil' => null, - 'null' => null, - '' => null, - 'true' => true, - 'false' => false, - 'blank' => Literal::Blank, - 'empty' => Literal::Empty, - ]; - - public static function parse(?string $markup): mixed - { - if ($markup === null) { - return null; - } - - $markup = trim($markup); - - if ( - (str_starts_with($markup, '"') && str_ends_with($markup, '"')) || - (str_starts_with($markup, "'") && str_ends_with($markup, "'")) - ) { - return substr($markup, 1, -1); - } - - if (preg_match(self::INTEGERS_REGEX, $markup, $matches) === 1) { - return (int) $matches[1]; - } - if (preg_match(self::RANGES_REGEX, $markup, $matches) === 1) { - return RangeLookup::parse($matches[1], $matches[2]); - } - if (preg_match(self::FLOATS_REGEX, $markup, $matches) === 1) { - return (float) $matches[1]; - } - if (array_key_exists($markup, self::LITERALS)) { - return self::LITERALS[$markup]; - } - - return new VariableLookup($markup); - } -} diff --git a/src/Parse/ExpressionParser.php b/src/Parse/ExpressionParser.php new file mode 100644 index 0000000..8fec38e --- /dev/null +++ b/src/Parse/ExpressionParser.php @@ -0,0 +1,134 @@ + null, + 'null' => null, + '' => null, + 'true' => true, + 'false' => false, + 'blank' => Literal::Blank, + 'empty' => Literal::Empty, + ]; + + public function __construct( + protected TokenStream $tokenStream + ) { + } + + /** + * @return Expression + * + * @throws SyntaxException + */ + public function parseExpression(): mixed + { + $token = $this->tokenStream->current(); + + if ($token === null) { + return null; + } + + return match ($token->type) { + TokenType::OpenRound => $this->parseRange(), + TokenType::String => $this->parseString(), + TokenType::Number => $this->parseNumber(), + TokenType::Identifier => array_key_exists($token->data, self::LITERALS) ? $this->parseLiteral() : $this->parseVariable(), + TokenType::VariableEnd => null, + default => throw SyntaxException::invalidExpression($token->data), + }; + } + + protected function parseVariable(): VariableLookup + { + $name = $this->tokenStream->consume(TokenType::Identifier)->data; + $lookups = $this->parseVariableLookups(); + + return new VariableLookup( + name: $name, + lookups: $lookups, + ); + } + + /** + * @throws SyntaxException + */ + protected function parseVariableLookups(): array + { + $lookups = []; + + while (true) { + if ($this->tokenStream->consumeOrFalse(TokenType::Dot)) { + $lookups[] = $this->tokenStream->consume(TokenType::Identifier)->data; + + continue; + } + if ($this->tokenStream->consumeOrFalse(TokenType::OpenSquare)) { + $lookups[] = $this->tokenStream->expression(); + $this->tokenStream->consume(TokenType::CloseSquare); + + continue; + } + + break; + } + + return $lookups; + } + + protected function parseRange(): RangeLookup + { + try { + $this->tokenStream->consume(TokenType::OpenRound); + + $start = $this->tokenStream->expression(); + $this->tokenStream->consume(TokenType::DotDot); + $end = $this->tokenStream->expression(); + + $this->tokenStream->consume(TokenType::CloseRound); + } catch (SyntaxException $exception) { + throw new SyntaxException('Invalid range syntax, correct syntax is (start..end)'); + } + + return new RangeLookup($start, $end); + } + + protected function parseString(): string + { + $token = $this->tokenStream->consume(TokenType::String); + + if ( + (str_starts_with($token->data, '"') && str_ends_with($token->data, '"')) || + (str_starts_with($token->data, "'") && str_ends_with($token->data, "'")) + ) { + return substr($token->data, 1, -1); + } + + return $token->data; + } + + protected function parseNumber(): int|float + { + $token = $this->tokenStream->consume(TokenType::Number); + + return str_contains($token->data, '.') ? (float) $token->data : (int) $token->data; + } + + protected function parseLiteral(): mixed + { + $token = $this->tokenStream->consume(TokenType::Identifier); + + return self::LITERALS[$token->data]; + } +} diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index 1cbb066..3708423 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -2,92 +2,325 @@ namespace Keepsuit\Liquid\Parse; -use Exception; use Keepsuit\Liquid\Exceptions\SyntaxException; +use RuntimeException; class Lexer { - protected const IDENTIFIER = "/\G[a-zA-Z_][\w-]*\??/"; + protected string $source; - protected const STRING_LITERAL = '/\G("[^\"]*")|\\G(\'[^\']*\')/'; + protected int $cursor; - protected const NUMBER_LITERAL = '/\G-?\d+(\.\d+)?/'; + protected int $end; - protected const DOTDOT = '/\G\.\./'; + protected int $lineNumber; - protected const COMPARISON_OPERATOR = '/\G(==|!=|<>|<=?|>=?|contains(?=\s))/'; + protected int $currentVarBlockLine; - protected const SPECIAL_CHARACTERS = [ - '|' => TokenType::Pipe, - '.' => TokenType::Dot, - ':' => TokenType::Colon, - ',' => TokenType::Comma, - '[' => TokenType::OpenSquare, - ']' => TokenType::CloseSquare, - '(' => TokenType::OpenRound, - ')' => TokenType::CloseRound, - '?' => TokenType::QuestionMark, - '-' => TokenType::Dash, - '=' => TokenType::Equals, - ]; + /** + * @var LexerState[] + */ + protected array $states; - protected const WHITESPACE_OR_NOTHING = '/\G\s*/'; + protected LexerState $state; + + /** + * @var Token[] + */ + protected array $tokens; + + /** + * @var array> + */ + protected array $positions; - protected array|Exception|null $result = null; + protected int $position; public function __construct( - protected readonly string $input + protected ParseContext $parseContext, ) { } /** - * @return array - * * @throws SyntaxException */ - public function tokenize(): array + public function tokenize(string $source): TokenStream { - if ($this->result instanceof Exception) { - throw $this->result; + $this->source = str_replace(["\r\n", "\r"], "\n", $source); + $this->cursor = 0; + $this->lineNumber = 1; + $this->end = strlen($this->source); + $this->states = []; + $this->state = LexerState::Data; + $this->tokens = []; + + $this->parseContext->lineNumber = 1; + + preg_match_all(LexerOptions::tokenStartRegex(), $this->source, $matches, PREG_OFFSET_CAPTURE); + $this->positions = $matches; + $this->position = -1; + + while ($this->cursor < $this->end) { + switch ($this->state) { + case LexerState::Data: + $this->lexData(); + break; + case LexerState::Variable: + $this->lexVariable(); + break; + case LexerState::Block: + $this->lexBlock(); + break; + } } - if (is_array($this->result)) { - return $this->result; + return new TokenStream($this->tokens, $this->source); + } + + protected function lexData(): void + { + // if no matches are left we return the rest of the template as simple text token + if ($this->position == count($this->positions[0]) - 1) { + $this->pushToken(TokenType::TextData, substr($this->source, $this->cursor)); + $this->cursor = $this->end; + + return; } - $output = []; + // Find the first token after the current cursor + $position = $this->positions[0][++$this->position]; + while ($position[1] < $this->cursor) { + if ($this->position == count($this->positions[0]) - 1) { + return; + } + $position = $this->positions[0][++$this->position]; + } + + // push the template text before the token first + $text = $textBeforeToken = substr($this->source, $this->cursor, $position[1] - $this->cursor); + + // trim? + if ($this->positions[2][$this->position][0] === LexerOptions::WhitespaceTrim->value) { + $textBeforeToken = rtrim($textBeforeToken); + } - $currentIndex = 0; - while ($currentIndex < strlen($this->input)) { - preg_match(self::WHITESPACE_OR_NOTHING, $this->input, $matches, offset: $currentIndex); - $currentIndex += strlen($matches[0]); + $this->pushToken(TokenType::TextData, $textBeforeToken); + $this->moveCursor($text.$position[0]); - if ($currentIndex >= strlen($this->input)) { + switch ($this->positions[1][$this->position][0]) { + case LexerOptions::TagBlockStart->value: + // {% raw %} + if (preg_match(LexerOptions::blockRawStartRegex(), $this->source, $matches, offset: $this->cursor) === 1) { + $this->moveCursor($matches[0]); + $this->lexRawData(); + break; + } + + // {% comment %} + if (preg_match(LexerOptions::blockCommentStartRegex(), $this->source, $matches, offset: $this->cursor) === 1) { + $this->moveCursor($matches[0]); + $this->lexComment(); + break; + } + + $this->pushToken(TokenType::BlockStart); + $this->pushState(LexerState::Block); + $this->currentVarBlockLine = $this->lineNumber; + break; + case LexerOptions::TagVariableStart->value: + $this->pushToken(TokenType::VariableStart); + $this->pushState(LexerState::Variable); + $this->currentVarBlockLine = $this->lineNumber; break; + } + } + + /** + * @throws SyntaxException + */ + protected function lexVariable(): void + { + if (preg_match(LexerOptions::variableEndRegex(), $this->source, $matches, offset: $this->cursor) === 1) { + $this->pushToken(TokenType::VariableEnd); + $this->moveCursor($matches[0]); + $this->popState(); + + // trim? + if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { + preg_match('/\s+/A', $this->source, $matches, offset: $this->cursor); + $this->moveCursor($matches[0] ?? ''); + } + } else { + $this->lexExpression(); + } + } + + /** + * @throws SyntaxException + */ + protected function lexBlock(): void + { + if (preg_match(LexerOptions::blockEndRegex(), $this->source, $matches, offset: $this->cursor) === 1) { + $this->pushToken(TokenType::BlockEnd); + $this->moveCursor($matches[0]); + $this->popState(); + + // trim? + if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { + preg_match('/\s+/A', $this->source, $matches, offset: $this->cursor); + $this->moveCursor($matches[0] ?? ''); } + } else { + $this->lexExpression(); + } + } + + /** + * @throws SyntaxException + */ + protected function lexExpression(): void + { + if (preg_match('/\G\s+/A', $this->source, $matches, offset: $this->cursor) === 1) { + $this->moveCursor($matches[0]); + } + + $this->ensureStreamNotEnded(); + + if ($this->source[$this->cursor] === '#') { + $this->lexInlineComment(); + + return; + } + + $token = match (true) { + preg_match(LexerOptions::comparisonOperatorRegex(), $this->source, $matches, offset: $this->cursor) === 1 => [TokenType::Comparison, $matches[0]], + preg_match(LexerOptions::identifierRegex(), $this->source, $matches, offset: $this->cursor) === 1 => [TokenType::Identifier, $matches[0]], + preg_match(LexerOptions::stringLiteralRegex(), $this->source, $matches, offset: $this->cursor) === 1 => [TokenType::String, $matches[0]], + preg_match(LexerOptions::numberLiteralRegex(), $this->source, $matches, offset: $this->cursor) === 1 => [TokenType::Number, $matches[0]], + $this->cursor + 1 < $this->end && $this->source[$this->cursor] === '.' && $this->source[$this->cursor + 1] === '.' => [TokenType::DotDot, '..'], + array_key_exists($this->source[$this->cursor], LexerOptions::specialCharacters()) => [LexerOptions::specialCharacters()[$this->source[$this->cursor]], $this->source[$this->cursor]], + default => throw SyntaxException::unexpectedCharacter($this->source[$this->cursor]), + }; - $token = match (true) { - preg_match(self::COMPARISON_OPERATOR, $this->input, $matches, offset: $currentIndex) === 1 => [TokenType::Comparison, $matches[0], $currentIndex], - preg_match(self::STRING_LITERAL, $this->input, $matches, offset: $currentIndex) === 1 => [TokenType::String, $matches[0], $currentIndex], - preg_match(self::NUMBER_LITERAL, $this->input, $matches, offset: $currentIndex) === 1 => [TokenType::Number, $matches[0], $currentIndex], - preg_match(self::IDENTIFIER, $this->input, $matches, offset: $currentIndex) === 1 => [TokenType::Identifier, $matches[0], $currentIndex], - preg_match(self::DOTDOT, $this->input, $matches, offset: $currentIndex) === 1 => [TokenType::DotDot, $matches[0], $currentIndex], - array_key_exists($this->input[$currentIndex], self::SPECIAL_CHARACTERS) => [self::SPECIAL_CHARACTERS[$this->input[$currentIndex]], $this->input[$currentIndex], $currentIndex], - default => SyntaxException::unexpectedCharacter($this->input[$currentIndex]), + $this->pushToken($token[0], $token[1]); + $this->moveCursor($token[1]); + + $this->ensureStreamNotEnded(); + } + + /** + * @throws SyntaxException + */ + protected function ensureStreamNotEnded(): void + { + if ($this->cursor >= $this->end) { + $exception = match ($this->state) { + LexerState::Variable => SyntaxException::missingVariableTerminator(), + LexerState::Block => SyntaxException::missingTagTerminator(), + default => SyntaxException::unexpectedEndOfTemplate(), }; - if ($token instanceof Exception) { - $this->result = $token; - throw $token; + if ($this->state !== LexerState::Data) { + $exception->lineNumber = $this->currentVarBlockLine; } - $output[] = $token; - $currentIndex += strlen($token[1]); + throw $exception; } + } + + protected function lexRawData(): void + { + if (preg_match(LexerOptions::blockRawDataRegex(), $this->source, $matches, flags: PREG_OFFSET_CAPTURE, offset: $this->cursor) !== 1) { + throw SyntaxException::tagBlockNeverClosed('raw'); + } + + $text = substr($this->source, $this->cursor, $matches[0][1] - $this->cursor); + + $this->moveCursor($text.$matches[0][0]); + + // trim? + if (isset($matches[2][0])) { + preg_match('/\s+/A', $this->source, $matches2, offset: $this->cursor); + $this->moveCursor($matches2[0] ?? ''); + } + + $this->pushToken(TokenType::RawData, $text); + } + + protected function lexComment(): void + { + if (preg_match(LexerOptions::blockCommentDataRegex(), $this->source, $matches, flags: PREG_OFFSET_CAPTURE, offset: $this->cursor) !== 1) { + throw SyntaxException::tagBlockNeverClosed('comment'); + } + + $text = substr($this->source, $this->cursor, $matches[0][1] - $this->cursor); + + $this->moveCursor($text.$matches[0][0]); + } + + protected function lexInlineComment(): void + { + if (preg_match(LexerOptions::inlineCommentDataRegex(), $this->source, $matches, flags: PREG_OFFSET_CAPTURE, offset: $this->cursor) !== 1) { + throw SyntaxException::tagBlockNeverClosed('#'); + } + + $text = substr($this->source, $this->cursor, $matches[0][1] - $this->cursor); - $output[] = [TokenType::EndOfString, '', $currentIndex]; - $this->result = $output; + $this->moveCursor($text.$matches[0][0]); + + if ($matches[1][0] === "\n") { + return; + } + + $lastToken = $this->tokens[count($this->tokens) - 1] ?? null; + + if ($lastToken?->type === TokenType::BlockStart) { + array_pop($this->tokens); + } else { + $this->pushToken(TokenType::BlockEnd); + } + + if ($matches[1][0] === LexerOptions::WhitespaceTrim->value) { + preg_match('/\s+/A', $this->source, $matches2, offset: $this->cursor); + $this->moveCursor($matches2[0] ?? ''); + } + } + + protected function pushToken(TokenType $type, string $value = ''): void + { + if ($type === TokenType::TextData && $value === '') { + return; + } + + $this->tokens[] = new Token($type, $value, $this->lineNumber); + } + + protected function moveCursor(string $text): void + { + if ($text === '') { + return; + } + + $this->cursor += strlen($text); + $this->lineNumber += substr_count($text, "\n"); + + $this->parseContext->lineNumber = $this->lineNumber; + } + + protected function pushState(LexerState $state): void + { + $this->states[] = $this->state; + $this->state = $state; + } + + protected function popState(): void + { + $state = array_pop($this->states); + + if ($state === null) { + throw new RuntimeException('Cannot pop state without a previous state'); + } - return $output; + $this->state = $state; } } diff --git a/src/Parse/LexerOptions.php b/src/Parse/LexerOptions.php new file mode 100644 index 0000000..bf53d4f --- /dev/null +++ b/src/Parse/LexerOptions.php @@ -0,0 +1,216 @@ +value), + preg_quote(LexerOptions::TagBlockStart->value), + preg_quote(LexerOptions::WhitespaceTrim->value) + ); + } + + return $regex; + } + + public static function commentBlockRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + "{\s*comment\s*(?:%s|%s')}Asx", + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::TagBlockEnd->value), + ); + } + + return $regex; + } + + public static function variableEndRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{\s*(?:%s|%s)}Ax', + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagVariableEnd->value), + preg_quote(LexerOptions::TagVariableEnd->value), + ); + } + + return $regex; + } + + public static function blockEndRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{\s*(?:%s|%s)}Ax', + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::TagBlockEnd->value), + ); + } + + return $regex; + } + + public static function blockRawStartRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{\s*raw\s*(?:%s|%s)}Ax', + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::TagBlockEnd->value), + ); + } + + return $regex; + } + + public static function blockRawDataRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{%s(%s)?\s*endraw\s*(%s)?%s}sx', + preg_quote(LexerOptions::TagBlockStart->value), + LexerOptions::WhitespaceTrim->value, + LexerOptions::WhitespaceTrim->value, + preg_quote(LexerOptions::TagBlockEnd->value), + ); + } + + return $regex; + } + + public static function blockCommentStartRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{\s*comment\s*(?:%s|%s)}Ax', + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::TagBlockEnd->value), + ); + } + + return $regex; + } + + public static function blockCommentDataRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{%s(%s)?\s*endcomment\s*(?:%s|%s)}sx', + preg_quote(LexerOptions::TagBlockStart->value), + LexerOptions::WhitespaceTrim->value, + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::TagBlockEnd->value), + ); + } + + return $regex; + } + + public static function inlineCommentDataRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{\s*(%s|%s|\n)}x', + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::TagBlockEnd->value), + ); + } + + return $regex; + } + + public static function comparisonOperatorRegex(): string + { + return '{\G(==|!=|<>|<=?|>=?|contains(?=\s))}As'; + } + + public static function stringLiteralRegex(): string + { + return '{\G("[^"]*")|(\'[^\']*\')}As'; + } + + public static function numberLiteralRegex(): string + { + return '{\G-?\d+(?:\.\d+)?}As'; + } + + public static function identifierRegex(): string + { + return "{\G[a-zA-Z_](?:\w|-\w)*\??}As"; + } + + public static function variableLookupRegex(): string + { + static $regex; + + if ($regex === null) { + $regex = sprintf( + '{%s|%s|%s|%s}', + '\.([\w\-]+)', + '\["([\w\-]+)"\]', + "\['([\w\-]+)'\]", + '\[(\d+)\]' + ); + } + + return $regex; + } + + public static function specialCharacters(): array + { + static $specialCharacters; + + if ($specialCharacters === null) { + $specialCharacters = [ + '|' => TokenType::Pipe, + '.' => TokenType::Dot, + ':' => TokenType::Colon, + ',' => TokenType::Comma, + '[' => TokenType::OpenSquare, + ']' => TokenType::CloseSquare, + '(' => TokenType::OpenRound, + ')' => TokenType::CloseRound, + '?' => TokenType::QuestionMark, + '-' => TokenType::Dash, + '=' => TokenType::Equals, + ]; + } + + return $specialCharacters; + } +} diff --git a/src/Parse/LexerState.php b/src/Parse/LexerState.php new file mode 100644 index 0000000..c5d8545 --- /dev/null +++ b/src/Parse/LexerState.php @@ -0,0 +1,10 @@ + - */ - protected array $partialsCache = []; + protected PartialsCache $partialsCache; protected OutputsBag $outputs; + protected Lexer $lexer; + + protected Parser $parser; + public function __construct( - bool|int|null $startLineNumber = null, public readonly TagRegistry $tagRegistry = new TagRegistry(), public readonly LiquidFileSystem $fileSystem = new BlankFileSystem(), - public readonly I18n $locale = new I18n(), ) { - $this->lineNumber = match (true) { - is_int($startLineNumber) => $startLineNumber, - $startLineNumber === true => 1, - default => null, - }; - + $this->lineNumber = 1; $this->outputs = new OutputsBag(); + $this->partialsCache = new PartialsCache(); + $this->lexer = new Lexer($this); + $this->parser = new Parser($this); } public function isPartial(): bool @@ -56,43 +52,50 @@ public function isPartial(): bool return $this->partial; } - public function newTokenizer(string $markup, bool $forLiquidTag = false): Tokenizer + /** + * @throws SyntaxException + */ + public function tokenize(string $markup): TokenStream { - return new Tokenizer($markup, startLineNumber: $this->lineNumber, forLiquidTag: $forLiquidTag); + return $this->lexer->tokenize($markup); } - public function parseExpression(string $markup): mixed + public function parse(TokenStream $tokenStream): BodyNode { - return Expression::parse($markup); + return $this->parser->parse($tokenStream); } public function loadPartial(string $templateName): Template { - if (Arr::has($this->partialsCache, $templateName)) { - return $this->partialsCache[$templateName]; + if ($cache = $this->partialsCache->get($templateName)) { + return $cache; } - $oldLineNumber = $this->lineNumber; - $this->partial = true; - $this->lineNumber = $this->lineNumber !== null ? 1 : null; + $partialParseContext = new ParseContext( + tagRegistry: $this->tagRegistry, + fileSystem: $this->fileSystem, + ); + $partialParseContext->partial = true; + $partialParseContext->depth = $this->depth; + $partialParseContext->outputs = $this->outputs; + $partialParseContext->partialsCache = $this->partialsCache; try { $source = $this->fileSystem->readTemplateFile($templateName); - $template = Template::parse($this, $source, $templateName); - $this->partialsCache[$templateName] = $template; + + $template = Template::parse($partialParseContext, $source, $templateName); + + $this->partialsCache->set($templateName, $template); return $template; } catch (LiquidException $exception) { $exception->templateName = $templateName; throw $exception; - } finally { - $this->partial = false; - $this->lineNumber = $oldLineNumber; } } - public function getPartialsCache(): array + public function getPartialsCache(): PartialsCache { return $this->partialsCache; } @@ -113,7 +116,7 @@ public function getOutputs(): OutputsBag public function nested(Closure $callback) { if ($this->depth >= self::MAX_DEPTH) { - throw new StackLevelException($this->locale->translate('errors.stack.nesting_too_deep')); + throw StackLevelException::nestingTooDeep(); } $this->depth += 1; diff --git a/src/Parse/ParseTreeVisitor.php b/src/Parse/ParseTreeVisitor.php index 85f6cf0..55fd548 100644 --- a/src/Parse/ParseTreeVisitor.php +++ b/src/Parse/ParseTreeVisitor.php @@ -4,6 +4,8 @@ use Closure; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; +use Keepsuit\Liquid\Nodes\Document; +use Keepsuit\Liquid\Nodes\Node; class ParseTreeVisitor { @@ -43,12 +45,20 @@ protected function children(): array return $this->node->parseTreeVisitorChildren(); } - return is_object($this->node) && method_exists($this->node, 'nodeList') ? $this->node->nodeList() : []; + if ($this->node instanceof Node) { + return $this->node->children(); + } + + if ($this->node instanceof Document) { + return $this->node->children(); + } + + return []; } protected function getCallbackFor(mixed $nodeType): ?Closure { - $key = is_object($nodeType) ? get_class($nodeType) : $nodeType; + $key = is_object($nodeType) ? get_class($nodeType) : gettype($nodeType); return $this->callbacks[$key] ?? null; } diff --git a/src/Parse/Parser.php b/src/Parse/Parser.php index 32c5077..322ec7b 100644 --- a/src/Parse/Parser.php +++ b/src/Parse/Parser.php @@ -3,193 +3,186 @@ namespace Keepsuit\Liquid\Parse; use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\Raw; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\Text; +use Keepsuit\Liquid\Nodes\Variable; +use Keepsuit\Liquid\Tag; +use Keepsuit\Liquid\TagBlock; class Parser { + protected TokenStream $tokenStream; + /** - * @var array + * @var TagBlock[] */ - protected array $tokens; + protected array $blockScopes; - protected int $pointer; + public function __construct( + protected ParseContext $parseContext, + ) { + } /** * @throws SyntaxException */ - public function __construct(protected string $input) + public function parse(TokenStream $tokenStream): BodyNode { - $this->tokens = (new Lexer($input))->tokenize(); - $this->pointer = 0; - } + $this->tokenStream = $tokenStream; + $this->blockScopes = []; - /** - * @phpstan-impure - */ - public function jump(int $int): void - { - $this->pointer = $int; + return $this->subparse(); } /** - * @phpstan-impure - * * @throws SyntaxException */ - public function consume(?TokenType $type = null): string + public function subparse(): BodyNode { - $token = $this->tokens[$this->pointer]; - - if ($type != null && $token[0] !== $type) { - throw SyntaxException::unexpectedTokenType($type, $token[0]); + if ($this->currentToken() === null) { + return new BodyNode([]); } - $this->pointer += 1; + $nodes = []; + + while (! $this->tokenStream->isEnd()) { + $token = $this->tokenStream->next(); + $this->parseContext->lineNumber = $token->lineNumber; + + switch ($token->type) { + case TokenType::TextData: + $nodes[] = (new Text($token->data))->setLineNumber($this->parseContext->lineNumber); + break; + case TokenType::RawData: + $nodes[] = (new Raw($token->data))->setLineNumber($this->parseContext->lineNumber); + break; + case TokenType::VariableStart: + $nodes[] = $this->parseVariable(); + + break; + case TokenType::BlockStart: + try { + $tagName = $this->tokenStream->consume(TokenType::Identifier)->data; + $this->tokenStream->jump(-1); + } catch (SyntaxException $e) { + throw new SyntaxException('A block must start with a tag name.'); + } + + if ($this->isEndOrSubTagOfCurrentBlock($tagName)) { + return new BodyNode($nodes); + } + + $nodes[] = $this->parseBlock(); + break; + default: + throw new SyntaxException('Unexpected token type: '.$token->type->toString()); + } + } - return $token[1] ?? ''; + return new BodyNode($nodes); } - public function consumeOrFalse(TokenType $type): string|false + protected function parseVariable(): Variable { - try { - return $this->consume($type); - } catch (SyntaxException) { - return false; - } + $variable = $this->tokenStream->variable(); + + $this->tokenStream->consume(TokenType::VariableEnd); + + return $variable; } /** - * @phpstan-impure - * * @throws SyntaxException */ - public function id(string $identifier): string|false + protected function parseBlock(): Tag { - $token = $this->tokens[$this->pointer]; + $currentToken = $this->tokenStream->current(); - if ($token === null || $token[0] !== TokenType::Identifier) { - throw SyntaxException::unexpectedTokenType(TokenType::Identifier, $token[0]); - } + $tagName = $this->tokenStream->consume(TokenType::Identifier)->data; - if ($token[1] !== $identifier) { - return false; + /** @var class-string|null $tagClass */ + $tagClass = $this->parseContext->tagRegistry->get($tagName) ?? null; + + if ($tagClass === null || ! class_exists($tagClass)) { + $blockTagName = $this->currentBlockScope() ? $this->currentBlockScope()::tagName() : null; + + throw SyntaxException::unknownTag($tagName, $blockTagName); } - $this->pointer += 1; + $tag = (new $tagClass())->setLineNumber($currentToken?->lineNumber); - return $token[1]; - } + if ($tag instanceof TagBlock) { + $this->blockScopes[] = $tag; - public function idOrFalse(string $identifier): string|false - { - try { - return $this->id($identifier); - } catch (SyntaxException) { - return false; - } - } + $currentTagName = $tagName; + do { + $params = $this->tokenStream->sliceUntil(TokenType::BlockEnd); + $this->tokenStream->consume(TokenType::BlockEnd); - public function look(TokenType $type, int $offset = 0): bool - { - $token = $this->tokens[$this->pointer + $offset] ?? null; + $body = $this->subparse(); + $tagParseContext = (new TagParseContext($currentTagName, $params, $body))->setParseContext($this->parseContext); - if ($token === null) { - return false; - } + $tag->parse($tagParseContext); - return $token[0] === $type; - } + try { + $currentTagName = $this->tokenStream->consume(TokenType::Identifier)->data; + } catch (SyntaxException $e) { + throw SyntaxException::tagBlockNeverClosed($tag::tagName()); + } + } while ($currentTagName !== $tag::blockDelimiter()); - /** - * @throws SyntaxException - */ - public function expression(): string - { - $token = $this->tokens[$this->pointer]; - - return match ($token[0]) { - TokenType::Identifier => $this->consume() - .$this->variableLookups(), - TokenType::OpenSquare => $this->consume() - .$this->expression() - .$this->consume(TokenType::CloseSquare) - .$this->variableLookups(), - TokenType::String, TokenType::Number => $this->consume(), - TokenType::OpenRound => $this->consume() - .$this->expression() - .$this->consume(TokenType::DotDot) - .$this->expression() - .$this->consume(TokenType::CloseRound), - default => throw SyntaxException::invalidExpression($token[1] ?? ''), - }; - } + $this->tokenStream->consume(TokenType::BlockEnd); - /** - * @return array - */ - public function attributes(?TokenType $separator = null): array - { - $attributes = []; + array_pop($this->blockScopes); - if ($this->look(TokenType::EndOfString) !== false) { - return $attributes; + return $tag; } - do { - $attribute = $this->consume(TokenType::Identifier); - $this->consume(TokenType::Colon); - $attributes[$attribute] = $this->expression(); + $params = $this->tokenStream->sliceUntil(TokenType::BlockEnd); + $this->tokenStream->consume(TokenType::BlockEnd); + + $tagParseContext = (new TagParseContext($tagName, $params)) + ->setParseContext($this->parseContext); - $shouldContinue = match (true) { - $separator === null => $this->look(TokenType::EndOfString) === false, - default => $this->consumeOrFalse($separator) !== false - }; - } while ($shouldContinue); + $tag->parse($tagParseContext); - return $attributes; + return $tag; } - /** - * @return array|string - * - * @throws SyntaxException - */ - public function argument(): string|array + protected function currentBlockScope(): ?TagBlock { - if ($this->look(TokenType::Identifier) && $this->look(TokenType::Colon, 1)) { - $identifier = $this->consume(TokenType::Identifier); - $this->consume(TokenType::Colon); + return $this->blockScopes[count($this->blockScopes) - 1] ?? null; + } - return [$identifier => $this->expression()]; + protected function isEndOrSubTagOfCurrentBlock(string $tagName): bool + { + $currentBlock = $this->currentBlockScope(); + + if (! $currentBlock) { + return false; } - return $this->expression(); + if ($tagName === $currentBlock::blockDelimiter()) { + return true; + } + + return $currentBlock->isSubTag($tagName); } - /** - * @throws SyntaxException - */ - protected function variableLookups(): string + public function getParseContext(): ParseContext { - $output = match (true) { - $this->look(TokenType::OpenSquare) => $this->consume() - .$this->expression() - .$this->consume(TokenType::CloseSquare), - $this->look(TokenType::Dot) => $this->consume() - .$this->consume(TokenType::Identifier), - default => '', - }; - - if ($output === '') { - return $output; - } - - return $output.$this->variableLookups(); + return $this->parseContext; } - public function toString(): string + public function getTokenStream(): TokenStream { - $current = $this->tokens[$this->pointer]; + return $this->tokenStream; + } - return substr($this->input, $current[2] ?? 0); + public function currentToken(): ?Token + { + return $this->tokenStream->current(); } } diff --git a/src/Parse/Regex.php b/src/Parse/Regex.php deleted file mode 100644 index e083769..0000000 --- a/src/Parse/Regex.php +++ /dev/null @@ -1,40 +0,0 @@ -[^\[\]]+|\g<0>)*\]|'.self::VariableSegment.'+\??'; - - const FullTagToken = '/\A'.self::TagStart.self::WhitespaceControl.'?(\s*)('.self::TagName.')(\s*)((\S|\s)*?)'.self::WhitespaceControl.'?'.self::TagEnd.'\z/m'; -} diff --git a/src/Parse/Token.php b/src/Parse/Token.php new file mode 100644 index 0000000..24f8d37 --- /dev/null +++ b/src/Parse/Token.php @@ -0,0 +1,13 @@ +expressionParser = new ExpressionParser($this); + $this->argumentParser = new ArgumentParser($this); + $this->variableParser = new VariableParser($this); + } + + /** + * @phpstan-impure + * + * @throws SyntaxException + */ + public function jump(int $offset): void + { + $newCursor = $this->cursor + $offset; + + if ($newCursor < 0 || $newCursor > count($this->tokens)) { + throw new SyntaxException("Invalid jump offset: $offset"); + } + + $this->cursor = $newCursor; + } + + public function look(TokenType $type, int $offset = 0): bool + { + $token = $this->tokens[$this->cursor + $offset] ?? null; + + if ($token === null) { + return false; + } + + return $token->type === $type; + } + + /** + * @throws SyntaxException + */ + public function next(): Token + { + return $this->consume(); + } + + /** + * @phpstan-impure + * + * @throws SyntaxException + */ + public function consume(?TokenType $type = null): Token + { + $token = $this->tokens[$this->cursor++] ?? null; + + if ($token === null) { + throw SyntaxException::unexpectedEndOfTemplate(); + } + + if ($type !== null && $token->type !== $type) { + throw SyntaxException::unexpectedTokenType($type, $token->type); + } + + return $token; + } + + public function consumeOrFalse(TokenType $type): Token|false + { + return $this->look($type) ? $this->consume($type) : false; + } + + /** + * @throws SyntaxException + * + * @phpstan-impure + */ + public function id(string $identifier): Token + { + $token = $this->consume(TokenType::Identifier); + + if ($token->data !== $identifier) { + throw SyntaxException::unexpectedIdentifier($identifier, $token->data); + } + + return $token; + } + + public function idOrFalse(string $identifier): Token|false + { + $token = $this->consumeOrFalse(TokenType::Identifier); + + if ($token === false) { + return false; + } + + if ($token->data === $identifier) { + return $token; + } + + $this->jump(-1); + + return false; + } + + public function current(): ?Token + { + return $this->tokens[$this->cursor] ?? null; + } + + public function isEnd(): bool + { + return $this->cursor >= count($this->tokens); + } + + /** + * @return Expression + * + * @throws SyntaxException + */ + public function expression(): mixed + { + return $this->expressionParser->parseExpression(); + } + + /** + * @return Argument + */ + public function argument(): mixed + { + return $this->argumentParser->parseArgument(); + } + + public function variable(): Variable + { + return $this->variableParser->parseVariable(); + } + + /** + * @throws SyntaxException + */ + public function assertEnd(): void + { + if (! $this->isEnd()) { + $token = $this->current(); + assert($token !== null); + throw SyntaxException::unexpectedToken($token); + } + } + + public function toArray(): array + { + return $this->tokens; + } + + /** + * @param TokenType|Closure(Token $token):bool $check + * + * @throws SyntaxException + */ + public function sliceUntil(Closure|TokenType $check): TokenStream + { + if ($check instanceof TokenType) { + $tokenType = $check; + $check = fn (Token $token) => $token->type === $tokenType; + } + + $tokens = []; + + while (! $this->isEnd()) { + $token = $this->consume(); + + if ($check($token)) { + $this->jump(-1); + break; + } + + $tokens[] = $token; + } + + return new TokenStream($tokens); + } +} diff --git a/src/Parse/TokenType.php b/src/Parse/TokenType.php index 1660530..68cd2f5 100644 --- a/src/Parse/TokenType.php +++ b/src/Parse/TokenType.php @@ -4,12 +4,24 @@ enum TokenType { + /** + * Tag tokens + */ + case TextData; + case RawData; + case VariableStart; + case VariableEnd; + case BlockStart; + case BlockEnd; + + /** + * Expression tokens + */ case String; case Number; case Identifier; case Comparison; case DotDot; - case EndOfString; case Pipe; case Dot; case Colon; @@ -29,19 +41,24 @@ public function toString(): string self::Number => 'Number', self::Identifier => 'Identifier', self::Comparison => 'Comparison', - self::DotDot => 'DotDot', - self::EndOfString => 'EndOfString', - self::Pipe => 'Pipe', - self::Dot => 'Dot', - self::Colon => 'Colon', - self::Comma => 'Comma', - self::OpenSquare => 'OpenSquare', - self::CloseSquare => 'CloseSquare', - self::OpenRound => 'OpenRound', - self::CloseRound => 'CloseRound', - self::QuestionMark => 'QuestionMark', - self::Dash => 'Dash', - self::Equals => 'Equals', + self::DotDot => '..', + self::Pipe => '|', + self::Dot => '.', + self::Colon => ':', + self::Comma => ',', + self::OpenSquare => '[', + self::CloseSquare => ']', + self::OpenRound => '(', + self::CloseRound => ')', + self::QuestionMark => '?', + self::Dash => '-', + self::Equals => '==', + self::VariableStart => '{{', + self::VariableEnd => '}}', + self::BlockStart => '{%', + self::BlockEnd => '%}', + self::TextData => 'Text', + self::RawData => 'Raw', }; } } diff --git a/src/Parse/Tokenizer.php b/src/Parse/Tokenizer.php deleted file mode 100644 index 66eda67..0000000 --- a/src/Parse/Tokenizer.php +++ /dev/null @@ -1,85 +0,0 @@ - - */ - protected array $tokens; - - public function __construct( - protected string $source, - int|bool|null $startLineNumber = null, - public readonly bool $forLiquidTag = false - ) { - $this->tokens = $this->tokenize(); - - $this->startLineNumber = match (true) { - is_int($startLineNumber) => $startLineNumber, - $startLineNumber === true => 1, - default => null, - }; - } - - /** - * @return \Generator - */ - public function shift(): \Generator - { - while ($this->offset < count($this->tokens)) { - $token = $this->tokens[$this->offset]; - - $this->offset += 1; - - if ($this->startLineNumber !== null) { - $this->startLineNumber = $this->endLineNumber ?? $this->startLineNumber; - $this->endLineNumber = $this->startLineNumber + ($this->forLiquidTag ? 1 : substr_count($token, PHP_EOL)); - } - - yield $token; - } - } - - protected function tokenize(): array - { - if (strlen($this->source) === 0) { - return []; - } - - if ($this->forLiquidTag) { - return explode(PHP_EOL, $this->source); - } - - $regex = sprintf('/%s/m', Regex::TemplateParser); - - $tokens = preg_split($regex, $this->source, flags: PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); - - if ($tokens === false) { - return []; - } - - if ($tokens[0] === '') { - $this->offset += 1; - } - - return $tokens; - } - - public function getStartLineNumber(): ?int - { - return $this->startLineNumber; - } - - public function getEndLineNumber(): ?int - { - return $this->endLineNumber; - } -} diff --git a/src/Parse/VariableParser.php b/src/Parse/VariableParser.php new file mode 100644 index 0000000..570fac2 --- /dev/null +++ b/src/Parse/VariableParser.php @@ -0,0 +1,70 @@ +tokenStream->current(); + + if ($currentToken === null) { + throw SyntaxException::unexpectedEndOfTemplate(); + } + + $expression = $this->tokenStream->expression(); + + $filters = []; + while ($this->tokenStream->consumeOrFalse(TokenType::Pipe)) { + $filterName = $this->tokenStream->consume(TokenType::Identifier)->data; + $filterArgs = $this->tokenStream->consumeOrFalse(TokenType::Colon) ? $this->parseFilterArgs() : []; + $filters[] = $this->parseFilterExpressions($filterName, $filterArgs); + } + + return (new Variable( + name: $expression, + filters: $filters, + ))->setLineNumber($currentToken->lineNumber); + } + + protected function parseFilterArgs(): array + { + $filterArgs = [$this->tokenStream->argument()]; + + while ($this->tokenStream->consumeOrFalse(TokenType::Comma)) { + $filterArgs[] = $this->tokenStream->argument(); + } + + return $filterArgs; + } + + /** + * @param array> $filterArgs + * @return array{0:string, 1:array, 2:array} + */ + protected function parseFilterExpressions(string $filterName, array $filterArgs): array + { + $parsedArgs = []; + $parsedNamedArgs = []; + + foreach ($filterArgs as $arg) { + if (is_array($arg)) { + foreach ($arg as $key => $value) { + $parsedNamedArgs[$key] = $value; + } + } else { + $parsedArgs[] = $arg; + } + } + + return [$filterName, $parsedArgs, $parsedNamedArgs]; + } +} diff --git a/src/Profiler/Profiler.php b/src/Profiler/Profiler.php index 333e6a5..d3e5138 100644 --- a/src/Profiler/Profiler.php +++ b/src/Profiler/Profiler.php @@ -2,7 +2,9 @@ namespace Keepsuit\Liquid\Profiler; -use Closure; +use Keepsuit\Liquid\Nodes\Node; +use Keepsuit\Liquid\Nodes\Text; +use Keepsuit\Liquid\Render\RenderContext; class Profiler { @@ -14,19 +16,16 @@ class Profiler protected ?Timing $currentTiming = null; - /** - * @param Closure(): string $renderFunction - */ - public function profile(?string $templateName, Closure $renderFunction): string + public function profile(Node $node, RenderContext $context, ?string $templateName): string { if ($this->currentTiming != null) { - return $renderFunction(); + return $node->render($context); } try { $this->currentRootTiming = null; - return $this->profileNode($templateName, $renderFunction); + return $this->profileNode($node, $context, $templateName); } finally { $this->currentTiming = null; @@ -37,15 +36,15 @@ public function profile(?string $templateName, Closure $renderFunction): string } } - /** - * @param Closure(): string $renderFunction - */ - public function profileNode(?string $templateName, Closure $renderFunction, ?string $code = null, ?int $lineNumber = null): string + public function profileNode(Node $node, RenderContext $context, ?string $templateName): string { + if ($node instanceof Text) { + return $node->render($context); + } + $timing = new Timing( + $node, templateName: $templateName, - code: $code, - lineNumber: $lineNumber, ); $this->currentRootTiming ??= $timing; @@ -53,7 +52,7 @@ public function profileNode(?string $templateName, Closure $renderFunction, ?str $parentTiming = $this->currentTiming; $this->currentTiming = $timing; - $output = $timing->measure($renderFunction); + $output = $timing->measure(fn () => $node->render($context)); $parentTiming?->addChild($timing); $this->currentTiming = $parentTiming; diff --git a/src/Profiler/Timing.php b/src/Profiler/Timing.php index 53e4d27..4e70610 100644 --- a/src/Profiler/Timing.php +++ b/src/Profiler/Timing.php @@ -2,8 +2,12 @@ namespace Keepsuit\Liquid\Profiler; +use Keepsuit\Liquid\Nodes\Node; + class Timing { + public readonly ?int $lineNumber; + protected ?int $startTime = null; protected ?int $totalTime = null; @@ -16,10 +20,10 @@ class Timing protected array $children = []; public function __construct( + public readonly Node $node, public readonly ?string $templateName = null, - public readonly ?string $code = null, - public readonly ?int $lineNumber = null, ) { + $this->lineNumber = $node->lineNumber(); } public function getTotalTime(): int @@ -63,12 +67,12 @@ public function measure(\Closure $renderFunction): string throw new \RuntimeException('Timing::measure() called while already measuring'); } - $this->startTime = hrtime(true); + $this->startTime = $this->time(); try { $output = $renderFunction(); } finally { - $this->totalTime = hrtime(true) - $this->startTime; + $this->totalTime = $this->time() - $this->startTime; } return $output; @@ -78,4 +82,12 @@ public function addChild(Timing $timing): void { $this->children[] = $timing; } + + protected function time(): int + { + $time = hrtime(true); + assert(is_int($time)); + + return $time; + } } diff --git a/src/Render/ContextSharedState.php b/src/Render/ContextSharedState.php index 0863031..4346e26 100644 --- a/src/Render/ContextSharedState.php +++ b/src/Render/ContextSharedState.php @@ -22,7 +22,7 @@ public function __construct( /** @var array */ public array $staticEnvironment = [], /** @var array */ - public array $staticRegisters = [], + public array $registers = [], /** @var array<\Throwable> */ public array $errors = [], /** @var array */ diff --git a/src/Render/Context.php b/src/Render/RenderContext.php similarity index 88% rename from src/Render/Context.php rename to src/Render/RenderContext.php index 743044d..350b168 100644 --- a/src/Render/Context.php +++ b/src/Render/RenderContext.php @@ -17,19 +17,18 @@ use Keepsuit\Liquid\Exceptions\UndefinedVariableException; use Keepsuit\Liquid\FileSystems\BlankFileSystem; use Keepsuit\Liquid\Interrupts\Interrupt; -use Keepsuit\Liquid\Parse\Expression; +use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\ParseContext; use Keepsuit\Liquid\Profiler\Profiler; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Support\FilterRegistry; -use Keepsuit\Liquid\Support\I18n; use Keepsuit\Liquid\Support\MissingValue; use Keepsuit\Liquid\Support\OutputsBag; use Keepsuit\Liquid\Template; use RuntimeException; use Throwable; -final class Context +final class RenderContext { protected int $baseScopeDepth = 0; @@ -57,12 +56,25 @@ final class Context protected ?Profiler $profiler; public function __construct( - /** @var array */ + /** + * Environment variables only available in the current context + * + * @var array + */ protected array $environment = [], - /** @var array $staticEnvironment */ + /** + * Environment variables that are shared with all sub-contexts + * + * @var array $staticEnvironment + */ array $staticEnvironment = [], - /** array */ - protected array $outerScope = [], + /** + * Registers allows to provide/export data or utilities inside tags + * Registers are not accessible as variables. + * Registers are shared with all sub-contexts + * + * @var array $registers + */ array $registers = [], protected bool $rethrowExceptions = false, public readonly bool $strictVariables = false, @@ -70,13 +82,12 @@ public function __construct( protected FilterRegistry $filterRegistry = new FilterRegistry(), public readonly ResourceLimits $resourceLimits = new ResourceLimits(), public readonly LiquidFileSystem $fileSystem = new BlankFileSystem(), - public readonly I18n $locale = new I18n(), ) { - $this->scopes = [$this->outerScope]; + $this->scopes = [[]]; $this->sharedState = new ContextSharedState( staticEnvironment: $staticEnvironment, - staticRegisters: $registers, + registers: $registers, ); $this->profiler = $profile ? new Profiler() : null; @@ -106,7 +117,7 @@ protected function pop(): array /** * @template TResult * - * @param Closure(Context $context): TResult $closure + * @param Closure(RenderContext $context): TResult $closure * @return TResult */ public function stack(Closure $closure) @@ -149,7 +160,7 @@ public function set(string $key, mixed $value): void public function get(string $key): mixed { - return $this->evaluate(Expression::parse($key)); + return $this->evaluate(VariableLookup::fromMarkup($key)); } public function has(string $key): bool @@ -243,7 +254,7 @@ public function applyFilter(string $filter, mixed $value, mixed ...$args): mixed public function getRegister(string $name): mixed { - return $this->dynamicRegisters[$name] ?? $this->sharedState->staticRegisters[$name] ?? null; + return $this->dynamicRegisters[$name] ?? $this->sharedState->registers[$name] ?? null; } public function setRegister(string $name, mixed $value): void @@ -328,27 +339,27 @@ public function getProfiler(): ?Profiler public function loadPartial(string $templateName): Template { if (! Arr::has($this->sharedState->partialsCache, $templateName)) { - throw new StandardException($this->locale->translate('errors.runtime.partial_not_loaded', ['partial' => $templateName])); + throw new StandardException(sprintf("The partial '%s' has not be loaded during parsing", $templateName)); } return $this->sharedState->partialsCache[$templateName]; } - public function setPartialsCache(array $partialsCache): Context + public function setPartialsCache(array $partialsCache): RenderContext { $this->sharedState->partialsCache = $partialsCache; return $this; } - public function mergePartialsCache(array $partialsCache): Context + public function mergePartialsCache(array $partialsCache): RenderContext { $this->sharedState->partialsCache = array_merge($this->sharedState->partialsCache, $partialsCache); return $this; } - public function mergeOutputs(array $outputs): Context + public function mergeOutputs(array $outputs): RenderContext { $this->sharedState->outputs->merge($outputs); @@ -363,17 +374,16 @@ public function getOutputs(): OutputsBag /** * @throws StackLevelException */ - public function newIsolatedSubContext(?string $templateName): Context + public function newIsolatedSubContext(?string $templateName): RenderContext { $this->checkOverflow(); - $subContext = new Context( + $subContext = new RenderContext( rethrowExceptions: $this->rethrowExceptions, strictVariables: $this->strictVariables, filterRegistry: $this->filterRegistry, resourceLimits: $this->resourceLimits, fileSystem: $this->fileSystem, - locale: $this->locale, ); $subContext->baseScopeDepth = $this->baseScopeDepth + 1; $subContext->sharedState = $this->sharedState; @@ -388,7 +398,7 @@ public function newIsolatedSubContext(?string $templateName): Context * @template TResult * * @param string[] $tags - * @param Closure(Context $context): TResult $closure + * @param Closure(RenderContext $context): TResult $closure * @return TResult */ public function withDisabledTags(array $tags, Closure $closure) @@ -419,7 +429,7 @@ public function tagDisabled(string $tag): bool protected function checkOverflow(): void { if ($this->baseScopeDepth + count($this->scopes) > ParseContext::MAX_DEPTH) { - throw new StackLevelException($this->locale->translate('errors.stack.nesting_too_deep')); + throw StackLevelException::nestingTooDeep(); } } } diff --git a/src/Support/FilterRegistry.php b/src/Support/FilterRegistry.php index 0cf1231..d26d4b6 100644 --- a/src/Support/FilterRegistry.php +++ b/src/Support/FilterRegistry.php @@ -5,7 +5,7 @@ use Keepsuit\Liquid\Contracts\IsContextAware; use Keepsuit\Liquid\Exceptions\InvalidArgumentException; use Keepsuit\Liquid\Exceptions\UndefinedFilterException; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; class FilterRegistry { @@ -29,7 +29,7 @@ public function register(string $filterClass): static continue; } - $this->filters[Str::snake($method->getName())] = function (Context $context, ...$args) use ($filterClass, $method) { + $this->filters[Str::snake($method->getName())] = function (RenderContext $context, ...$args) use ($filterClass, $method) { $filterClassInstance = new $filterClass(); if ($filterClassInstance instanceof IsContextAware) { @@ -46,7 +46,7 @@ public function register(string $filterClass): static /** * @throws UndefinedFilterException */ - public function invoke(Context $context, string $filterName, mixed $value, mixed ...$args): mixed + public function invoke(RenderContext $context, string $filterName, mixed $value, mixed ...$args): mixed { $filter = $this->filters[$filterName] ?? null; diff --git a/src/Support/I18n.php b/src/Support/I18n.php deleted file mode 100644 index 741e4af..0000000 --- a/src/Support/I18n.php +++ /dev/null @@ -1,67 +0,0 @@ -path = $path ?? __DIR__.'/../../locales/en.yml'; - } - - public function translate(string $key, array $vars = []): string - { - return $this->interpolate($this->deepFetchTranslation($key), $vars); - } - - protected function deepFetchTranslation(string $key): string - { - $result = array_reduce( - explode('.', $key), - function (mixed $translation, string $current) use ($key) { - if (! is_array($translation)) { - throw TranslationException::invalidTranslation($key, $this->path); - } - - return $translation[$current] ?? throw TranslationException::keyNotExists($key, $this->path); - }, - $this->getLocale() - ); - - if (! (is_string($result) || is_numeric($result))) { - throw TranslationException::invalidTranslation($key, $this->path); - } - - return (string) $result; - } - - protected function interpolate(string $translation, array $vars = []): string - { - $result = preg_replace_callback( - '/%\{(\w+)}/', - fn (array $matches) => (string) ($vars[$matches[1]] ?? $matches[0]), - $translation - ); - - if ($result === null) { - throw TranslationException::interpolationFailed($translation, $vars); - } - - return $result; - } - - protected function getLocale(): array - { - if ($this->locale === null) { - $this->locale = YamlParser::parseFile($this->path); - } - - return $this->locale; - } -} diff --git a/src/Support/PartialsCache.php b/src/Support/PartialsCache.php new file mode 100644 index 0000000..93929f5 --- /dev/null +++ b/src/Support/PartialsCache.php @@ -0,0 +1,45 @@ + + */ + protected array $cache = []; + + public function set(string $key, Template $value): Template + { + $this->cache[$key] = $value; + + return $value; + } + + public function get(string $key): ?Template + { + return $this->cache[$key] ?? null; + } + + public function has(string $templateName): bool + { + return isset($this->cache[$templateName]); + } + + /** + * @return array + */ + public function all(): array + { + return $this->cache; + } + + public function merge(PartialsCache $partialsCache): void + { + foreach ($partialsCache->all() as $key => $value) { + $this->cache[$key] = $value; + } + } +} diff --git a/src/Support/Str.php b/src/Support/Str.php index bb2a8dd..9c3414c 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -22,9 +22,15 @@ public static function snake(string $value, string $delimiter = '_'): string } if (! ctype_lower($value)) { - $value = preg_replace('/\s+/u', '', ucwords($value)) ?? ''; + if (($value = preg_replace('/\s+/u', '', ucwords($value))) === null) { + return $key; + } - $value = static::lower(preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $value) ?? ''); + if (($value = preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $value)) === null) { + return $key; + } + + $value = static::lower($value); } return static::$snakeCache[$key][$delimiter] = $value; @@ -135,4 +141,17 @@ public static function blank(string $value): bool { return trim($value) === ''; } + + public static function beforeFirst(string $string, array $search): string + { + $positions = array_filter(array_map(fn ($search) => strpos($string, $search), $search)); + + if ($positions === []) { + return $string; + } + + $index = min($positions); + + return self::substr($string, 0, $index); + } } diff --git a/src/Support/YamlParser.php b/src/Support/YamlParser.php deleted file mode 100644 index 3fd5ad3..0000000 --- a/src/Support/YamlParser.php +++ /dev/null @@ -1,22 +0,0 @@ -markup); - } - /** * @throws SyntaxException */ - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static - { - return $this; - } + abstract public function parse(TagParseContext $context): static; - public function render(Context $context): string - { - return ''; - } + abstract public function render(RenderContext $context): string; - protected function parseExpression(ParseContext $parseContext, string $markup): mixed - { - return $parseContext->parseExpression($markup); - } - - protected function newParser(?string $markup = null): Parser + public function blank(): bool { - return new Parser($markup ?? $this->markup); + return false; } - public function ensureTagIsEnabled(Context $context): void + /** + * @throws TagDisabledException + */ + public function ensureTagIsEnabled(RenderContext $context): void { if (! $this instanceof Disableable) { return; @@ -74,6 +38,6 @@ public function ensureTagIsEnabled(Context $context): void return; } - throw new TagDisabledException(static::tagName(), $context->locale); + throw new TagDisabledException(static::tagName()); } } diff --git a/src/TagBlock.php b/src/TagBlock.php index 4bbca8e..32254ba 100644 --- a/src/TagBlock.php +++ b/src/TagBlock.php @@ -2,74 +2,22 @@ namespace Keepsuit\Liquid; -use Keepsuit\Liquid\Nodes\BlockBodySection; -use Keepsuit\Liquid\Parse\BlockParser; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; -abstract class TagBlock extends Tag +abstract class TagBlock extends Tag implements HasParseTreeVisitorChildren { - /** - * @var array - */ - protected array $bodySections = []; - - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static - { - $this->bodySections = self::parseBody($parseContext, $tokenizer); - - return $this; - } - - /** - * @return array - */ - protected function parseBody(ParseContext $parseContext, Tokenizer $tokenizer): array + public static function blockDelimiter(): string { - return $parseContext->nested(function () use ($parseContext, $tokenizer) { - return BlockParser::forTag(static::tagName(), $this->markup) - ->subTagsHandler(fn (string $tagName) => $this->isSubTag($tagName)) - ->parse($tokenizer, $parseContext); - }); - } - - public function blank(): bool - { - foreach ($this->bodySections as $bodySection) { - if (! $bodySection->blank()) { - return false; - } - } - - return true; - } - - public function nodeList(): array - { - return $this->bodySections; - } - - public function render(Context $context): string - { - return $context->withDisabledTags($this->disabledTags(), function (Context $context) { - $output = ''; - - foreach ($this->bodySections as $bodySection) { - $output .= $bodySection->render($context); - } - - return $output; - }); + return 'end'.static::tagName(); } - protected function isSubTag(string $tagName): bool + public function isSubTag(string $tagName): bool { return false; } - protected static function blockDelimiter(): ?string + public function parseTreeVisitorChildren(): array { - return 'end'.static::tagName(); + return []; } } diff --git a/src/Tags/AssignTag.php b/src/Tags/AssignTag.php index e6a082c..ffc7727 100644 --- a/src/Tags/AssignTag.php +++ b/src/Tags/AssignTag.php @@ -4,17 +4,21 @@ use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\Nodes\TagParseContext; use Keepsuit\Liquid\Nodes\Variable; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Parse\ExpressionParser; +use Keepsuit\Liquid\Parse\TokenType; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Tag; +/** + * @phpstan-import-type Expression from ExpressionParser + */ class AssignTag extends Tag implements HasParseTreeVisitorChildren { - const Syntax = '/('.Regex::VariableSignature.')\s*=\s*(.*)\s*/m'; + protected const SYNTAX_ERROR = "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"; protected string $to; @@ -25,21 +29,28 @@ public static function tagName(): string return 'assign'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + try { + $to = $context->params->expression(); + $this->to = match (true) { + $to instanceof VariableLookup, is_string($to) => (string) $to, + default => throw new SyntaxException(self::SYNTAX_ERROR), + }; - if (preg_match(static::Syntax, $this->markup, $matches)) { - $this->to = $matches[1]; - $this->from = Variable::fromMarkup($matches[2], $parseContext->lineNumber); - } else { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.assign')); + $context->params->consume(TokenType::Equals); + + $this->from = $context->params->variable(); + + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw new SyntaxException(self::SYNTAX_ERROR); } return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $value = $this->from->evaluate($context); diff --git a/src/Tags/BreakTag.php b/src/Tags/BreakTag.php index cccaaaf..1836b64 100644 --- a/src/Tags/BreakTag.php +++ b/src/Tags/BreakTag.php @@ -3,7 +3,8 @@ namespace Keepsuit\Liquid\Tags; use Keepsuit\Liquid\Interrupts\BreakInterrupt; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class BreakTag extends Tag @@ -13,7 +14,14 @@ public static function tagName(): string return 'break'; } - public function render(Context $context): string + public function parse(TagParseContext $context): static + { + $context->params->assertEnd(); + + return $this; + } + + public function render(RenderContext $context): string { $context->pushInterrupt(new BreakInterrupt()); diff --git a/src/Tags/CaptureTag.php b/src/Tags/CaptureTag.php index 981e6a9..f54c310 100644 --- a/src/Tags/CaptureTag.php +++ b/src/Tags/CaptureTag.php @@ -3,27 +3,33 @@ namespace Keepsuit\Liquid\Tags; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TagBlock; class CaptureTag extends TagBlock { - protected const Syntax = '/('.Regex::VariableSignature.'+)/'; + protected const SYNTAX_ERROR = "Syntax Error in 'capture' - Valid syntax: capture [var]"; protected string $to; - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + protected BodyNode $body; + + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + assert($context->body !== null); + + $this->body = $context->body; + + $to = $context->params->expression(); + $this->to = match (true) { + is_string($to), $to instanceof VariableLookup => (string) $to, + default => throw new SyntaxException(self::SYNTAX_ERROR), + }; - if (preg_match(static::Syntax, $this->markup, $matches)) { - $this->to = $matches[1]; - } else { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.capture')); - } + $context->params->assertEnd(); return $this; } @@ -38,14 +44,21 @@ public function blank(): bool return true; } - public function render(Context $context): string + public function render(RenderContext $context): string { $context->resourceLimits->withCapture(function () use ($context) { - $captureValue = parent::render($context); + $captureValue = $this->body->render($context); $context->setToActiveScope($this->to, $captureValue); }); return ''; } + + public function parseTreeVisitorChildren(): array + { + return [ + $this->body, + ]; + } } diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index 43bb6f1..24fdf30 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -4,24 +4,25 @@ use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Condition\ElseCondition; -use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Nodes\BlockBodySection; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Parse\ExpressionParser; +use Keepsuit\Liquid\Parse\TokenType; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TagBlock; -class CaseTag extends TagBlock implements HasParseTreeVisitorChildren +/** + * @phpstan-import-type Expression from ExpressionParser + */ +class CaseTag extends TagBlock { - protected const Syntax = '/('.Regex::QuotedFragment.')/'; - - protected const WhenSyntax = '/('.Regex::QuotedFragment.')(?:(?:\s+or\s+|\s*\,\s*)(.*))?/m'; - /** @var Condition[] */ protected array $conditions = []; + /** + * @var Expression + */ protected mixed $left = null; public static function tagName(): string @@ -29,40 +30,38 @@ public static function tagName(): string return 'case'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); - $caseSection = array_shift($this->bodySections); - - if (preg_match(self::Syntax, $this->markup, $matches) === 1) { - $this->left = $this->parseExpression($parseContext, $matches[1]); + if ($context->tag === 'case') { + $this->left = $context->params->expression(); } else { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.case')); + $this->conditions[] = $this->mapBodySectionToCondition($context); } - $this->conditions = array_map(fn (BlockBodySection $block) => $this->parseBodySection($parseContext, $block), $this->bodySections); - return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { foreach ($this->conditions as $condition) { if ($condition->else()) { - return $condition->attachment?->render($context) ?? ''; + return $condition->body?->render($context) ?? ''; } if ($condition->evaluate($context)) { - return $condition->attachment?->render($context) ?? ''; + return $condition->body?->render($context) ?? ''; } } return ''; } - public function nodeList(): array + public function children(): array { - return array_map(fn (Condition $block) => $block->attachment, $this->conditions); + return array_filter( + array_map(fn (Condition $block) => $block->body, $this->conditions), + fn (?BodyNode $block) => $block !== null + ); } public function parseTreeVisitorChildren(): array @@ -70,51 +69,57 @@ public function parseTreeVisitorChildren(): array return [$this->left, ...$this->conditions]; } - protected function parseBodySection(ParseContext $parseContext, BlockBodySection $section): Condition + public function blank(): bool { - assert($section->startDelimiter() !== null); + foreach ($this->conditions as $condition) { + if (! $condition->body?->blank()) { + return false; + } + } - $condition = match ($section->startDelimiter()->tag) { - 'when' => $this->recordWhenCondition($parseContext, $section->startDelimiter()->markup), - 'else' => $this->recordElseCondition($parseContext, $section->startDelimiter()->markup), - default => SyntaxException::unknownTag($parseContext, $section->startDelimiter()->tag, $section->startDelimiter()->markup), - }; + return true; + } - assert($condition instanceof Condition); + protected function mapBodySectionToCondition(TagParseContext $bodySection): Condition + { + $condition = match ($bodySection->tag) { + 'when' => $this->recordWhenCondition($bodySection), + 'else' => $this->recordElseCondition($bodySection), + default => throw new SyntaxException('Unknown tag '.$bodySection->tag) + }; - if ($section->blank()) { - $section->removeBlankStrings(); + if ($bodySection->body?->blank()) { + $bodySection->body->removeBlankStrings(); } - $condition->attach($section); + + $condition->body($bodySection->body); + + $bodySection->params->assertEnd(); return $condition; } - protected function recordWhenCondition(ParseContext $parseContext, string $markup): Condition + protected function recordWhenCondition(TagParseContext $bodySection): Condition { - if (preg_match(self::WhenSyntax, $markup, $matches) !== 1) { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.case_invalid_when')); - } - - $condition = new Condition($this->left, '==', $this->parseExpression($parseContext, $matches[1])); + $condition = new Condition($this->left, '==', $bodySection->params->expression()); - if ($matches[2] ?? false) { - $condition->or($this->recordWhenCondition($parseContext, $matches[2])); + if ($bodySection->params->idOrFalse('or') || $bodySection->params->consumeOrFalse(TokenType::Comma)) { + $condition->or($this->recordWhenCondition($bodySection)); } + $bodySection->params->assertEnd(); + return $condition; } - protected function recordElseCondition(ParseContext $parseContext, string $markup): Condition + protected function recordElseCondition(TagParseContext $bodySection): Condition { - if (trim($markup) !== '') { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.case_invalid_else')); - } + $bodySection->params->assertEnd(); return new ElseCondition(); } - protected function isSubTag(string $tagName): bool + public function isSubTag(string $tagName): bool { return in_array($tagName, ['when', 'else']); } diff --git a/src/Tags/CommentTag.php b/src/Tags/CommentTag.php deleted file mode 100644 index b39ad3b..0000000 --- a/src/Tags/CommentTag.php +++ /dev/null @@ -1,48 +0,0 @@ -shift() as $token) { - if (preg_match(Regex::FullTagToken, $token, $matches) !== 1) { - continue; - } - - $tagName = $matches[2]; - if ($tagName === 'end'.static::tagName()) { - break; - } - } - - return $this; - } - - public function render(Context $context): string - { - return ''; - } - - public function blank(): bool - { - return true; - } - - public function nodeList(): array - { - return []; - } -} diff --git a/src/Tags/ContinueTag.php b/src/Tags/ContinueTag.php index 8507475..ff9041a 100644 --- a/src/Tags/ContinueTag.php +++ b/src/Tags/ContinueTag.php @@ -3,7 +3,8 @@ namespace Keepsuit\Liquid\Tags; use Keepsuit\Liquid\Interrupts\ContinueInterrupt; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class ContinueTag extends Tag @@ -13,7 +14,14 @@ public static function tagName(): string return 'continue'; } - public function render(Context $context): string + public function parse(TagParseContext $context): static + { + $context->params->assertEnd(); + + return $this; + } + + public function render(RenderContext $context): string { $context->pushInterrupt(new ContinueInterrupt()); diff --git a/src/Tags/CycleTag.php b/src/Tags/CycleTag.php index 7db9e43..7c2fe0b 100644 --- a/src/Tags/CycleTag.php +++ b/src/Tags/CycleTag.php @@ -4,46 +4,67 @@ use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; -use Keepsuit\Liquid\Support\Arr; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Parse\TokenType; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class CycleTag extends Tag implements HasParseTreeVisitorChildren { - protected const SimpleSyntax = '/\A'.Regex::QuotedFragment.'+/'; - - protected const NamedSyntax = '/\A('.Regex::QuotedFragment.')\s*\:\s*(.*)/m'; + protected const SYNTAX_ERROR = "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"; + /** + * @var (string|int|float)[] + */ protected array $variables = []; - protected mixed $name; + protected ?string $name = null; public static function tagName(): string { return 'cycle'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); - - if (preg_match(static::NamedSyntax, $this->markup, $matches)) { - $this->variables = $this->parseVariablesFromString($parseContext, $matches[2]); - $this->name = $this->parseExpression($parseContext, $matches[1]); - } elseif (preg_match(static::SimpleSyntax, $this->markup, $matches)) { - $this->variables = $this->parseVariablesFromString($parseContext, $this->markup); - $this->name = json_encode($this->variables); - } else { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.cycle')); + $this->name = null; + $this->variables = []; + + if ($context->params->look(TokenType::Colon, 1)) { + if (! in_array($context->params->current()?->type, [TokenType::String, TokenType::Number, TokenType::Identifier])) { + throw new SyntaxException(self::SYNTAX_ERROR); + } + + $name = $context->params->expression(); + $this->name = match (true) { + is_string($name), is_numeric($name), $name instanceof VariableLookup => (string) $name, + default => throw new SyntaxException(self::SYNTAX_ERROR), + }; + + $context->params->consume(TokenType::Colon); + } + + do { + if (! in_array($context->params->current()?->type, [TokenType::String, TokenType::Number])) { + throw new SyntaxException(self::SYNTAX_ERROR); + } + + $variable = $context->params->expression(); + $this->variables[] = match (true) { + is_string($variable), is_numeric($variable) => $variable, + default => throw new SyntaxException(self::SYNTAX_ERROR), + }; + } while ($context->params->consumeOrFalse(TokenType::Comma)); + + if ($this->name === null) { + $this->name = json_encode($this->variables, JSON_THROW_ON_ERROR); } return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $output = ''; @@ -75,18 +96,4 @@ public function parseTreeVisitorChildren(): array { return $this->variables; } - - protected function parseVariablesFromString(ParseContext $parseContext, string $markup): array - { - $variables = explode(',', $markup); - - $variables = array_map( - fn (string $var) => preg_match('/\s*('.Regex::QuotedFragment.')\s*/', $var, $matches) - ? $this->parseExpression($parseContext, $matches[1]) - : null, - $variables - ); - - return Arr::compact($variables); - } } diff --git a/src/Tags/DecrementTag.php b/src/Tags/DecrementTag.php index 1529f03..6aba657 100644 --- a/src/Tags/DecrementTag.php +++ b/src/Tags/DecrementTag.php @@ -2,12 +2,9 @@ namespace Keepsuit\Liquid\Tags; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; -use Keepsuit\Liquid\Tag; +use Keepsuit\Liquid\Render\RenderContext; -class DecrementTag extends Tag +class DecrementTag extends IncrementTag { protected string $variableName; @@ -16,16 +13,7 @@ public static function tagName(): string return 'decrement'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static - { - parent::parse($parseContext, $tokenizer); - - $this->variableName = trim($this->markup); - - return $this; - } - - public function render(Context $context): string + public function render(RenderContext $context): string { $counter = $context->getEnvironment($this->variableName) ?? 0; $counter -= 1; diff --git a/src/Tags/EchoTag.php b/src/Tags/EchoTag.php index 8c69cf6..c908998 100644 --- a/src/Tags/EchoTag.php +++ b/src/Tags/EchoTag.php @@ -3,21 +3,20 @@ namespace Keepsuit\Liquid\Tags; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; +use Keepsuit\Liquid\Nodes\TagParseContext; use Keepsuit\Liquid\Nodes\Variable; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class EchoTag extends Tag implements HasParseTreeVisitorChildren { protected Variable $variable; - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + $this->variable = $context->params->variable(); - $this->variable = Variable::fromMarkup($this->markup, $parseContext->lineNumber); + $context->params->assertEnd(); return $this; } @@ -37,7 +36,7 @@ public function parseTreeVisitorChildren(): array return [$this->variable]; } - public function render(Context $context): string + public function render(RenderContext $context): string { return $this->variable->render($context); } diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index 4994dee..696f1f7 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -7,64 +7,63 @@ use Keepsuit\Liquid\Exceptions\InvalidArgumentException; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Interrupts\BreakInterrupt; -use Keepsuit\Liquid\Nodes\BlockBodySection; +use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Range; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Parser; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; +use Keepsuit\Liquid\Nodes\RangeLookup; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Parse\ExpressionParser; +use Keepsuit\Liquid\Parse\TokenStream; use Keepsuit\Liquid\Parse\TokenType; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TagBlock; use Traversable; +/** + * @phpstan-import-type Expression from ExpressionParser + */ class ForTag extends TagBlock implements HasParseTreeVisitorChildren { - const Syntax = '/\A('.Regex::VariableSegment.'+)\s+in\s+('.Regex::QuotedFragment.'+)\s*(reversed)?/'; - protected string $variableName; - protected mixed $collectionName; + protected VariableLookup|RangeLookup|string $collection; protected string $name; protected bool $reversed; + /** + * @var Expression|null + */ protected mixed $from = null; + /** + * @var Expression|null + */ protected mixed $limit = null; - protected BlockBodySection $forBlock; + protected BodyNode $forBlock; - protected ?BlockBodySection $elseBlock = null; + protected ?BodyNode $elseBlock = null; public static function tagName(): string { return 'for'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); - - $this->forBlock = $this->bodySections[0]; - - if (count($this->bodySections) > 1) { - $this->elseBlock = $this->bodySections[1]; - } - - $this->parseForBlock($parseContext, $this->forBlock->startDelimiter()->markup ?? ''); - - if ($this->blank()) { - $this->forBlock->removeBlankStrings(); - $this->elseBlock?->removeBlankStrings(); - } + match ($context->tag) { + 'for' => $this->parseForBlock($context), + 'else' => $this->parseElseBlock($context), + default => throw new SyntaxException('Invalid tag'), + }; return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $segment = $this->collectionSegment($context); @@ -75,7 +74,7 @@ public function render(Context $context): string return $this->renderSegment($context, $segment); } - public function nodeList(): array + public function children(): array { return $this->elseBlock ? [$this->forBlock, $this->elseBlock] : [$this->forBlock]; } @@ -83,72 +82,48 @@ public function nodeList(): array public function parseTreeVisitorChildren(): array { return Arr::compact([ - ...$this->nodeList(), + ...$this->children(), $this->limit, $this->from, - $this->collectionName, + $this->collection, ]); } - protected function parseForBlock(ParseContext $parseContext, string $markup): void + public function blank(): bool { - $parser = new Parser($markup); - - $this->variableName = $parser->consume(TokenType::Identifier); - - if (! $parser->idOrFalse('in')) { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.for_invalid_in')); - } - - $collectionNameMarkup = $parser->expression(); - $this->collectionName = $this->parseExpression($parseContext, $collectionNameMarkup); - - $this->name = sprintf('%s-%s', $this->variableName, $collectionNameMarkup); - $this->reversed = $parser->idOrFalse('reversed') !== false; - - while ($parser->look(TokenType::Comma) || $parser->look(TokenType::Identifier)) { - $parser->consumeOrFalse(TokenType::Comma); - - $attribute = $parser->idOrFalse('limit') ?: $parser->idOrFalse('offset'); - - if (! $attribute) { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.for_invalid_attribute')); - } - - $parser->consume(TokenType::Colon); - - $this->setAttribute($parseContext, $attribute, $parser->expression()); - } - - $parser->consume(TokenType::EndOfString); + return $this->forBlock->blank() && ($this->elseBlock?->blank() ?? true); } - protected function setAttribute(ParseContext $parseContext, string $attribute, string $expression): void + protected function setAttribute(string $attribute, TokenStream $tokenStream): void { if ($attribute === 'offset') { - $this->from = $expression === 'continue' ? 'continue' : $this->parseExpression($parseContext, $expression); + $expression = $tokenStream->expression(); + $this->from = match (true) { + $expression instanceof VariableLookup, is_string($expression) => (string) $expression === 'continue' ? 'continue' : $expression, + default => $expression, + }; return; } if ($attribute === 'limit') { - $this->limit = $this->parseExpression($parseContext, $expression); + $this->limit = $tokenStream->expression(); return; } } - protected function isSubTag(string $tagName): bool + public function isSubTag(string $tagName): bool { return in_array($tagName, ['else'], true); } - protected function collectionSegment(Context $context): array + protected function collectionSegment(RenderContext $context): array { $offsets = $context->getRegister('for') ?? []; assert(is_array($offsets)); - $collection = $context->evaluate($this->collectionName) ?? []; + $collection = $context->evaluate($this->collection) ?? []; $collection = match (true) { $collection instanceof Range => $collection->toArray(), $collection instanceof Traversable => iterator_to_array($collection), @@ -161,13 +136,16 @@ protected function collectionSegment(Context $context): array $offset = $offsets[$this->name]; } else { $fromValue = $context->evaluate($this->from); - $offset = $fromValue === null ? 0 : (is_numeric($fromValue) ? (int) $fromValue : throw new InvalidArgumentException('Invalid integer')); + $offset = match (true) { + $fromValue === null => 0, + is_numeric($fromValue) => (int) $fromValue, + default => throw new InvalidArgumentException('Invalid integer'), + }; } assert(is_int($offset)); $limitValue = $context->evaluate($this->limit); $length = $limitValue === null ? null : (is_numeric($limitValue) ? (int) $limitValue : throw new InvalidArgumentException('Invalid integer')); - $segment = array_slice($collection, $offset, $length); $segment = $this->reversed ? array_reverse($segment) : $segment; @@ -177,7 +155,7 @@ protected function collectionSegment(Context $context): array return $segment; } - protected function renderSegment(Context $context, array $segment): string + protected function renderSegment(RenderContext $context, array $segment): string { /** @var ForLoopDrop[] $forStack */ $forStack = $context->getRegister('for_stack') ?? []; @@ -218,8 +196,63 @@ protected function renderSegment(Context $context, array $segment): string }); } - protected function renderElse(Context $context): string + protected function renderElse(RenderContext $context): string { return $this->elseBlock?->render($context) ?? ''; } + + protected function parseForBlock(TagParseContext $context): void + { + assert($context->body !== null); + $this->forBlock = $context->body; + + $variableName = $context->params->expression(); + $this->variableName = match (true) { + $variableName instanceof VariableLookup, is_string($variableName) => (string) $variableName, + default => throw new SyntaxException('Invalid variable name'), + }; + + if (! $context->params->idOrFalse('in')) { + throw new SyntaxException("For loops require an 'in' clause"); + } + + $collection = $context->params->expression(); + $this->collection = match (true) { + $collection instanceof VariableLookup, $collection instanceof RangeLookup, is_string($collection) => $collection, + default => throw new SyntaxException('Invalid collection'), + }; + + $this->name = sprintf('%s-%s', $this->variableName, $this->collection); + $this->reversed = $context->params->idOrFalse('reversed') !== false; + + while ($context->params->look(TokenType::Comma) || $context->params->look(TokenType::Identifier)) { + $context->params->consumeOrFalse(TokenType::Comma); + + $attribute = $context->params->idOrFalse('limit') ?: $context->params->idOrFalse('offset'); + + if (! $attribute) { + throw new SyntaxException('Invalid attribute in for loop. Valid attributes are limit and offset'); + } + + $context->params->consume(TokenType::Colon); + + $this->setAttribute($attribute->data, $context->params); + } + + $context->params->assertEnd(); + + if ($this->forBlock->blank()) { + $this->forBlock->removeBlankStrings(); + } + } + + protected function parseElseBlock(TagParseContext $context): void + { + $this->elseBlock = $context->body; + $context->params->assertEnd(); + + if ($this->elseBlock?->blank()) { + $this->elseBlock->removeBlankStrings(); + } + } } diff --git a/src/Tags/IfChanged.php b/src/Tags/IfChanged.php index e3633ae..9c7c4e3 100644 --- a/src/Tags/IfChanged.php +++ b/src/Tags/IfChanged.php @@ -2,19 +2,34 @@ namespace Keepsuit\Liquid\Tags; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TagBlock; class IfChanged extends TagBlock { + protected BodyNode $body; + public static function tagName(): string { return 'ifchanged'; } - public function render(Context $context): string + public function parse(TagParseContext $context): static + { + assert($context->body !== null); + + $this->body = $context->body; + + $context->params->assertEnd(); + + return $this; + } + + public function render(RenderContext $context): string { - $output = parent::render($context); + $output = $this->body->render($context); if ($context->getRegister('ifchanged') === $output) { return ''; diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index 2999cdc..b0ba244 100644 --- a/src/Tags/IfTag.php +++ b/src/Tags/IfTag.php @@ -4,17 +4,13 @@ use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Condition\ElseCondition; -use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; -use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Nodes\BlockBodySection; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Parser; -use Keepsuit\Liquid\Parse\Tokenizer; +use Keepsuit\Liquid\Nodes\TagParseContext; use Keepsuit\Liquid\Parse\TokenType; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TagBlock; -class IfTag extends TagBlock implements HasParseTreeVisitorChildren +class IfTag extends TagBlock { /** @var Condition[] */ protected array $conditions = []; @@ -24,28 +20,21 @@ public static function tagName(): string return 'if'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); - - try { - $this->conditions = array_map(fn (BlockBodySection $block) => $this->parseBodySection($parseContext, $block), $this->bodySections); - } catch (SyntaxException $exception) { - $exception->markupContext = $this->markup; - throw $exception; - } + $this->conditions[] = $this->mapBodySectionToCondition($context); return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $output = ''; foreach ($this->conditions as $condition) { $result = $condition->evaluate($context); if ($result) { - return $condition->attachment?->render($context) ?? ''; + return $condition->body?->render($context) ?? ''; } } @@ -57,71 +46,72 @@ public function parseTreeVisitorChildren(): array return $this->conditions; } - public function nodeList(): array + protected function mapBodySectionToCondition(TagParseContext $bodySection): Condition { - return array_map(fn (Condition $block) => $block->attachment, $this->conditions); + $condition = match ($bodySection->tag) { + 'else' => new ElseCondition(), + default => $this->parseCondition($bodySection) + }; + + if ($bodySection->body?->blank()) { + $bodySection->body->removeBlankStrings(); + } + + $condition->body($bodySection->body); + + $bodySection->params->assertEnd(); + + return $condition; } - protected function isSubTag(string $tagName): bool + public function children(): array { - return in_array($tagName, ['else', 'elsif'], true); + return Arr::compact(array_map(fn (Condition $block) => $block->body, $this->conditions)); } - /** - * @throws SyntaxException - */ - protected function parseBodySection(ParseContext $parseContext, BlockBodySection $section): Condition + public function blank(): bool { - assert($section->startDelimiter() !== null); - - $condition = match (true) { - $section->startDelimiter()->tag === 'else' => new ElseCondition(), - default => $this->parseCondition($parseContext, $section->startDelimiter()->markup), - }; - - if ($section->blank()) { - $section->removeBlankStrings(); + foreach ($this->conditions as $condition) { + if (! $condition->body?->blank()) { + return false; + } } - $condition->attach($section); - return $condition; + return true; } - /** - * @throws SyntaxException - */ - protected function parseCondition(ParseContext $parseContext, string $markup): Condition + public function isSubTag(string $tagName): bool { - $parser = new Parser($markup); - - $condition = $this->parseBinaryComparison($parseContext, $parser); - $parser->consume(TokenType::EndOfString); + return in_array($tagName, ['else', 'elsif'], true); + } - return $condition; + protected function parseCondition(TagParseContext $bodySection): Condition + { + return $this->parseBinaryComparison($bodySection); } - protected function parseBinaryComparison(ParseContext $parseContext, Parser $parser): Condition + protected function parseBinaryComparison(TagParseContext $bodySection): Condition { - $condition = $this->parseComparison($parseContext, $parser); + $condition = $this->parseComparison($bodySection); $firstCondition = $condition; - while ($operator = $parser->idOrFalse('and') ?: $parser->idOrFalse('or')) { - $childCondition = $this->parseComparison($parseContext, $parser); - $condition->{$operator}($childCondition); + while ($operator = $bodySection->params->idOrFalse('and') ?: $bodySection->params->idOrFalse('or')) { + $childCondition = $this->parseComparison($bodySection); + $condition->{$operator->data}($childCondition); $condition = $childCondition; } return $firstCondition; } - protected function parseComparison(ParseContext $parseContext, Parser $parser): Condition + protected function parseComparison(TagParseContext $bodySection): Condition { - $a = $this->parseExpression($parseContext, $parser->expression()); + $a = $bodySection->params->expression(); - if ($operator = $parser->consumeOrFalse(TokenType::Comparison)) { - $b = $this->parseExpression($parseContext, $parser->expression()); + if ($operator = $bodySection->params->consumeOrFalse(TokenType::Comparison)) { + $b = $bodySection->params->expression(); - return new Condition($a, $operator, $b); + return new Condition($a, $operator->data, $b); } else { return new Condition($a); } diff --git a/src/Tags/IncrementTag.php b/src/Tags/IncrementTag.php index 11b1dff..5a04888 100644 --- a/src/Tags/IncrementTag.php +++ b/src/Tags/IncrementTag.php @@ -2,9 +2,10 @@ namespace Keepsuit\Liquid\Tags; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class IncrementTag extends Tag @@ -16,16 +17,20 @@ public static function tagName(): string return 'increment'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + $variableName = $context->params->expression(); + $this->variableName = match (true) { + $variableName instanceof VariableLookup || is_string($variableName) => (string) $variableName, + default => throw new SyntaxException('Invalid variable name'), + }; - $this->variableName = trim($this->markup); + $context->params->assertEnd(); return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $counter = $context->getEnvironment($this->variableName) ?? -1; $counter += 1; diff --git a/src/Tags/InlineCommentTag.php b/src/Tags/InlineCommentTag.php deleted file mode 100644 index 221de03..0000000 --- a/src/Tags/InlineCommentTag.php +++ /dev/null @@ -1,18 +0,0 @@ -newTokenizer( - markup: $this->markup, - forLiquidTag: true - ); + $this->body = new BodyNode(); - $this->bodySections = BlockParser::forDocument()->parse($liquidTokenizer, $parseContext); + while (! $context->params->isEnd()) { + $this->body->pushChild($this->parseLine($context)); + } + + $context->params->assertEnd(); return $this; } - public function render(Context $context): string + protected function parseLine(TagParseContext $context): Tag + { + $tokens = $context->params; + + $currentToken = $tokens->current(); + + if ($currentToken === null) { + throw SyntaxException::unexpectedEndOfTemplate(); + } + + $context->getParseContext()->lineNumber = $currentToken->lineNumber; + + $tagName = $tokens->consume(TokenType::Identifier)->data; + + /** @var class-string|null $tagClass */ + $tagClass = $context->getParseContext()->tagRegistry->get($tagName) ?? null; + + if ($tagClass === null || ! class_exists($tagClass)) { + throw SyntaxException::unknownTag($tagName); + } + + $tag = (new $tagClass())->setLineNumber($currentToken->lineNumber); + + if ($tag instanceof TagBlock) { + $currentTagName = $tag::tagName(); + + do { + $params = $tokens->sliceUntil(fn (Token $token) => $token->lineNumber > $currentToken->lineNumber); + + $body = new BodyNode(); + + $nextTag = $this->nextToken($context, $tag::tagName())->data; + + while ($nextTag !== $tag::blockDelimiter() && ! $tag->isSubTag($nextTag)) { + $body->pushChild($this->parseLine($context)); + + $nextTag = $this->nextToken($context, $tag::tagName())->data; + } + + $tagParseContext = (new TagParseContext($currentTagName, $params, $body)) + ->setParseContext($context->getParseContext()); + + $tag->parse($tagParseContext); + + try { + $currentToken = $tokens->consume(TokenType::Identifier); + $currentTagName = $currentToken->data; + } catch (SyntaxException $e) { + throw SyntaxException::tagBlockNeverClosed($tag::tagName()); + } + } while ($currentTagName !== $tag::blockDelimiter()); + + return $tag; + } + + $params = $tokens->sliceUntil(fn (Token $token) => $token->lineNumber > $currentToken->lineNumber); + + $tagParseContext = (new TagParseContext($tagName, $params)) + ->setParseContext($context->getParseContext()); + + $tag->parse($tagParseContext); + + return $tag; + } + + public function render(RenderContext $context): string + { + return $this->body->render($context); + } + + /** + * @throws SyntaxException + */ + protected function nextToken(TagParseContext $context, string $currentTag): Token { - $output = ''; + if (! $context->params->current()) { + $context->getParseContext()->lineNumber++; - foreach ($this->bodySections as $bodySection) { - $output .= $bodySection->render($context); + throw SyntaxException::tagBlockNeverClosed($currentTag); } - return $output; + return $context->params->current(); } } diff --git a/src/Tags/RawTag.php b/src/Tags/RawTag.php deleted file mode 100644 index 9631443..0000000 --- a/src/Tags/RawTag.php +++ /dev/null @@ -1,63 +0,0 @@ -markup, $matches) !== 1) { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.tag_unexpected_args', ['tag' => static::tagName()])); - } - - $this->body = ''; - - foreach ($tokenizer->shift() as $token) { - if (preg_match(self::FullTokenPossiblyInvalid, $token, $matches) === 1 && static::blockDelimiter() === $matches[2]) { - $parseContext->trimWhitespace = $token[-3] === Regex::WhitespaceControl; - - if ($matches[1] !== '') { - $this->body .= $matches[1]; - } - - return $this; - } - $this->body .= $token; - } - - throw SyntaxException::tagNeverClosed(static::tagName(), $parseContext); - } - - public function render(Context $context): string - { - return $this->body; - } - - public function blank(): bool - { - return strlen($this->body) === 0; - } - - protected function isSubTag(string $tagName): bool - { - return true; - } -} diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index 359468d..d6ba531 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -4,27 +4,31 @@ use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Drops\ForLoopDrop; -use Keepsuit\Liquid\Exceptions\InvalidArgumentException; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Parse\ExpressionParser; +use Keepsuit\Liquid\Parse\TokenType; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\Template; use Traversable; +/** + * @phpstan-import-type Expression from ExpressionParser + */ class RenderTag extends Tag implements HasParseTreeVisitorChildren { - protected const Syntax = '/('.Regex::QuotedString.'+)(\s+(with|for)\s+('.Regex::QuotedFragment.'+))?(\s+(?:as)\s+('.Regex::VariableSegment.'+))?/'; - protected string $templateNameExpression; protected mixed $variableNameExpression; protected ?string $aliasName; + /** + * @var array + */ protected array $attributes = []; protected bool $isForLoop; @@ -34,35 +38,54 @@ public static function tagName(): string return 'render'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - return $parseContext->nested(function () use ($parseContext) { - if (! preg_match(static::Syntax, $this->markup, $matches)) { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.render')); + $this->isForLoop = false; + $this->variableNameExpression = null; + + $context->getParseContext()->nested(function () use ($context) { + $templateNameExpression = $context->params->expression(); + $this->templateNameExpression = match (true) { + is_string($templateNameExpression) => $templateNameExpression, + default => throw new SyntaxException('Template name must be a string'), + }; + + if ($context->params->idOrFalse('for')) { + $this->isForLoop = true; + $this->variableNameExpression = $context->params->expression(); + } elseif ($context->params->idOrFalse('with')) { + $this->variableNameExpression = $context->params->expression(); } - $templateNameExpression = $this->parseExpression($parseContext, $matches[1]); - if (! is_string($templateNameExpression)) { - throw new InvalidArgumentException('Template name must be a string'); + if ($context->params->idOrFalse('as')) { + $aliasName = $context->params->expression(); + $this->aliasName = match (true) { + is_string($aliasName), $aliasName instanceof VariableLookup => (string) $aliasName, + default => throw new SyntaxException('Alias name must be a valid variable name'), + }; } - $this->templateNameExpression = $templateNameExpression; - $this->aliasName = $matches[6] ?? null; - $this->variableNameExpression = ($matches[4] ?? null) ? $this->parseExpression($parseContext, $matches[4]) : null; - $this->isForLoop = ($matches[3] ?? null) === 'for'; + while ($context->params->consumeOrFalse(TokenType::Comma)) { + $attributeName = $context->params->expression(); + if (! (is_string($attributeName) || $attributeName instanceof VariableLookup)) { + throw new SyntaxException('Attribute name must be a valid variable name'); + } + + $context->params->consume(TokenType::Colon); + $attributeValue = $context->params->expression(); - preg_match_all(sprintf('/%s/', Regex::TagAttributes), $this->markup, $attributeMatches, PREG_SET_ORDER); - foreach ($attributeMatches as $matches) { - $this->attributes[$matches[1]] = $this->parseExpression($parseContext, $matches[2]); + $this->attributes[(string) $attributeName] = $attributeValue; } - $parseContext->loadPartial($this->templateNameExpression); + $context->params->assertEnd(); - return $this; + $context->getParseContext()->loadPartial($this->templateNameExpression); }); + + return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $partial = $context->loadPartial($this->templateNameExpression); @@ -108,7 +131,7 @@ public function parseTreeVisitorChildren(): array ]; } - protected function buildPartialContext(Template $partial, Context $context, array $variables = []): Context + protected function buildPartialContext(Template $partial, RenderContext $context, array $variables = []): RenderContext { $innerContext = $context->newIsolatedSubContext($partial->name); diff --git a/src/Tags/TableRowTag.php b/src/Tags/TableRowTag.php index 30c1b0c..12e78d1 100644 --- a/src/Tags/TableRowTag.php +++ b/src/Tags/TableRowTag.php @@ -2,55 +2,55 @@ namespace Keepsuit\Liquid\Tags; -use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Drops\TableRowLoopDrop; use Keepsuit\Liquid\Exceptions\InvalidArgumentException; -use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Range; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Regex; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Parse\TokenType; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TagBlock; use Traversable; -class TableRowTag extends TagBlock implements HasParseTreeVisitorChildren +class TableRowTag extends TagBlock { - const Syntax = '/(\w+)\s+in\s+('.Regex::QuotedFragment.'+)/'; - protected string $variableName; protected mixed $collectionName; protected array $attributes = []; + protected BodyNode $body; + public static function tagName(): string { return 'tablerow'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + assert($context->body !== null); - if (! preg_match(self::Syntax, $this->markup, $matches)) { - throw new SyntaxException($parseContext->locale->translate('errors.syntax.table_row')); - } + $this->body = $context->body; - $this->variableName = $matches[1]; - $this->collectionName = $this->parseExpression($parseContext, $matches[2]); + $this->variableName = $context->params->consume(TokenType::Identifier)->data; + $context->params->id('in'); + $this->collectionName = $context->params->expression(); - preg_match_all(sprintf('/%s/', Regex::TagAttributes), $this->markup, $attributeMatches, PREG_SET_ORDER); - - foreach ($attributeMatches as $matches) { - $this->attributes[$matches[1]] = $this->parseExpression($parseContext, $matches[2]); + while ($context->params->look(TokenType::Identifier)) { + $attribute = $context->params->consume(TokenType::Identifier)->data; + $context->params->consume(TokenType::Colon); + $value = $context->params->expression(); + $this->attributes[$attribute] = $value; } + $context->params->assertEnd(); + return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $collection = $context->evaluate($this->collectionName) ?? []; $collection = match (true) { @@ -88,7 +88,7 @@ public function render(Context $context): string $context->set($this->variableName, $item); $output .= sprintf('', $tableRowLoop->col); - $output .= parent::render($context); + $output .= $this->body->render($context); $output .= ''; if ($tableRowLoop->col_last && ! $tableRowLoop->last) { @@ -106,10 +106,10 @@ public function render(Context $context): string public function parseTreeVisitorChildren(): array { - return [ - ...$this->nodeList(), + return Arr::compact([ + $this->body, ...$this->attributes, $this->collectionName, - ]; + ]); } } diff --git a/src/Tags/UnlessTag.php b/src/Tags/UnlessTag.php index 8535880..cca9a7a 100644 --- a/src/Tags/UnlessTag.php +++ b/src/Tags/UnlessTag.php @@ -3,9 +3,8 @@ namespace Keepsuit\Liquid\Tags; use Keepsuit\Liquid\Condition\Condition; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Render\RenderContext; class UnlessTag extends IfTag { @@ -16,20 +15,23 @@ public static function tagName(): string return 'unless'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); + parent::parse($context); - $this->unlessCondition = array_shift($this->conditions); + if ($context->tag === static::tagName()) { + $this->unlessCondition = array_shift($this->conditions); + } return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { $result = $this->unlessCondition?->evaluate($context); + if (! $result) { - return $this->unlessCondition?->attachment?->render($context) ?? ''; + return $this->unlessCondition?->body?->render($context) ?? ''; } return parent::render($context); diff --git a/src/Template.php b/src/Template.php index 88c34d4..c05b38d 100644 --- a/src/Template.php +++ b/src/Template.php @@ -2,11 +2,12 @@ namespace Keepsuit\Liquid; +use Keepsuit\Liquid\Exceptions\InternalException; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Parse\ParseContext; use Keepsuit\Liquid\Profiler\Profiler; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; class Template { @@ -27,30 +28,35 @@ public function __construct( public static function parse(ParseContext $parseContext, string $source, ?string $name = null): Template { try { - $tokenizer = $parseContext->newTokenizer($source); - $root = Document::parse($parseContext, $tokenizer); + $root = $parseContext->parse($parseContext->tokenize($source)); $template = new Template( - root: $root, + root: new Document($root), name: $name, ); if (! $parseContext->isPartial()) { - $template->state->partialsCache = $parseContext->getPartialsCache(); + $template->state->partialsCache = $parseContext->getPartialsCache()->all(); $template->state->outputs = $parseContext->getOutputs()->all(); } return $template; } catch (LiquidException $e) { $e->templateName = $e->templateName ?? $name; + $e->lineNumber = $e->lineNumber ?? $parseContext->lineNumber; throw $e; + } catch (\Throwable $e) { + $exception = new InternalException($e); + $exception->templateName = $exception->templateName ?? $name; + $exception->lineNumber = $exception->lineNumber ?? $parseContext->lineNumber; + throw $exception; } } /** * @throws LiquidException */ - public function render(Context $context): string + public function render(RenderContext $context): string { $this->profiler = $context->getProfiler(); diff --git a/src/TemplateFactory.php b/src/TemplateFactory.php index 8c421ba..5a0bd82 100644 --- a/src/TemplateFactory.php +++ b/src/TemplateFactory.php @@ -7,10 +7,9 @@ use Keepsuit\Liquid\FileSystems\BlankFileSystem; use Keepsuit\Liquid\Filters\StandardFilters; use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\Support\FilterRegistry; -use Keepsuit\Liquid\Support\I18n; use Keepsuit\Liquid\Support\TagRegistry; final class TemplateFactory @@ -23,12 +22,8 @@ final class TemplateFactory protected ResourceLimits $resourceLimits; - protected I18n $locale; - protected bool $profile = false; - protected bool $lineNumbers = false; - protected bool $rethrowExceptions = false; protected bool $strictVariables = false; @@ -39,7 +34,6 @@ public function __construct() $this->filterRegistry = $this->buildFilterRegistry(); $this->fileSystem = new BlankFileSystem(); $this->resourceLimits = new ResourceLimits(); - $this->locale = new I18n(); } public static function new(): TemplateFactory @@ -81,18 +75,6 @@ public function getFilterRegistry(): FilterRegistry return $this->filterRegistry; } - public function setLocale(I18n $locale): TemplateFactory - { - $this->locale = $locale; - - return $this; - } - - public function getLocale(): I18n - { - return $this->locale; - } - public function profile(bool $profile = true): TemplateFactory { $this->profile = $profile; @@ -100,13 +82,6 @@ public function profile(bool $profile = true): TemplateFactory return $this; } - public function lineNumbers(bool $lineNumbers = true): TemplateFactory - { - $this->lineNumbers = $lineNumbers; - - return $this; - } - public function rethrowExceptions(bool $rethrowExceptions = true): TemplateFactory { $this->rethrowExceptions = $rethrowExceptions; @@ -126,7 +101,6 @@ public function strictVariables(bool $strictVariables = true): TemplateFactory */ public function debugMode(bool $debugMode = true): TemplateFactory { - $this->lineNumbers = $debugMode; $this->rethrowExceptions = $debugMode; $this->strictVariables = $debugMode; @@ -136,27 +110,36 @@ public function debugMode(bool $debugMode = true): TemplateFactory public function newParseContext(): ParseContext { return new ParseContext( - startLineNumber: $this->lineNumbers || $this->profile, tagRegistry: $this->tagRegistry, fileSystem: $this->fileSystem, - locale: $this->locale, ); } public function newRenderContext( - /** @var array $environment */ + /** + * Environment variables only available in the current context + * + * @var array $environment + */ array $environment = [], - /** @var array $staticEnvironment */ + /** + * Environment variables that are shared with all sub-contexts + * + * @var array $staticEnvironment + */ array $staticEnvironment = [], - /** @var array $outerScope */ - array $outerScope = [], - /** @var array $registers */ + /** + * Registers allows to provide/export data or utilities inside tags + * Registers are not accessible as variables. + * Registers are shared with all sub-contexts + * + * @var array $registers + */ array $registers = [], - ): Context { - return new Context( + ): RenderContext { + return new RenderContext( environment: $environment, staticEnvironment: $staticEnvironment, - outerScope: $outerScope, registers: $registers, rethrowExceptions: $this->rethrowExceptions, strictVariables: $this->strictVariables, @@ -164,7 +147,6 @@ public function newRenderContext( filterRegistry: $this->filterRegistry, resourceLimits: $this->resourceLimits, fileSystem: $this->fileSystem, - locale: $this->locale, ); } @@ -213,7 +195,6 @@ protected function buildTagRegistry(): TagRegistry ->register(Tags\BreakTag::class) ->register(Tags\CaptureTag::class) ->register(Tags\CaseTag::class) - ->register(Tags\CommentTag::class) ->register(Tags\ContinueTag::class) ->register(Tags\CycleTag::class) ->register(Tags\DecrementTag::class) @@ -222,9 +203,7 @@ protected function buildTagRegistry(): TagRegistry ->register(Tags\IfChanged::class) ->register(Tags\IfTag::class) ->register(Tags\IncrementTag::class) - ->register(Tags\InlineCommentTag::class) ->register(Tags\LiquidTag::class) - ->register(Tags\RawTag::class) ->register(Tags\RenderTag::class) ->register(Tags\TableRowTag::class) ->register(Tags\UnlessTag::class); diff --git a/tests/Integration/BlockTest.php b/tests/Integration/BlockTest.php index e3c7a98..2302389 100644 --- a/tests/Integration/BlockTest.php +++ b/tests/Integration/BlockTest.php @@ -8,7 +8,7 @@ test('unexpected end tag', function () { expect(fn () => renderTemplate('{% if true %}{% endunless %}')) - ->toThrow(SyntaxException::class, "'endunless' is not a valid delimiter for if tags. use endif"); + ->toThrow(SyntaxException::class, "'endunless' is not a valid delimiter for if tag. use endif"); }); test('with custom tag block', function () { @@ -20,13 +20,3 @@ factory: $this->templateFactory ); }); - -test('custom tag block have a default render method', function () { - $this->templateFactory->registerTag(\Keepsuit\Liquid\Tests\Stubs\TestTagBlockTag::class); - - assertTemplateResult( - ' bla ', - '{% testblock %} bla {% endtestblock %}', - factory: $this->templateFactory - ); -}); diff --git a/tests/Integration/ContextTest.php b/tests/Integration/ContextTest.php index b6ff520..5aecfd2 100644 --- a/tests/Integration/ContextTest.php +++ b/tests/Integration/ContextTest.php @@ -1,7 +1,7 @@ context = new Context(); + $this->context = new RenderContext(); }); test('variables', function () { @@ -174,11 +174,6 @@ assertTemplateResult('element151cm', '{{ product["variants"].last["title"] }}', $assigns); }); -test('access variable with hash notation', function () { - assertTemplateResult('baz', '{{ ["foo"] }}', ['foo' => 'baz']); - assertTemplateResult('baz', '{{ [bar] }}', ['foo' => 'baz', 'bar' => 'foo']); -}); - test('access hashes with hash access variables', function () { $assigns = [ 'var' => 'tags', @@ -190,12 +185,12 @@ assertTemplateResult('freestyle', '{{ products[nested.var].last }}', $assigns); }); -test('hash notation only for hash access', function () { - $assigns = ['array' => [1, 2, 3, 4, 5]]; - assertTemplateResult('1', '{{ array.first }}', $assigns); - assertTemplateResult('pass', '{% if array["first"] == nil %}pass{% endif %}', $assigns); +test('hash notation for lookup filters', function () { + assertTemplateResult('1', '{{ value.first }}', ['value' => [1, 2, 3, 4, 5]]); + assertTemplateResult('1', '{{ value["first"] }}', ['value' => [1, 2, 3, 4, 5]]); - assertTemplateResult('Hello', '{{ hash["first"] }}', ['hash' => ['first' => 'Hello']]); + assertTemplateResult('Hello', '{{ value["first"] }}', ['value' => ['first' => 'Hello']]); + assertTemplateResult('', '{{ value["first"] }}', ['value' => ['key' => 'value']]); }); test('first can appear in middle of call chain', function () { @@ -343,7 +338,7 @@ function () use (&$global) { test('access to context from closure', function () { $this->context->setRegister('magic', 3445392); - $this->context->set('closure', fn (Context $context) => $context->getRegister('magic')); + $this->context->set('closure', fn (RenderContext $context) => $context->getRegister('magic')); expect($this->context->get('closure'))->toBe(3445392); }); @@ -356,9 +351,9 @@ function () use (&$global) { }); test('context initialization with a closure in environment', function () { - $context = new Context( + $context = new RenderContext( environment: [ - 'test' => fn (Context $c) => $c->get('poutine'), + 'test' => fn (RenderContext $c) => $c->get('poutine'), ], staticEnvironment: [ 'poutine' => 'fries', @@ -369,7 +364,7 @@ function () use (&$global) { }); test('staticEnvironment has lower priority then environment', function () { - $context = new Context( + $context = new RenderContext( environment: [ 'shadowed' => 'dynamic', ], @@ -384,7 +379,7 @@ function () use (&$global) { }); test('new isolated subcontext does not inherit variables', function () { - $context = new Context(); + $context = new RenderContext(); $context->set('my_variable', 'some value'); $subContext = $context->newIsolatedSubContext('sub'); @@ -392,21 +387,21 @@ function () use (&$global) { }); test('new isolated subcontext inherit static environments', function () { - $context = new Context(staticEnvironment: ['my_env_value' => 'some value']); + $context = new RenderContext(staticEnvironment: ['my_env_value' => 'some value']); $subContext = $context->newIsolatedSubContext('sub'); expect($subContext->get('my_env_value'))->toBe('some value'); }); test('new isolated subcontext does inherit static registers', function () { - $context = new Context(registers: ['my_register' => 'my value']); + $context = new RenderContext(registers: ['my_register' => 'my value']); $subContext = $context->newIsolatedSubContext('sub'); expect($subContext->getRegister('my_register'))->toBe('my value'); }); test('new isolated subcontext does not inherit non static registers', function () { - $context = new Context(registers: ['my_register' => 'my value']); + $context = new RenderContext(registers: ['my_register' => 'my value']); $context->setRegister('my_register', 'my alt value'); $subContext = $context->newIsolatedSubContext('sub'); @@ -414,7 +409,7 @@ function () use (&$global) { }); test('new isolated subcontext registers do not pollute context', function () { - $context = new Context(registers: ['my_register' => 'my value']); + $context = new RenderContext(registers: ['my_register' => 'my value']); $subContext = $context->newIsolatedSubContext('sub'); $subContext->setRegister('my_register', 'my alt value'); @@ -423,7 +418,7 @@ function () use (&$global) { test('new isolated subcontext inherit resource limits', function () { $resourceLimits = new \Keepsuit\Liquid\Render\ResourceLimits(); - $context = new Context(resourceLimits: $resourceLimits); + $context = new RenderContext(resourceLimits: $resourceLimits); $subContext = $context->newIsolatedSubContext('sub'); expect($subContext->resourceLimits)->toBe($resourceLimits); @@ -431,7 +426,7 @@ function () use (&$global) { test('new isolated subcontext inherit file system', function () { $fileSystem = new \Keepsuit\Liquid\Tests\Stubs\StubFileSystem(); - $context = new Context(fileSystem: $fileSystem); + $context = new RenderContext(fileSystem: $fileSystem); $subContext = $context->newIsolatedSubContext('sub'); expect($subContext->fileSystem)->toBe($fileSystem); @@ -447,7 +442,7 @@ function () use (&$global) { }); test('disabled specified tags', function () { - $this->context->withDisabledTags(['foo', 'bar'], function (Context $context) { + $this->context->withDisabledTags(['foo', 'bar'], function (RenderContext $context) { expect($context) ->tagDisabled('foo')->toBe(true) ->tagDisabled('bar')->toBe(true) @@ -456,19 +451,19 @@ function () use (&$global) { }); test('disabled nested tags', function () { - $this->context->withDisabledTags(['foo'], function (Context $context) { - $context->withDisabledTags(['foo'], function (Context $context) { + $this->context->withDisabledTags(['foo'], function (RenderContext $context) { + $context->withDisabledTags(['foo'], function (RenderContext $context) { expect($context) ->tagDisabled('foo')->toBe(true) ->tagDisabled('bar')->toBe(false); }); - $context->withDisabledTags(['bar'], function (Context $context) { + $context->withDisabledTags(['bar'], function (RenderContext $context) { expect($context) ->tagDisabled('foo')->toBe(true) ->tagDisabled('bar')->toBe(true); - $context->withDisabledTags(['foo'], function (Context $context) { + $context->withDisabledTags(['foo'], function (RenderContext $context) { expect($context) ->tagDisabled('foo')->toBe(true) ->tagDisabled('bar')->toBe(true); diff --git a/tests/Integration/DocumentTest.php b/tests/Integration/DocumentTest.php index c7809e3..7a2ab52 100644 --- a/tests/Integration/DocumentTest.php +++ b/tests/Integration/DocumentTest.php @@ -1,10 +1,5 @@ new ContextDrop(), 's' => fn (Context $context) => $context->get('context.scopes')]))->toBe('1'); - expect(renderTemplate('{%for i in dummy%}{{ s }}{%endfor%}', ['context' => new ContextDrop(), 's' => fn (Context $context) => $context->get('context.scopes'), 'dummy' => [1]]))->toBe('2'); - expect(renderTemplate('{%for i in dummy%}{%for i in dummy%}{{ s }}{%endfor%}{%endfor%}', ['context' => new ContextDrop(), 's' => fn (Context $context) => $context->get('context.scopes'), 'dummy' => [1]]))->toBe('3'); + expect(renderTemplate('{{ s }}', ['context' => new ContextDrop(), 's' => fn (RenderContext $context) => $context->get('context.scopes')]))->toBe('1'); + expect(renderTemplate('{%for i in dummy%}{{ s }}{%endfor%}', ['context' => new ContextDrop(), 's' => fn (RenderContext $context) => $context->get('context.scopes'), 'dummy' => [1]]))->toBe('2'); + expect(renderTemplate('{%for i in dummy%}{%for i in dummy%}{{ s }}{%endfor%}{%endfor%}', ['context' => new ContextDrop(), 's' => fn (RenderContext $context) => $context->get('context.scopes'), 'dummy' => [1]]))->toBe('3'); }); test('scope with assign', function () { diff --git a/tests/Integration/ErrorHandlingTest.php b/tests/Integration/ErrorHandlingTest.php index f3ba993..6cc536d 100644 --- a/tests/Integration/ErrorHandlingTest.php +++ b/tests/Integration/ErrorHandlingTest.php @@ -4,7 +4,7 @@ use Keepsuit\Liquid\Exceptions\StackLevelException; use Keepsuit\Liquid\Exceptions\StandardException; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TemplateFactory; use Keepsuit\Liquid\Tests\Stubs\ErrorDrop; use Keepsuit\Liquid\Tests\Stubs\StubFileSystem; @@ -42,30 +42,30 @@ }); test('standard error', function () { - $template = parseTemplate(' {{ errors.standard_error }} ', lineNumbers: false); + $template = parseTemplate(' {{ errors.standard_error }} '); - expect($template->render(new Context(staticEnvironment: ['errors' => new ErrorDrop()]))) - ->toBe(' Liquid error: Standard error '); + expect($template->render(new RenderContext(staticEnvironment: ['errors' => new ErrorDrop()]))) + ->toBe(' Liquid error (line 1): Standard error '); expect($template->getErrors())->toHaveCount(1); expect($template->getErrors()[0])->toBeInstanceOf(StandardException::class); }); test('syntax error', function () { - $template = parseTemplate(' {{ errors.syntax_error }} ', lineNumbers: false); + $template = parseTemplate(' {{ errors.syntax_error }} '); - expect($template->render(new Context(staticEnvironment: ['errors' => new ErrorDrop()]))) - ->toBe(' Liquid syntax error: Syntax error '); + expect($template->render(new RenderContext(staticEnvironment: ['errors' => new ErrorDrop()]))) + ->toBe(' Liquid syntax error (line 1): Syntax error '); expect($template->getErrors())->toHaveCount(1); expect($template->getErrors()[0])->toBeInstanceOf(SyntaxException::class); }); test('argument error', function () { - $template = parseTemplate(' {{ errors.argument_error }} ', lineNumbers: false); + $template = parseTemplate(' {{ errors.argument_error }} '); - expect($template->render(new Context(staticEnvironment: ['errors' => new ErrorDrop()]))) - ->toBe(' Liquid error: Argument error '); + expect($template->render(new RenderContext(staticEnvironment: ['errors' => new ErrorDrop()]))) + ->toBe(' Liquid error (line 1): Argument error '); expect($template->getErrors())->toHaveCount(1); expect($template->getErrors()[0])->toBeInstanceOf(\Keepsuit\Liquid\Exceptions\InvalidArgumentException::class); @@ -84,7 +84,7 @@ test('with line numbers adds numbers to parser errors', function () { assertMatchSyntaxError( - "Liquid syntax error (line 3): Unknown tag '{% \"cat\" | foobar %}'", + 'Liquid syntax error (line 3): A block must start with a tag name.', <<<'LIQUID' foobar @@ -97,7 +97,7 @@ test('with line numbers adds numbers to parser errors with whitespace trim', function () { assertMatchSyntaxError( - "Liquid syntax error (line 3): Unknown tag '{%- \"cat\" | foobar -%}'", + 'Liquid syntax error (line 3): A block must start with a tag name.', <<<'LIQUID' foobar @@ -120,11 +120,10 @@ bla LIQUID, - lineNumbers: true ); } catch (SyntaxException $exception) { expect($exception->toLiquidErrorMessage()) - ->toBe('Liquid syntax error (line 4): Unexpected character ! in "1 =! 2"'); + ->toBe('Liquid syntax error (line 4): Unexpected character !'); return; } @@ -149,20 +148,20 @@ test('strict error messages', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Unexpected character ! in "1 =! 2"', + 'Liquid syntax error (line 1): Unexpected character !', ' {% if 1 =! 2 %}ok{% endif %} ', ); assertMatchSyntaxError( - 'Liquid syntax error (line 1): Unexpected character % in "{{%%%}}"', + 'Liquid syntax error (line 1): Unexpected character %', '{{%%%}}', ); }); test('default exception renderer with internal error', function () { - $template = parseTemplate('This is a runtime error: {{ errors.runtime_error }}', lineNumbers: true); + $template = parseTemplate('This is a runtime error: {{ errors.runtime_error }}'); - $output = $template->render(new Context(staticEnvironment: ['errors' => new ErrorDrop()])); + $output = $template->render(new RenderContext(staticEnvironment: ['errors' => new ErrorDrop()])); expect($output)->toBe('This is a runtime error: Liquid error (line 1): Internal exception'); expect($template->getErrors()) diff --git a/tests/Integration/ParsingQuirksTest.php b/tests/Integration/ParsingQuirksTest.php index 7079098..45f50eb 100644 --- a/tests/Integration/ParsingQuirksTest.php +++ b/tests/Integration/ParsingQuirksTest.php @@ -7,21 +7,21 @@ test('throw exception on single close bracket', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Variable \'{{method}\' was not properly terminated with regexp: }}', + 'Liquid syntax error (line 1): Unexpected character }', 'text {{method} oh nos!' ); }); test('throw exception on label and no close bracket', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Variable \'{{\' was not properly terminated with regexp: }}', + 'Liquid syntax error (line 1): Variable was not properly terminated with: }}', 'TEST {{ ' ); }); test('throw exception on label and no close bracket percent', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Tag \'{%\' was not properly terminated with regexp: %}', + 'Liquid syntax error (line 1): Tag was not properly terminated with: %}', 'TEST {% ' ); }); @@ -29,36 +29,36 @@ test('throw exception on empty filter', function () { assertTemplateResult('', '{{test}}'); assertMatchSyntaxError( - 'Liquid syntax error (line 1): | is not a valid expression in "{{|test}}"', + 'Liquid syntax error (line 1): | is not a valid expression', '{{|test}}' ); assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected Identifier, got EndOfString in "{{test |a|b|}}"', + 'Liquid syntax error (line 1): Expected Identifier, got }}', '{{test |a|b|}}' ); }); test('meaningless parens error', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected DotDot, got Comparison in "a == \'foo\' or (b == \'bar\' and c == \'baz\') or false"', + 'Liquid syntax error (line 1): Invalid range syntax, correct syntax is (start..end)', "{% if a == 'foo' or (b == 'bar' and c == 'baz') or false %} YES {% endif %}" ); }); test('unexpected characters', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Unexpected character & in "true && false"', + 'Liquid syntax error (line 1): Unexpected character &', '{% if true && false %} YES {% endif %}' ); assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected EndOfString, got Pipe in "true || false"', + 'Liquid syntax error (line 1): Unexpected token |: "|"', '{% if true || false %} YES {% endif %}' ); }); test('throw exception on invalid tag delimiter', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Unexpected outer \'end\' tag', + 'Liquid syntax error (line 1): Unknown tag \'end\'', '{% end %}' ); }); @@ -66,9 +66,3 @@ test('blank variable markup', function () { assertTemplateResult('', '{{}}'); }); - -test('lookup on var with literal name', function () { - $assigns = ['blank' => ['x' => 'result']]; - assertTemplateResult('result', '{{ blank.x }}', $assigns); - assertTemplateResult('result', "{{ blank['x'] }}", $assigns); -}); diff --git a/tests/Integration/ProfilerTest.php b/tests/Integration/ProfilerTest.php index 40e2b9a..437fec2 100644 --- a/tests/Integration/ProfilerTest.php +++ b/tests/Integration/ProfilerTest.php @@ -2,7 +2,7 @@ use Keepsuit\Liquid\Profiler\Profiler; use Keepsuit\Liquid\Profiler\Timing; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TemplateFactory; use Keepsuit\Liquid\Tests\Stubs\ProfilingFileSystem; @@ -17,10 +17,10 @@ test('context allows flagging profiling', function () { $template = parseTemplate("{{ 'a string' | upcase }}"); - $template->render(new Context()); + $template->render(new RenderContext()); expect($template->getProfiler())->toBeNull(); - $template->render(new Context(profile: true)); + $template->render(new RenderContext(profile: true)); expect($template->getProfiler())->toBeInstanceOf(Profiler::class); }); @@ -29,8 +29,7 @@ expect($profiler->getTiming()) ->toBeInstanceOf(Timing::class) - ->getChildren()->toHaveCount(1) - ->getChildren()->{0}->code->toBe(" 'a string' | upcase "); + ->getChildren()->toHaveCount(1); }); test('profiler ignore raw strings', function () { @@ -81,8 +80,8 @@ }); test('profiling multiple renders', function () { - $context = new Context(profile: true, fileSystem: new ProfilingFileSystem()); - $template = parseTemplate('{% sleep 0.001 %}', lineNumbers: true, factory: $this->templateFactory); + $context = new RenderContext(profile: true, fileSystem: new ProfilingFileSystem()); + $template = parseTemplate('{% sleep 0.001 %}', factory: $this->templateFactory); invade($context)->templateName = 'index'; $template->render($context); diff --git a/tests/Integration/Tags/AssignTagTest.php b/tests/Integration/Tags/AssignTagTest.php index 156438e..e8b3a08 100644 --- a/tests/Integration/Tags/AssignTagTest.php +++ b/tests/Integration/Tags/AssignTagTest.php @@ -2,7 +2,7 @@ use Keepsuit\Liquid\Exceptions\ResourceLimitException; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\TemplateFactory; @@ -42,7 +42,7 @@ ->toThrow(SyntaxException::class, 'assign'); expect(fn () => renderTemplate("{% assign foo = ('X' | downcase) %}")) - ->toThrow(SyntaxException::class, 'Expected DotDot, got Pipe'); + ->toThrow(SyntaxException::class, 'assign'); }); test('expression with whitespace in square brackets', function () { @@ -56,11 +56,11 @@ test('assign score exceeding resource limit', function () { $template = parseTemplate('{% assign foo = 42 %}{% assign bar = 23 %}'); - $context = new Context(rethrowExceptions: true, resourceLimits: new ResourceLimits(assignScoreLimit: 1)); + $context = new RenderContext(rethrowExceptions: true, resourceLimits: new ResourceLimits(assignScoreLimit: 1)); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); expect($context->resourceLimits->reached())->toBeTrue(); - $context = new Context(rethrowExceptions: true, resourceLimits: new ResourceLimits(assignScoreLimit: 2)); + $context = new RenderContext(rethrowExceptions: true, resourceLimits: new ResourceLimits(assignScoreLimit: 2)); expect($template->render($context))->toBe(''); expect($context->resourceLimits->reached())->toBeFalse(); expect($context->resourceLimits->getAssignScore())->toBe(2); @@ -104,7 +104,7 @@ function assignScoreOf(mixed $value): int { - $context = new Context(rethrowExceptions: true, staticEnvironment: ['value' => $value]); + $context = new RenderContext(rethrowExceptions: true, staticEnvironment: ['value' => $value]); parseTemplate('{% assign obj = value %}')->render($context); return $context->resourceLimits->getAssignScore(); diff --git a/tests/Integration/Tags/CaptureTagTest.php b/tests/Integration/Tags/CaptureTagTest.php index c3ec562..505f011 100644 --- a/tests/Integration/Tags/CaptureTagTest.php +++ b/tests/Integration/Tags/CaptureTagTest.php @@ -1,6 +1,6 @@ render($context); expect($context->resourceLimits->getAssignScore())->toBe(9); }); diff --git a/tests/Integration/Tags/DisableableCustomTagTest.php b/tests/Integration/Tags/DisableableCustomTagTest.php index c9d76b9..71cefc5 100644 --- a/tests/Integration/Tags/DisableableCustomTagTest.php +++ b/tests/Integration/Tags/DisableableCustomTagTest.php @@ -1,12 +1,15 @@ templateFactory = \Keepsuit\Liquid\TemplateFactory::new() + $this->templateFactory = TemplateFactory::new() ->registerTag(CustomTag::class) ->registerTag(Custom2Tag::class); }); @@ -32,10 +35,15 @@ public static function tagName(): string return 'custom'; } - public function render(Context $context): string + public function render(RenderContext $context): string { return static::tagName(); } + + public function parse(TagParseContext $context): static + { + return $this; + } } class Custom2Tag extends Tag implements Disableable @@ -45,34 +53,57 @@ public static function tagName(): string return 'custom2'; } - public function render(Context $context): string + public function render(RenderContext $context): string { return static::tagName(); } + + public function parse(TagParseContext $context): static + { + return $this; + } } class DisableCustomTag extends TagBlock { + protected ?BodyNode $body; + public static function tagName(): string { return 'disable'; } - public function disabledTags(): array + public function parse(TagParseContext $context): static + { + $this->body = $context->body; + + return $this; + } + + public function render(RenderContext $context): string { - return ['custom']; + return $context->withDisabledTags(['custom'], fn () => $this->body?->render($context) ?? ''); } } class DisableBothTag extends TagBlock { + protected ?BodyNode $body; + public static function tagName(): string { return 'disable'; } - public function disabledTags(): array + public function parse(TagParseContext $context): static + { + $this->body = $context->body; + + return $this; + } + + public function render(RenderContext $context): string { - return ['custom', 'custom2']; + return $context->withDisabledTags(['custom', 'custom2'], fn () => $this->body?->render($context) ?? ''); } } diff --git a/tests/Integration/Tags/ForTagTest.php b/tests/Integration/Tags/ForTagTest.php index 58e84ca..a4a649a 100644 --- a/tests/Integration/Tags/ForTagTest.php +++ b/tests/Integration/Tags/ForTagTest.php @@ -2,7 +2,7 @@ use Keepsuit\Liquid\Exceptions\InvalidArgumentException; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tests\Stubs\ErrorDrop; use Keepsuit\Liquid\Tests\Stubs\LoaderDrop; use Keepsuit\Liquid\Tests\Stubs\ThingWithValue; @@ -365,7 +365,7 @@ }); test('for cleans up registers', function () { - $context = new Context(rethrowExceptions: true, staticEnvironment: ['drop' => new ErrorDrop()]); + $context = new RenderContext(rethrowExceptions: true, staticEnvironment: ['drop' => new ErrorDrop()]); expect(fn () => parseTemplate('{% for i in (1..2) %}{{ drop.standard_error }}{% endfor %}')->render($context))->toThrow(\Keepsuit\Liquid\Exceptions\StandardException::class); diff --git a/tests/Integration/Tags/InlineCommentTagTest.php b/tests/Integration/Tags/InlineCommentTagTest.php index e9893a8..e048d9a 100644 --- a/tests/Integration/Tags/InlineCommentTagTest.php +++ b/tests/Integration/Tags/InlineCommentTagTest.php @@ -28,6 +28,18 @@ }); test('inline comment can be written on multiple lines', function () { + assertTemplateResult('', <<<'LIQUID' + {% + ############################### + # This is a comment + # across multiple lines + ############################### + %} + LIQUID + ); +}); + +test('inline comment can be written on multiple lines inside liquid tag', function () { assertTemplateResult('', <<<'LIQUID' {%- liquid ###################################### @@ -39,5 +51,5 @@ }); test('inline comment does not support nested tags', function () { - assertTemplateResult(' -%}', "{%- # {% echo 'hello world' %} -%}"); + assertMatchSyntaxError('Liquid syntax error (line 1): Unexpected token type: %}', "{%- # {% echo 'hello world' %} -%}"); }); diff --git a/tests/Integration/Tags/LiquidTagTest.php b/tests/Integration/Tags/LiquidTagTest.php index 90b00cb..51e8f3e 100644 --- a/tests/Integration/Tags/LiquidTagTest.php +++ b/tests/Integration/Tags/LiquidTagTest.php @@ -18,6 +18,21 @@ -%} LIQUID, assigns: ['array' => [1, 2, 3]]); + assertTemplateResult('2', <<<'LIQUID' + {%- liquid + case value + when 1 + echo 1 + when 2 + echo 2 + when 3 + echo 3 + else + echo "else" + endcase + -%} + LIQUID, assigns: ['value' => 2]); + assertTemplateResult('4 8 12 6', <<<'LIQUID' {%- liquid for value in array @@ -60,7 +75,7 @@ LIQUID ); - assertMatchSyntaxError("Liquid syntax error (line 2): Unknown tag '!!! the guards are vigilant'", <<<'LIQUID' + assertMatchSyntaxError('Liquid syntax error (line 2): Unexpected character !', <<<'LIQUID' {%- liquid !!! the guards are vigilant -%} @@ -103,7 +118,7 @@ }); test('cannot close blocks created before a liquid tag', function () { - assertMatchSyntaxError("Liquid syntax error (line 3): 'endif' is not a valid delimiter for liquid tags. use %}", <<<'LIQUID' + assertMatchSyntaxError("Liquid syntax error (line 3): Unknown tag 'endif'", <<<'LIQUID' {%- if true -%} 42 {%- liquid endif -%} @@ -146,7 +161,7 @@ }); test('nested liquid with unclosed if tag', function () { - assertMatchSyntaxError("Liquid syntax error (line 2): 'if' tag was never closed", <<<'LIQUID' + assertMatchSyntaxError("Liquid syntax error (line 3): 'if' tag was never closed", <<<'LIQUID' {%- liquid liquid if true echo "good" diff --git a/tests/Integration/Tags/RawTagTest.php b/tests/Integration/Tags/RawTagTest.php index af7d42a..1379828 100644 --- a/tests/Integration/Tags/RawTagTest.php +++ b/tests/Integration/Tags/RawTagTest.php @@ -27,6 +27,6 @@ test('invalid raw', function () { assertMatchSyntaxError('Liquid syntax error (line 1): \'raw\' tag was never closed', '{% raw %} foo'); - assertMatchSyntaxError('Liquid syntax error (line 1): Syntax Error in \'raw\' - Valid syntax: raw', '{% raw } foo {% endraw %}'); - assertMatchSyntaxError('Liquid syntax error (line 1): Syntax Error in \'raw\' - Valid syntax: raw', '{% raw } foo %}{% endraw %}'); + assertMatchSyntaxError('Liquid syntax error (line 1): Unexpected character }', '{% raw } foo {% endraw %}'); + assertMatchSyntaxError('Liquid syntax error (line 1): Unexpected character }', '{% raw } foo %}{% endraw %}'); }); diff --git a/tests/Integration/Tags/RenderTagTest.php b/tests/Integration/Tags/RenderTagTest.php index ff25ec5..2fb3722 100644 --- a/tests/Integration/Tags/RenderTagTest.php +++ b/tests/Integration/Tags/RenderTagTest.php @@ -122,16 +122,16 @@ test('increment is isolated between renders', function () { assertTemplateResult( '010', - '{% increment %}{% increment %}{% render "incr" %}', - partials: ['incr' => '{% increment %}'], + '{% increment a %}{% increment a %}{% render "incr" %}', + partials: ['incr' => '{% increment a %}'], ); }); test('decrement is isolated between renders', function () { assertTemplateResult( '-1-2-1', - '{% decrement %}{% decrement %}{% render "decr" %}', - partials: ['decr' => '{% decrement %}'], + '{% decrement a %}{% decrement a %}{% render "decr" %}', + partials: ['decr' => '{% decrement a %}'], ); }); diff --git a/tests/Integration/Tags/StandardTagTest.php b/tests/Integration/Tags/StandardTagTest.php index f80fdb8..338ff9f 100644 --- a/tests/Integration/Tags/StandardTagTest.php +++ b/tests/Integration/Tags/StandardTagTest.php @@ -34,7 +34,6 @@ assertTemplateResult('', '{% comment %}{% blabla %}{% endcomment %}'); assertTemplateResult('', '{%comment%}{% endif %}{%endcomment%}'); assertTemplateResult('', '{% comment %}{% endwhatever %}{% endcomment %}'); - assertTemplateResult(' ', '{% comment %}{% raw %} {{%%%%}} }} { {% endcomment %} {% comment {% endraw %} {% endcomment %}'); assertTemplateResult('', '{% comment %}{% " %}{% endcomment %}'); assertTemplateResult('', '{% comment %}{%%}{% endcomment %}'); diff --git a/tests/Integration/Tags/TableRowTest.php b/tests/Integration/Tags/TableRowTest.php index 7e0259c..fae8e67 100644 --- a/tests/Integration/Tags/TableRowTest.php +++ b/tests/Integration/Tags/TableRowTest.php @@ -163,17 +163,17 @@ test('tablerow renders correct error message for invalid parameters', function () { assertTemplateResult( 'Liquid error (line 1): invalid integer', - '{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}', + '{% tablerow n in (1..10) limit:true %} {{n}} {% endtablerow %}', renderErrors: true, ); assertTemplateResult( 'Liquid error (line 1): invalid integer', - '{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}', + '{% tablerow n in (1..10) offset:true %} {{n}} {% endtablerow %}', renderErrors: true, ); assertTemplateResult( 'Liquid error (line 1): invalid integer', - '{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}', + '{% tablerow n in (1..10) cols:true %} {{n}} {% endtablerow %}', renderErrors: true, ); }); diff --git a/tests/Integration/TemplateTest.php b/tests/Integration/TemplateTest.php index 2cb2184..4885003 100644 --- a/tests/Integration/TemplateTest.php +++ b/tests/Integration/TemplateTest.php @@ -4,13 +4,13 @@ use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; use Keepsuit\Liquid\Exceptions\UndefinedFilterException; use Keepsuit\Liquid\Exceptions\UndefinedVariableException; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\ResourceLimits; test('assigns persist on same context between renders', function () { $template = parseTemplate("{{ foo }}{% assign foo = 'foo' %}{{ foo }}"); - $context = new Context(); + $context = new RenderContext(); expect($template->render($context))->toBe('foo'); expect($template->render($context))->toBe('foofoo'); }); @@ -18,15 +18,15 @@ test('assigns does not persist on different contexts between renders', function () { $template = parseTemplate("{{ foo }}{% assign foo = 'foo' %}{{ foo }}"); - expect($template->render(new Context()))->toBe('foo'); - expect($template->render(new Context()))->toBe('foo'); + expect($template->render(new RenderContext()))->toBe('foo'); + expect($template->render(new RenderContext()))->toBe('foo'); }); test('lamdba is called once over multiple renders', function () { $template = parseTemplate('{{ number }}'); $global = 0; - $context = new Context( + $context = new RenderContext( staticEnvironment: [ 'number' => function () use (&$global) { $global += 1; @@ -43,13 +43,13 @@ test('resource limits render length', function () { $template = parseTemplate('0123456789'); - $context = new Context( + $context = new RenderContext( resourceLimits: new ResourceLimits(renderLengthLimit: 9) ); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); expect($context->resourceLimits->reached())->toBeTrue(); - $context = new Context( + $context = new RenderContext( resourceLimits: new ResourceLimits(renderLengthLimit: 10) ); expect($template->render($context))->toBe('0123456789'); @@ -58,20 +58,20 @@ test('resource limits render score', function () { $template = parseTemplate('{% for a in (1..10) %} {% for a in (1..10) %} foo {% endfor %} {% endfor %}'); - $context = new Context( + $context = new RenderContext( resourceLimits: new ResourceLimits(renderScoreLimit: 50) ); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); expect($context->resourceLimits->reached())->toBeTrue(); $template = parseTemplate('{% for a in (1..100) %} foo {% endfor %}'); - $context = new Context( + $context = new RenderContext( resourceLimits: new ResourceLimits(renderScoreLimit: 50) ); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); expect($context->resourceLimits->reached())->toBeTrue(); - $context = new Context( + $context = new RenderContext( resourceLimits: new ResourceLimits(renderScoreLimit: 200) ); expect($template->render($context))->toBe(str_repeat(' foo ', 100)); @@ -80,7 +80,7 @@ test('resource limits abort rendering after first error', function () { $template = parseTemplate('{% for a in (1..100) %} foo1 {% endfor %} bar {% for a in (1..100) %} foo2 {% endfor %}'); - $context = new Context( + $context = new RenderContext( rethrowExceptions: false, resourceLimits: new ResourceLimits(renderScoreLimit: 50) ); @@ -90,7 +90,7 @@ test('resource limits get updated even if no limits are set', function () { $template = parseTemplate('{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}'); - $context = new Context(); + $context = new RenderContext(); $template->render($context); expect($context->resourceLimits) @@ -101,35 +101,35 @@ test('render length persists between blocks', function () { $template = parseTemplate('{% if true %}aaaa{% endif %}'); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 3)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 3)); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 4)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 4)); expect($template->render($context))->toBe('aaaa'); $template = parseTemplate('{% if true %}aaaa{% endif %}{% if true %}bbb{% endif %}'); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 6)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 6)); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 7)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 7)); expect($template->render($context))->toBe('aaaabbb'); $template = parseTemplate('{% if true %}a{% endif %}{% if true %}b{% endif %}{% if true %}a{% endif %}{% if true %}b{% endif %}{% if true %}a{% endif %}{% if true %}b{% endif %}'); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 5)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 5)); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 6)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 6)); expect($template->render($context))->toBe('ababab'); }); test('render length uses number of bytes not characters', function () { $template = parseTemplate('{% if true %}すごい{% endif %}'); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 8)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 8)); expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); - $context = new Context(resourceLimits: new ResourceLimits(renderLengthLimit: 9)); + $context = new RenderContext(resourceLimits: new ResourceLimits(renderLengthLimit: 9)); expect($template->render($context))->toBe('すごい'); }); test('undefined variables', function () { $template = parseTemplate('{{x}} {{y}} {{z.a}} {{z.b}} {{z.c.d}}'); - $context = new Context( + $context = new RenderContext( staticEnvironment: [ 'x' => 33, 'z' => ['a' => 32, 'c' => ['e' => 31]], @@ -152,7 +152,7 @@ test('null value does not throw exception', function () { $template = parseTemplate('some{{x}}thing'); - $context = new Context( + $context = new RenderContext( staticEnvironment: [ 'x' => null, ], @@ -168,7 +168,7 @@ test('undefined drop method', function () { $template = parseTemplate('{{ d.text }} {{ d.undefined }}'); - $context = new Context( + $context = new RenderContext( staticEnvironment: [ 'd' => new \Keepsuit\Liquid\Tests\Stubs\TextDrop(), ], @@ -185,7 +185,7 @@ test('undefined drop method throw exception', function () { $template = parseTemplate('{{ d.text }} {{ d.undefined }}'); - $context = new Context( + $context = new RenderContext( staticEnvironment: [ 'd' => new \Keepsuit\Liquid\Tests\Stubs\TextDrop(), ], @@ -198,7 +198,7 @@ test('undefined filter', function () { $template = parseTemplate('{{a}} {{x | upcase | somefilter1 | somefilter2 | downcase}}'); - $context = new Context( + $context = new RenderContext( staticEnvironment: [ 'a' => 123, 'x' => 'foo', @@ -216,7 +216,7 @@ test('undefined filter throw exception', function () { $template = parseTemplate('{{a}} {{x | upcase | somefilter1 | somefilter2 | downcase}}'); - $context = new Context( + $context = new RenderContext( staticEnvironment: [ 'a' => 123, 'x' => 'foo', diff --git a/tests/Integration/TrimModeTest.php b/tests/Integration/TrimModeTest.php index 93640e4..ead27f1 100644 --- a/tests/Integration/TrimModeTest.php +++ b/tests/Integration/TrimModeTest.php @@ -596,5 +596,5 @@ }); test('trim blank', function () { - assertTemplateResult('foobar', 'foo {{-}} bar'); + assertTemplateResult('foobar', 'foo {{--}} bar'); }); diff --git a/tests/Pest.php b/tests/Pest.php index 52bc6c6..7697959 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,8 @@ lineNumbers($lineNumbers) ->parseString($source); } @@ -36,7 +36,6 @@ function renderTemplate( TemplateFactory $factory = new TemplateFactory() ): string { $factory = $factory->setFilesystem(new StubFileSystem(partials: $partials)) - ->lineNumbers() ->rethrowExceptions(! $renderErrors); $template = $factory->parseString($template); @@ -89,3 +88,13 @@ function assertMatchSyntaxError( throw new ExpectationFailedException('Syntax Exception not thrown.'); } + +function tokenize(string $source): TokenStream +{ + return (new ParseContext())->tokenize($source); +} + +function parse(string|TokenStream $source) +{ + return (new ParseContext())->parse($source instanceof TokenStream ? $source : tokenize($source)); +} diff --git a/tests/Stubs/ContextDrop.php b/tests/Stubs/ContextDrop.php index fd96340..790faa0 100644 --- a/tests/Stubs/ContextDrop.php +++ b/tests/Stubs/ContextDrop.php @@ -8,11 +8,13 @@ class ContextDrop extends Drop { public function scopes(): int { + // @phpstan-ignore-next-line return count(invade($this->context)->scopes); } public function scopesAsArray(): array { + // @phpstan-ignore-next-line return range(1, count(invade($this->context)->scopes)); } diff --git a/tests/Stubs/FooBarTag.php b/tests/Stubs/FooBarTag.php index 48bdb72..5648303 100644 --- a/tests/Stubs/FooBarTag.php +++ b/tests/Stubs/FooBarTag.php @@ -2,7 +2,8 @@ namespace Keepsuit\Liquid\Tests\Stubs; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class FooBarTag extends Tag @@ -12,7 +13,12 @@ public static function tagName(): string return 'foobar'; } - public function render(Context $context): string + public function parse(TagParseContext $context): static + { + return $this; + } + + public function render(RenderContext $context): string { return ' '; } diff --git a/tests/Stubs/SleepTag.php b/tests/Stubs/SleepTag.php index fa0710b..96dc245 100644 --- a/tests/Stubs/SleepTag.php +++ b/tests/Stubs/SleepTag.php @@ -2,35 +2,45 @@ namespace Keepsuit\Liquid\Tests\Stubs; -use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Parse\Tokenizer; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Exceptions\InvalidArgumentException; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class SleepTag extends Tag { - protected float $duration; + protected VariableLookup|float $duration; public static function tagName(): string { return 'sleep'; } - public function parse(ParseContext $parseContext, Tokenizer $tokenizer): static + public function parse(TagParseContext $context): static { - parent::parse($parseContext, $tokenizer); - - $this->duration = floatval($this->markup); + $duration = $context->params->expression(); + $this->duration = match (true) { + is_numeric($duration) => floatval($duration), + $duration instanceof VariableLookup => $duration, + default => throw new InvalidArgumentException('Invalid duration value'), + }; return $this; } - public function render(Context $context): string + public function render(RenderContext $context): string { - if ($this->duration > 1) { - sleep((int) $this->duration); + $duration = match (true) { + is_numeric($this->duration) => $this->duration, + $this->duration instanceof VariableLookup => $this->duration->evaluate($context), + }; + assert(is_numeric($duration)); + + if ($duration > 1) { + sleep((int) $duration); } else { - usleep((int) (1_000_000 * $this->duration)); + usleep((int) (1_000_000 * $duration)); } return ''; diff --git a/tests/Stubs/TestTag.php b/tests/Stubs/TestTag.php index 9789507..d2d91cf 100644 --- a/tests/Stubs/TestTag.php +++ b/tests/Stubs/TestTag.php @@ -2,10 +2,22 @@ namespace Keepsuit\Liquid\Tests\Stubs; +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; class TestTag extends Tag { + public function parse(TagParseContext $context): static + { + return $this; + } + + public function render(RenderContext $context): string + { + return ''; + } + public static function tagName(): string { return 'test'; diff --git a/tests/Stubs/TestTagBlockTag.php b/tests/Stubs/TestTagBlockTag.php index 3990362..4b27227 100644 --- a/tests/Stubs/TestTagBlockTag.php +++ b/tests/Stubs/TestTagBlockTag.php @@ -2,10 +2,24 @@ namespace Keepsuit\Liquid\Tests\Stubs; -class TestTagBlockTag extends \Keepsuit\Liquid\TagBlock +use Keepsuit\Liquid\Nodes\TagParseContext; +use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\TagBlock; + +class TestTagBlockTag extends TagBlock { public static function tagName(): string { return 'testblock'; } + + public function parse(TagParseContext $context): static + { + return $this; + } + + public function render(RenderContext $context): string + { + return ''; + } } diff --git a/tests/Unit/BlockTest.php b/tests/Unit/BlockTest.php index 3aced56..7216b0e 100644 --- a/tests/Unit/BlockTest.php +++ b/tests/Unit/BlockTest.php @@ -1,62 +1,68 @@ root->nodeList())->toBe([' ']); + expect($template->root->children()) + ->toHaveCount(1) + ->{0}->toBeInstanceOf(Text::class) + ->{0}->value->toBe(' '); }); test('variable beginning', function () { $template = parseTemplate('{{funk}} '); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(2) ->{0}->toBeInstanceOf(Variable::class) - ->{1}->toBeString(); + ->{1}->toBeInstanceOf(Text::class); }); test('variable end', function () { $template = parseTemplate(' {{funk}}'); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(2) - ->{0}->toBeString() + ->{0}->toBeInstanceOf(Text::class) ->{1}->toBeInstanceOf(Variable::class); }); test('variable middle', function () { $template = parseTemplate(' {{funk}} '); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(3) - ->{0}->toBeString() + ->{0}->toBeInstanceOf(Text::class) ->{1}->toBeInstanceOf(Variable::class) - ->{2}->toBeString(); + ->{2}->toBeInstanceOf(Text::class); }); test('variable many embedded fragments', function () { $template = parseTemplate(' {{funk}} {{so}} {{brother}} '); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(7) - ->{0}->toBeString() + ->{0}->toBeInstanceOf(Text::class) ->{1}->toBeInstanceOf(Variable::class) - ->{2}->toBeString() + ->{2}->toBeInstanceOf(Text::class) ->{3}->toBeInstanceOf(Variable::class) - ->{4}->toBeString() + ->{4}->toBeInstanceOf(Text::class) ->{5}->toBeInstanceOf(Variable::class) - ->{6}->toBeString(); + ->{6}->toBeInstanceOf(Text::class); }); test('with block', function () { - $template = parseTemplate(' {% comment %} {% endcomment %} '); + $template = parseTemplate(' {% if hi %} hi {% endif %} '); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(3) - ->{0}->toBeString() - ->{1}->toBeInstanceOf(CommentTag::class) - ->{2}->toBeString(); + ->{0}->toBeInstanceOf(Text::class) + ->{1}->toBeInstanceOf(IfTag::class) + ->{1}->children()->{0}->children()->toHaveCount(1) + ->{1}->children()->{0}->children()->{0}->toBeInstanceOf(Text::class) + ->{2}->toBeInstanceOf(Text::class); }); diff --git a/tests/Unit/ConditionTest.php b/tests/Unit/ConditionTest.php index f652ee8..e893a27 100644 --- a/tests/Unit/ConditionTest.php +++ b/tests/Unit/ConditionTest.php @@ -3,10 +3,10 @@ use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\VariableLookup; -use Keepsuit\Liquid\Render\Context; +use Keepsuit\Liquid\Render\RenderContext; beforeEach(function () { - $this->context = new Context(); + $this->context = new RenderContext(); }); afterEach(function () { @@ -108,24 +108,24 @@ test('or condition', function () { $a = new Condition(1, '==', 2); - expect($a->evaluate(new Context()))->toBeFalse(); + expect($a->evaluate(new RenderContext()))->toBeFalse(); $a->or(new Condition(2, '==', 1)); - expect($a->evaluate(new Context()))->toBeFalse(); + expect($a->evaluate(new RenderContext()))->toBeFalse(); $a->or(new Condition(1, '==', 1)); - expect($a->evaluate(new Context()))->toBeTrue(); + expect($a->evaluate(new RenderContext()))->toBeTrue(); }); test('and condition', function () { $a = new Condition(1, '==', 1); - expect($a->evaluate(new Context()))->toBeTrue(); + expect($a->evaluate(new RenderContext()))->toBeTrue(); $a->and(new Condition(2, '==', 2)); - expect($a->evaluate(new Context()))->toBeTrue(); + expect($a->evaluate(new RenderContext()))->toBeTrue(); $a->and(new Condition(2, '==', 1)); - expect($a->evaluate(new Context()))->toBeFalse(); + expect($a->evaluate(new RenderContext()))->toBeFalse(); }); test('should allow custom operators', function () { diff --git a/tests/Unit/FileSystemTest.php b/tests/Unit/FileSystemTest.php index 5c2c136..5c88284 100644 --- a/tests/Unit/FileSystemTest.php +++ b/tests/Unit/FileSystemTest.php @@ -13,8 +13,8 @@ $fileSystem = new LocalFileSystem('/some/path'); expect($fileSystem) - ->fullPath('mypartial')->toBe('/some/path/_mypartial.liquid') - ->fullPath('dir/mypartial')->toBe('/some/path/dir/_mypartial.liquid'); + ->fullPath('mypartial')->toBe('/some/path/mypartial.liquid') + ->fullPath('dir/mypartial')->toBe('/some/path/dir/mypartial.liquid'); expect(fn () => $fileSystem->fullPath('../dir/mypartial'))->toThrow(FileSystemException::class); diff --git a/tests/Unit/I18nTest.php b/tests/Unit/I18nTest.php deleted file mode 100644 index 07b1a16..0000000 --- a/tests/Unit/I18nTest.php +++ /dev/null @@ -1,24 +0,0 @@ -i18n = new \Keepsuit\Liquid\Support\I18n(fixture('en_locale.yml')); -}); - -test('translate simple string', function () { - expect($this->i18n->translate('simple'))->toBe('less is more'); -}); - -test('translate nested string', function () { - expect($this->i18n->translate('errors.syntax.oops'))->toBe("something wasn't right"); -}); - -test('single string interpolation', function () { - expect($this->i18n->translate('whatever', ['something' => 'different']))->toBe('something different'); -}); - -test('throw unknown translation', function () { - expect(fn () => $this->i18n->translate('doesnt_exist')) - ->toThrow(TranslationException::class, sprintf("Translation for '%s' does not exist in locale: '%s'", 'doesnt_exist', fixture('en_locale.yml'))); -}); diff --git a/tests/Unit/LexerTest.php b/tests/Unit/LexerTest.php index d2c78e1..cc50917 100644 --- a/tests/Unit/LexerTest.php +++ b/tests/Unit/LexerTest.php @@ -1,92 +1,214 @@ tokenize(); +test('[variable] strings', function () { + $tokens = tokenize('{{ \'this is a test""\' "wat \'lol\'" }}'); - expect($tokens) - ->toHaveCount(3) - ->toBe([ - [TokenType::String, '\'this is a test""\'', 1], - [TokenType::String, '"wat \'lol\'"', 20], - [TokenType::EndOfString, '', 31], - ]); + $tokens->consume(TokenType::VariableStart); + + expect($tokens->consume(TokenType::String)) + ->data->toBe('\'this is a test""\'') + ->lineNumber->toBe(1); + + expect($tokens->consume(TokenType::String)) + ->data->toBe('"wat \'lol\'"') + ->lineNumber->toBe(1); + + $tokens->consume(TokenType::VariableEnd); + + expect($tokens->isEnd())->toBeTrue(); }); -test('integer', function () { - $tokens = (new Lexer('hi 50'))->tokenize(); +test('[variable] integer', function () { + $tokens = tokenize('{{ 50 -10 }}'); + + $tokens->consume(TokenType::VariableStart); + + expect($tokens->consume(TokenType::Number)) + ->data->toBe('50') + ->lineNumber->toBe(1); - expect($tokens) - ->toHaveCount(3) - ->toBe([ - [TokenType::Identifier, 'hi', 0], - [TokenType::Number, '50', 3], - [TokenType::EndOfString, '', 5], - ]); + expect($tokens->consume(TokenType::Number)) + ->data->toBe('-10') + ->lineNumber->toBe(1); + + $tokens->consume(TokenType::VariableEnd); + + expect($tokens->isEnd())->toBeTrue(); }); -test('float', function () { - $tokens = (new Lexer('hi 5.0'))->tokenize(); +test('[variable] float', function () { + $tokens = tokenize('{{ 5.0 -2.7 }}'); + + $tokens->consume(TokenType::VariableStart); + + expect($tokens->consume(TokenType::Number)) + ->data->toBe('5.0') + ->lineNumber->toBe(1); + + expect($tokens->consume(TokenType::Number)) + ->data->toBe('-2.7') + ->lineNumber->toBe(1); - expect($tokens) - ->toHaveCount(3) - ->toBe([ - [TokenType::Identifier, 'hi', 0], - [TokenType::Number, '5.0', 3], - [TokenType::EndOfString, '', 6], - ]); + $tokens->consume(TokenType::VariableEnd); + + expect($tokens->isEnd())->toBeTrue(); }); -test('specials', function () { - $tokens = (new Lexer('| .:'))->tokenize(); - - expect($tokens) - ->toHaveCount(4) - ->toBe([ - [TokenType::Pipe, '|', 0], - [TokenType::Dot, '.', 2], - [TokenType::Colon, ':', 3], - [TokenType::EndOfString, '', 4], - ]); - - $tokens = (new Lexer('[,]'))->tokenize(); - - expect($tokens) - ->toHaveCount(4) - ->toBe([ - [TokenType::OpenSquare, '[', 0], - [TokenType::Comma, ',', 1], - [TokenType::CloseSquare, ']', 2], - [TokenType::EndOfString, '', 3], - ]); +test('[variable] specials', function () { + $tokens = tokenize('{{ | .: [,] }}'); + + $tokens->consume(TokenType::VariableStart); + + expect($tokens->consume(TokenType::Pipe))->data->toBe('|'); + expect($tokens->consume(TokenType::Dot))->data->toBe('.'); + expect($tokens->consume(TokenType::Colon))->data->toBe(':'); + expect($tokens->consume(TokenType::OpenSquare))->data->toBe('['); + expect($tokens->consume(TokenType::Comma))->data->toBe(','); + expect($tokens->consume(TokenType::CloseSquare))->data->toBe(']'); + + $tokens->consume(TokenType::VariableEnd); + + expect($tokens->isEnd())->toBeTrue(); }); -test('fancy identifiers', function () { - $tokens = (new Lexer('hi five?'))->tokenize(); - - expect($tokens) - ->toHaveCount(3) - ->toBe([ - [TokenType::Identifier, 'hi', 0], - [TokenType::Identifier, 'five?', 3], - [TokenType::EndOfString, '', 8], - ]); - - $tokens = (new Lexer('2foo'))->tokenize(); - - expect($tokens) - ->toHaveCount(3) - ->toBe([ - [TokenType::Number, '2', 0], - [TokenType::Identifier, 'foo', 1], - [TokenType::EndOfString, '', 4], - ]); +test('[variable] fancy identifiers', function () { + $tokens = tokenize('{{ hi five? 2foo }}'); + + $tokens->consume(TokenType::VariableStart); + + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('hi') + ->lineNumber->toBe(1); + + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('five?') + ->lineNumber->toBe(1); + + expect($tokens->consume(TokenType::Number)) + ->data->toBe('2') + ->lineNumber->toBe(1); + + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('foo') + ->lineNumber->toBe(1); + + $tokens->consume(TokenType::VariableEnd); + + expect($tokens->isEnd())->toBeTrue(); }); -test('unexpected character', function () { - expect(fn () => (new Lexer('%'))->tokenize()) +test('[variable] unexpected character', function () { + expect(fn () => tokenize('{{ % }}')) ->toThrow(SyntaxException::class, 'Unexpected character %'); }); + +it('[blocks]', function () { + $tokens = tokenize('{% if hi %} {% endif %}'); + + $tokens->consume(TokenType::BlockStart); + + expect($tokens->consume()) + ->type->toBe(TokenType::Identifier) + ->data->toBe('if'); + + expect($tokens->consume()) + ->type->toBe(TokenType::Identifier) + ->data->toBe('hi'); + + $tokens->consume(TokenType::BlockEnd); + + expect($tokens->consume()) + ->type->toBe(TokenType::TextData) + ->data->toBe(' '); + + $tokens->consume(TokenType::BlockStart); + + expect($tokens->consume()) + ->type->toBe(TokenType::Identifier) + ->data->toBe('endif'); + + $tokens->consume(TokenType::BlockEnd); +}); + +test('text', function () { + expect(tokenize(' ')->consume()) + ->type->toBe(TokenType::TextData) + ->data->toBe(' '); + + expect(tokenize('hello world')->consume()) + ->type->toBe(TokenType::TextData) + ->data->toBe('hello world'); +}); + +test('unclosed expression', function () { + expect(fn () => tokenize('{{ hi')) + ->toThrow(SyntaxException::class, 'Variable was not properly terminated with: }}'); + + expect(fn () => tokenize('{% if')) + ->toThrow(SyntaxException::class, 'Tag was not properly terminated with: %}'); +}); + +test('full source', function () { + $tokens = tokenize(<<<'LIQUID' + This is a test + {{- hi | filter: 5.0 }} + {%- if hi == 5 %} + {{ hi }} + {%- endif %} + end + LIQUID + ); + + expect($tokens->consume(TokenType::TextData)) + ->data->toBe('This is a test') + ->lineNumber->toBe(1); + + $tokens->consume(TokenType::VariableStart); + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('hi') + ->lineNumber->toBe(2); + $tokens->consume(TokenType::Pipe); + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('filter'); + $tokens->consume(TokenType::Colon); + expect($tokens->consume(TokenType::Number)) + ->data->toBe('5.0'); + $tokens->consume(TokenType::VariableEnd); + + $tokens->consume(TokenType::BlockStart); + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('if') + ->lineNumber->toBe(3); + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('hi'); + expect($tokens->consume(TokenType::Comparison)) + ->data->toBe('==') + ->lineNumber->toBe(3); + expect($tokens->consume(TokenType::Number)) + ->data->toBe('5'); + $tokens->consume(TokenType::BlockEnd); + + expect($tokens->consume(TokenType::TextData)) + ->data->toBe("\n "); + + $tokens->consume(TokenType::VariableStart); + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('hi') + ->lineNumber->toBe(4); + $tokens->consume(TokenType::VariableEnd); + + $tokens->consume(TokenType::BlockStart); + expect($tokens->consume(TokenType::Identifier)) + ->data->toBe('endif') + ->lineNumber->toBe(5); + $tokens->consume(TokenType::BlockEnd); + + expect($tokens->consume(TokenType::TextData)) + ->data->toBe("\nend") + ->lineNumber->toBe(5); + + expect($tokens->isEnd())->toBeTrue(); +}); diff --git a/tests/Unit/ParseTreeVisitorTest.php b/tests/Unit/ParseTreeVisitorTest.php index 02207d2..3747284 100644 --- a/tests/Unit/ParseTreeVisitorTest.php +++ b/tests/Unit/ParseTreeVisitorTest.php @@ -115,7 +115,10 @@ }); test('cycle', function () { - expect(visit('{% cycle test %}'))->toBe(['test']); + $traversal = traversal('{% cycle "test" %}') + ->addCallbackFor('string', fn (string $node) => [$node, null]); + + expect(Arr::compact(Arr::flatten($traversal->visit())))->toBe(['test']); }); test('assign', function () { @@ -136,9 +139,9 @@ [ null, [ - [null, [[null, [['other', []]]]]], - ['test', []], - ['xs', []], + [null, [[null, [['other', [[null, []]]]]]]], + ['test', [[null, []]]], + ['xs', [[null, []]]], ], ], ]); diff --git a/tests/Unit/ParserTest.php b/tests/Unit/ParserTest.php deleted file mode 100644 index 9589396..0000000 --- a/tests/Unit/ParserTest.php +++ /dev/null @@ -1,100 +0,0 @@ -consume(TokenType::Identifier)->toBe('wat') - ->consume(TokenType::Colon)->toBe(':') - ->consume(TokenType::Number)->toBe('7'); -}); - -test('jump', function () { - $parser = new Parser('wat: 7'); - - $parser->jump(2); - - expect($parser) - ->consume(TokenType::Number)->toBe('7'); -}); - -test('consumeOrFalse', function () { - $parser = new Parser('wat: 7'); - - expect($parser) - ->consumeOrFalse(TokenType::Identifier)->toBe('wat') - ->consumeOrFalse(TokenType::Dot)->toBeFalse() - ->consumeOrFalse(TokenType::Colon)->toBe(':') - ->consumeOrFalse(TokenType::Number)->toBe('7'); -}); - -test('idOrFalse', function () { - $parser = new Parser('wat 6 Peter Hegemon'); - - expect($parser) - ->idOrFalse('wat')->toBe('wat') - ->idOrFalse('endgame')->toBeFalse() - ->consume(TokenType::Number)->toBe('6') - ->idOrFalse('Peter')->toBe('Peter') - ->idOrFalse('Achilles')->toBeFalse(); -}); - -test('look', function () { - $parser = new Parser('wat 6 Peter Hegemon'); - - expect($parser) - ->look(TokenType::Identifier)->toBeTrue() - ->consume(TokenType::Identifier)->toBe('wat') - ->look(TokenType::Comparison)->toBeFalse() - ->look(TokenType::Number)->toBeTrue() - ->look(TokenType::Identifier, 1)->toBeTrue() - ->look(TokenType::Number, 1)->toBeFalse(); -}); - -test('expressions', function () { - $parser = new Parser('hi.there hi?[5].there? hi.there.bob'); - - expect($parser) - ->expression()->toBe('hi.there') - ->expression()->toBe('hi?[5].there?') - ->expression()->toBe('hi.there.bob'); - - $parser = new Parser("567 6.0 'lol' \"wut\""); - - expect($parser) - ->expression()->toBe('567') - ->expression()->toBe('6.0') - ->expression()->toBe("'lol'") - ->expression()->toBe('"wut"'); -}); - -test('ranges', function () { - $parser = new Parser('(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)'); - - expect($parser) - ->expression()->toBe('(5..7)') - ->expression()->toBe('(1.5..9.6)') - ->expression()->toBe('(young..old)') - ->expression()->toBe('(hi[5].wat..old)'); -}); - -test('arguments', function () { - $parser = new Parser('filter: hi.there[5], keyarg: 7'); - - expect($parser) - ->consume(TokenType::Identifier)->toBe('filter') - ->consume(TokenType::Colon)->toBe(':') - ->argument()->toBe('hi.there[5]') - ->consume(TokenType::Comma)->toBe(',') - ->argument()->toBe(['keyarg' => '7']); -}); - -test('invalid expression', function () { - $parser = new Parser('=='); - - expect(fn () => $parser->expression()) - ->toThrow(Exception::class, '== is not a valid expression'); -}); diff --git a/tests/Unit/RegexTest.php b/tests/Unit/RegexTest.php deleted file mode 100644 index 231a3a8..0000000 --- a/tests/Unit/RegexTest.php +++ /dev/null @@ -1,62 +0,0 @@ -expect(regexMatch('', Regex::QuotedFragment)) - ->toBe([]); - -test('quote') - ->expect(regexMatch('"arg 1"', Regex::QuotedFragment)) - ->toBe(['"arg 1"']); - -test('words') - ->expect(regexMatch('arg1 arg2', Regex::QuotedFragment)) - ->toBe(['arg1', 'arg2']); - -test('tags', function () { - expect(regexMatch(' ', Regex::QuotedFragment))->toBe(['', '']); - expect(regexMatch('', Regex::QuotedFragment))->toBe(['']); - expect(regexMatch('', Regex::QuotedFragment))->toBe(['', '']); -}); - -test('double quoted words') - ->expect(regexMatch('arg1 arg2 "arg 3"', Regex::QuotedFragment)) - ->toBe(['arg1', 'arg2', '"arg 3"']); - -test('single quoted words') - ->expect(regexMatch('arg1 arg2 \'arg 3\'', Regex::QuotedFragment)) - ->toBe(['arg1', 'arg2', "'arg 3'"]); - -test('quoted words in the middle') - ->expect(regexMatch('arg1 arg2 "arg 3" arg4 ', Regex::QuotedFragment)) - ->toBe(['arg1', 'arg2', '"arg 3"', 'arg4']); - -test('variable parser', function () { - expect(regexMatch('var', Regex::VariableParser))->toBe(['var']); - expect(regexMatch('[var]', Regex::VariableParser))->toBe(['[var]']); - expect(regexMatch('var.method', Regex::VariableParser))->toBe(['var', 'method']); - expect(regexMatch('var[method]', Regex::VariableParser))->toBe(['var', '[method]']); - expect(regexMatch('var[method][0]', Regex::VariableParser))->toBe(['var', '[method]', '[0]']); - expect(regexMatch('var["method"][0]', Regex::VariableParser))->toBe(['var', '["method"]', '[0]']); - expect(regexMatch('var[method][0].method', Regex::VariableParser))->toBe(['var', '[method]', '[0]', 'method']); -}); - -test('variable parser with large input', function () { - $veryLongString = str_repeat('foo', 1000); - - // Valid dynamic lookup - expect(regexMatch(sprintf('[%s]', $veryLongString), Regex::VariableParser))->toBe([sprintf('[%s]', $veryLongString)]); - - //Invalid dynamic lookup - expect(regexMatch(sprintf('[%s', $veryLongString), Regex::VariableParser))->toBe([$veryLongString]); -}); - -function regexMatch(string $input, string $regex): array -{ - if (preg_match_all(sprintf('/%s/', $regex), $input, $matches) !== false) { - return $matches[0]; - } - - return []; -} diff --git a/tests/Unit/TagTest.php b/tests/Unit/TagTest.php deleted file mode 100644 index 8ec67f2..0000000 --- a/tests/Unit/TagTest.php +++ /dev/null @@ -1,26 +0,0 @@ -parse($parseContext, $parseContext->newTokenizer('')); - - expect($tag) - ->name()->toBe(TestTag::class) - ->render(new Context())->toBe(''); -}); - -test('return raw text of tag', function () { - $parseContext = new ParseContext(); - $tag = (new TestTag('param1, param2, param3'))->parse($parseContext, $parseContext->newTokenizer('')); - - expect($tag) - ->raw()->toBe('test param1, param2, param3'); -}); - -test('tag name should return name of tag', function () { - expect(TestTag::tagName())->toBe('test'); -}); diff --git a/tests/Unit/Tags/CaseTagTest.php b/tests/Unit/Tags/CaseTagTest.php index 2b01957..6546760 100644 --- a/tests/Unit/Tags/CaseTagTest.php +++ b/tests/Unit/Tags/CaseTagTest.php @@ -2,13 +2,13 @@ use Keepsuit\Liquid\Tags\CaseTag; -test('case nodelist', function () { +test('case children', function () { $template = parseTemplate('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}'); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(CaseTag::class) - ->{0}->nodeList()->toHaveCount(2) - ->{0}->nodeList()->{0}->nodeList()->toBe(['WHEN']) - ->{0}->nodeList()->{1}->nodeList()->toBe(['ELSE']); + ->{0}->children()->toHaveCount(2) + ->{0}->children()->{0}->children()->{0}->value->toBe('WHEN') + ->{0}->children()->{1}->children()->{0}->value->toBe('ELSE'); }); diff --git a/tests/Unit/Tags/ForTagTest.php b/tests/Unit/Tags/ForTagTest.php index 938f785..37b130f 100644 --- a/tests/Unit/Tags/ForTagTest.php +++ b/tests/Unit/Tags/ForTagTest.php @@ -2,23 +2,23 @@ use Keepsuit\Liquid\Tags\ForTag; -test('for nodelist', function () { +test('for children', function () { $template = parseTemplate('{% for item in items %}FOR{% endfor %}'); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(ForTag::class) - ->{0}->nodeList()->toHaveCount(1) - ->{0}->nodeList()->{0}->nodeList()->toBe(['FOR']); + ->{0}->children()->toHaveCount(1) + ->{0}->children()->{0}->children()->{0}->value->toBe('FOR'); }); -test('for else nodelist', function () { +test('for else children', function () { $template = parseTemplate('{% for item in items %}FOR{% else %}ELSE{% endfor %}'); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(ForTag::class) - ->{0}->nodeList()->toHaveCount(2) - ->{0}->nodeList()->{0}->nodeList()->toBe(['FOR']) - ->{0}->nodeList()->{1}->nodeList()->toBe(['ELSE']); + ->{0}->children()->toHaveCount(2) + ->{0}->children()->{0}->children()->{0}->value->toBe('FOR') + ->{0}->children()->{1}->children()->{0}->value->toBe('ELSE'); }); diff --git a/tests/Unit/Tags/IfTagTest.php b/tests/Unit/Tags/IfTagTest.php index f859914..faca9f1 100644 --- a/tests/Unit/Tags/IfTagTest.php +++ b/tests/Unit/Tags/IfTagTest.php @@ -2,13 +2,13 @@ use Keepsuit\Liquid\Tags\IfTag; -test('if nodelist', function () { +test('if children', function () { $template = parseTemplate('{% if true %}IF{% else %}ELSE{% endif %}'); - expect($template->root->nodeList()) + expect($template->root->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(IfTag::class) - ->{0}->nodeList()->toHaveCount(2) - ->{0}->nodeList()->{0}->nodeList()->toBe(['IF']) - ->{0}->nodeList()->{1}->nodeList()->toBe(['ELSE']); + ->{0}->children()->toHaveCount(2) + ->{0}->children()->{0}->children()->{0}->value->toBe('IF') + ->{0}->children()->{1}->children()->{0}->value->toBe('ELSE'); }); diff --git a/tests/Unit/TemplateTest.php b/tests/Unit/TemplateTest.php index 291394a..9b6c1bd 100644 --- a/tests/Unit/TemplateTest.php +++ b/tests/Unit/TemplateTest.php @@ -1,24 +1,7 @@ newParseContext(); - - expect($parseContext->locale)->toBeInstanceOf(I18n::class); -}); - -it('sets default localization in context with quick initialization', function () { - $factory = TemplateFactory::new() - ->setLocale($i18n = new I18n(fixture('en_locale.yml'))); - - $parseContext = $factory->newParseContext(); - expect($parseContext->locale)->toBe($i18n); -}); - test('register & delete custom tags', function () { $factory = TemplateFactory::new() ->registerTag(\Keepsuit\Liquid\Tests\Stubs\TestTagBlockTag::class); diff --git a/tests/Unit/TokenStreamTest.php b/tests/Unit/TokenStreamTest.php new file mode 100644 index 0000000..4e82d3f --- /dev/null +++ b/tests/Unit/TokenStreamTest.php @@ -0,0 +1,119 @@ +consume(TokenType::VariableStart)->data->toBe('') + ->consume(TokenType::Identifier)->data->toBe('wat') + ->consume(TokenType::Colon)->data->toBe(':') + ->consume(TokenType::Number)->data->toBe('7') + ->consume(TokenType::VariableEnd)->data->toBe('') + ->isEnd()->toBeTrue(); +}); + +test('jump', function () { + $tokenStream = tokenize('{{ wat: 7 }}'); + + $tokenStream->jump(3); + + expect($tokenStream) + ->consume(TokenType::Number)->data->toBe('7'); +}); + +test('consumeOrFalse', function () { + $tokenStream = tokenize('{{ wat: 7 }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect($tokenStream) + ->consumeOrFalse(TokenType::Identifier)->data->toBe('wat') + ->consumeOrFalse(TokenType::Dot)->toBeFalse() + ->consumeOrFalse(TokenType::Colon)->data->toBe(':') + ->consumeOrFalse(TokenType::Number)->data->toBe('7'); +}); + +test('idOrFalse', function () { + $tokenStream = tokenize('{{ wat 6 Peter Hegemon }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect($tokenStream) + ->idOrFalse('wat')->data->toBe('wat') + ->idOrFalse('endgame')->toBeFalse() + ->consume(TokenType::Number)->data->toBe('6') + ->idOrFalse('Peter')->data->toBe('Peter') + ->idOrFalse('Achilles')->toBeFalse(); +}); + +test('look', function () { + $tokenStream = tokenize('{{ wat 6 Peter Hegemon }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect($tokenStream) + ->look(TokenType::Identifier)->toBeTrue() + ->consume(TokenType::Identifier)->data->toBe('wat') + ->look(TokenType::Comparison)->toBeFalse() + ->look(TokenType::Number)->toBeTrue() + ->look(TokenType::Identifier, 1)->toBeTrue() + ->look(TokenType::Number, 1)->toBeFalse(); +}); + +test('expressions', function () { + $tokenStream = tokenize('{{ hi.there hi?[5].there? hi.there.bob }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect($tokenStream) + ->expression()->toString()->toBe('hi.there') + ->expression()->toString()->toBe('hi?.5.there?') + ->expression()->toString()->toBe('hi.there.bob'); + + $tokenStream = tokenize('{{ 567 6.0 \'lol\' "wut" }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect($tokenStream) + ->expression()->toBe(567) + ->expression()->toBe(6.0) + ->expression()->toBe('lol') + ->expression()->toBe('wut'); +}); + +test('ranges', function () { + $tokenStream = tokenize('{{ (5..7) (1.5..9.6) (young..old) (hi[5].wat..old) }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect($tokenStream) + ->expression()->toString()->toBe('(5..7)') + ->expression()->toString()->toBe('(1.5..9.6)') + ->expression()->toString()->toBe('(young..old)') + ->expression()->toString()->toBe('(hi.5.wat..old)'); +}); + +test('arguments', function () { + $tokenStream = tokenize('{{ filter: hi.there[5], keyarg: 7 }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect($tokenStream) + ->consume(TokenType::Identifier)->data->toBe('filter') + ->consume(TokenType::Colon)->data->toBe(':') + ->argument()->toString()->toBe('hi.there.5') + ->consume(TokenType::Comma)->data->toBe(',') + ->argument()->toBe(['keyarg' => 7]); +}); + +test('invalid expression', function () { + $tokenStream = tokenize('{{ == }}'); + + $tokenStream->consume(TokenType::VariableStart); + + expect(fn () => $tokenStream->expression()) + ->toThrow(SyntaxException::class, '== is not a valid expression'); +}); diff --git a/tests/Unit/TokenizerTest.php b/tests/Unit/TokenizerTest.php deleted file mode 100644 index 0cd796b..0000000 --- a/tests/Unit/TokenizerTest.php +++ /dev/null @@ -1,54 +0,0 @@ -toBe([' ']); - expect(tokenize('hello world'))->toBe(['hello world']); -}); - -it('tokenize variables', function () { - expect(tokenize('{{funk}}'))->toBe(['{{funk}}']); - expect(tokenize(' {{funk}} '))->toBe([' ', '{{funk}}', ' ']); - expect(tokenize(' {{funk}} {{so}} {{brother}} '))->toBe([' ', '{{funk}}', ' ', '{{so}}', ' ', '{{brother}}', ' ']); - expect(tokenize(' {{ funk }} '))->toBe([' ', '{{ funk }}', ' ']); -}); - -it('tokenize blocks', function () { - expect(tokenize('{%comment%}'))->toBe(['{%comment%}']); - expect(tokenize(' {%comment%} '))->toBe([' ', '{%comment%}', ' ']); - - expect(tokenize(' {%comment%} {%endcomment%} '))->toBe([' ', '{%comment%}', ' ', '{%endcomment%}', ' ']); - expect(tokenize(' {% comment %} {% endcomment %} '))->toBe([' ', '{% comment %}', ' ', '{% endcomment %}', ' ']); -}); - -it('calculate line numbers per token with profiling', function () { - expect(tokenizeLineNumbers('{{funk}}'))->toBe([1]); - expect(tokenizeLineNumbers(' {{funk}} '))->toBe([1, 1, 1]); - expect(tokenizeLineNumbers("\n{{funk}}\n"))->toBe([1, 2, 2]); - expect(tokenizeLineNumbers(" {{\n funk \n}} "))->toBe([1, 1, 3]); -}); - -function tokenize(string $source): array -{ - $tokenizer = (new ParseContext())->newTokenizer($source); - - $tokens = []; - foreach ($tokenizer->shift() as $token) { - $tokens[] = $token; - } - - return $tokens; -} - -function tokenizeLineNumbers(string $source): array -{ - $tokenizer = (new ParseContext(startLineNumber: 1))->newTokenizer($source); - - $lineNumbers = []; - foreach ($tokenizer->shift() as $ignored) { - $lineNumbers[] = $tokenizer->getStartLineNumber(); - } - - return $lineNumbers; -} diff --git a/tests/Unit/VariableTest.php b/tests/Unit/VariableTest.php index 1834531..a4db2eb 100644 --- a/tests/Unit/VariableTest.php +++ b/tests/Unit/VariableTest.php @@ -7,126 +7,126 @@ test('variable', function () { $var = createVariable('hello'); - expect($var->name()) + expect($var->name) ->toBeInstanceOf(VariableLookup::class) ->name->toBe('hello'); }); test('filters', function () { expect(createVariable('hello | textileze')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['textileze', [], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['textileze', [], []]]); expect(createVariable('hello | textileze | paragraph')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['textileze', [], []], ['paragraph', [], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['textileze', [], []], ['paragraph', [], []]]); expect(createVariable(' hello | strftime: \'%Y\'')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['strftime', ['%Y'], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['strftime', ['%Y'], []]]); expect(createVariable(' \'typo\' | link_to: \'Typo\', true ')) - ->name()->toBeString() - ->name()->toBe('typo') - ->filters()->toBe([['link_to', ['Typo', true], []]]); + ->name->toBeString() + ->name->toBe('typo') + ->filters->toBe([['link_to', ['Typo', true], []]]); expect(createVariable(' \'typo\' | link_to: \'Typo\', false ')) - ->name()->toBeString() - ->name()->toBe('typo') - ->filters()->toBe([['link_to', ['Typo', false], []]]); + ->name->toBeString() + ->name->toBe('typo') + ->filters->toBe([['link_to', ['Typo', false], []]]); expect(createVariable(' \'foo\' | repeat: 3 ')) - ->name()->toBeString() - ->name()->toBe('foo') - ->filters()->toBe([['repeat', [3], []]]); + ->name->toBeString() + ->name->toBe('foo') + ->filters->toBe([['repeat', [3], []]]); expect(createVariable(' \'foo\' | repeat: 3, 3 ')) - ->name()->toBeString() - ->name()->toBe('foo') - ->filters()->toBe([['repeat', [3, 3], []]]); + ->name->toBeString() + ->name->toBe('foo') + ->filters->toBe([['repeat', [3, 3], []]]); expect(createVariable(' \'foo\' | repeat: 3, 3, 3 ')) - ->name()->toBeString() - ->name()->toBe('foo') - ->filters()->toBe([['repeat', [3, 3, 3], []]]); + ->name->toBeString() + ->name->toBe('foo') + ->filters->toBe([['repeat', [3, 3, 3], []]]); expect(createVariable(' hello | strftime: \'%Y, okay?\'')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['strftime', ['%Y, okay?'], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['strftime', ['%Y, okay?'], []]]); expect(createVariable(' hello | things: "%Y, okay?", \'the other one\'')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['things', ['%Y, okay?', 'the other one'], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['things', ['%Y, okay?', 'the other one'], []]]); }); test('filter with date parameter', function () { expect(createVariable(' \'2006-06-06\' | date: "%m/%d/%Y"')) - ->name()->toBeString() - ->name()->toBe('2006-06-06') - ->filters()->toBe([['date', ['%m/%d/%Y'], []]]); + ->name->toBeString() + ->name->toBe('2006-06-06') + ->filters->toBe([['date', ['%m/%d/%Y'], []]]); }); test('filters without whitespace', function () { expect(createVariable('hello | textileze | paragraph')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['textileze', [], []], ['paragraph', [], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['textileze', [], []], ['paragraph', [], []]]); expect(createVariable('hello|textileze|paragraph')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['textileze', [], []], ['paragraph', [], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['textileze', [], []], ['paragraph', [], []]]); expect(createVariable("hello|replace:'foo','bar'|textileze")) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['replace', ['foo', 'bar'], []], ['textileze', [], []]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['replace', ['foo', 'bar'], []], ['textileze', [], []]]); }); test('string to filters', function () { expect(createVariable("'http://disney.com/logo.gif' | image: 'med' ")) - ->name()->toBeString() - ->name()->toBe('http://disney.com/logo.gif') - ->filters()->toBe([['image', ['med'], []]]); + ->name->toBeString() + ->name->toBe('http://disney.com/logo.gif') + ->filters->toBe([['image', ['med'], []]]); }); test('string single quoted', function () { expect(createVariable(" 'hello' ")) - ->name()->toBeString() - ->name()->toBe('hello'); + ->name->toBeString() + ->name->toBe('hello'); }); test('string double quoted', function () { expect(createVariable(' "hello" ')) - ->name()->toBeString() - ->name()->toBe('hello'); + ->name->toBeString() + ->name->toBe('hello'); }); test('integer', function () { expect(createVariable(' 1000 ')) - ->name()->toBeNumeric() - ->name()->toBe(1000); + ->name->toBeNumeric() + ->name->toBe(1000); }); test('float', function () { expect(createVariable(' 1000.01 ')) - ->name()->toBeNumeric() - ->name()->toBe(1000.01); + ->name->toBeNumeric() + ->name->toBe(1000.01); }); test('dashes', function () { expect(createVariable('foo-bar')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('foo-bar'); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('foo-bar'); expect(createVariable('foo-bar-2')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('foo-bar-2'); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('foo-bar-2'); expect(fn () => createVariable('foo - bar'))->toThrow(SyntaxException::class); expect(fn () => createVariable('-foo'))->toThrow(SyntaxException::class); @@ -135,22 +135,22 @@ test('string with special chars', function () { expect(createVariable(' \'hello! $!@.;"ddasd" \' ')) - ->name()->toBeString() - ->name()->toBe('hello! $!@.;"ddasd" '); + ->name->toBeString() + ->name->toBe('hello! $!@.;"ddasd" '); }); test('string dot', function () { expect(createVariable(' test.test ')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('test') - ->name()->lookups->toBe(['test']); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('test') + ->name->lookups->toBe(['test']); }); test('filter with keyword arguments', function () { expect(createVariable(' hello | things: greeting: "world", farewell: \'goodbye\'')) - ->name()->toBeInstanceOf(VariableLookup::class) - ->name()->name->toBe('hello') - ->filters()->toBe([['things', [], ['greeting' => 'world', 'farewell' => 'goodbye']]]); + ->name->toBeInstanceOf(VariableLookup::class) + ->name->name->toBe('hello') + ->filters->toBe([['things', [], ['greeting' => 'world', 'farewell' => 'goodbye']]]); }); test('string filter argument parsing', function () { @@ -158,18 +158,18 @@ ->toThrow(SyntaxException::class); }); -test('output raw source of variable', function () { - expect(createVariable(' name_of_variable | upcase ')) - ->raw()->toBe(' name_of_variable | upcase '); -}); - test('variable lookup interface', function () { - expect(new VariableLookup('a.b.c')) + $variable = createVariable('a.b.c'); + + expect($variable->name) + ->toBeInstanceOf(VariableLookup::class) ->name->toBe('a') ->lookups->toBe(['b', 'c']); }); function createVariable(string $markup): Variable { - return Variable::fromMarkup($markup); + $body = parse(sprintf('{{ %s }}', $markup)); + + return $body->children()[0]; }