From ed1a42131a6f50930877831d3f3a1c7307884955 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 26 Feb 2026 20:15:19 +0100 Subject: [PATCH 1/9] |padLeft |padRight: added support for int|float [Closes #408] --- src/Latte/Essential/Filters.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index ffc396888..17a8a6056 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -477,7 +477,7 @@ public static function trim(FilterInfo $info, string $s, string $charlist = " \t /** * Pad a string to a certain length with another string. */ - public static function padLeft(string|Stringable|null $s, int $length, string $append = ' '): string + public static function padLeft(string|Stringable|int|float|null $s, int $length, string $append = ' '): string { $s = (string) $s; $length = max(0, $length - self::strLength($s)); @@ -489,7 +489,7 @@ public static function padLeft(string|Stringable|null $s, int $length, string $a /** * Pad a string to a certain length with another string. */ - public static function padRight(string|Stringable|null $s, int $length, string $append = ' '): string + public static function padRight(string|Stringable|int|float|null $s, int $length, string $append = ' '): string { $s = (string) $s; $length = max(0, $length - self::strLength($s)); From 8f5744f1cd223d5b13e0433472db12bbd52bf3cf Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 23 Dec 2025 21:12:38 +0100 Subject: [PATCH 2/9] added |column filter --- src/Latte/Essential/CoreExtension.php | 1 + src/Latte/Essential/Filters.php | 11 +++++ tests/filters/column.phpt | 60 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 tests/filters/column.phpt diff --git a/src/Latte/Essential/CoreExtension.php b/src/Latte/Essential/CoreExtension.php index 12c340436..4840d48cd 100644 --- a/src/Latte/Essential/CoreExtension.php +++ b/src/Latte/Essential/CoreExtension.php @@ -123,6 +123,7 @@ public function getFilters(): array 'ceil' => $this->filters->ceil(...), 'checkUrl' => $this->filters->checkUrl(...), 'clamp' => $this->filters->clamp(...), + 'column' => $this->filters->column(...), 'dataStream' => $this->filters->dataStream(...), 'datastream' => $this->filters->dataStream(...), 'date' => $this->filters->date(...), diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index 17a8a6056..50ccd091f 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -513,6 +513,17 @@ public static function reverse(string|iterable $val, bool $preserveKeys = false) } + /** + * Returns the values from a single column in the input array. + * @param iterable $data + * @return mixed[] + */ + public static function column(iterable $data, string|int|null $columnKey, string|int|null $indexKey = null): array + { + return array_column(iterator_to_array($data), $columnKey, $indexKey); + } + + /** * Chunks items by returning an array of arrays with the given number of items. * @param iterable $list diff --git a/tests/filters/column.phpt b/tests/filters/column.phpt new file mode 100644 index 000000000..25c4537ad --- /dev/null +++ b/tests/filters/column.phpt @@ -0,0 +1,60 @@ + 1, 'name' => 'John', 'age' => 30], + ['id' => 2, 'name' => 'Jane', 'age' => 25], + ['id' => 3, 'name' => 'Bob', 'age' => 35], +]; + +test('extracts single column', function () use ($data) { + Assert::same(['John', 'Jane', 'Bob'], Filters::column($data, 'name')); + Assert::same([1, 2, 3], Filters::column($data, 'id')); + Assert::same([30, 25, 35], Filters::column($data, 'age')); +}); + + +test('extracts column with custom index', function () use ($data) { + Assert::same([1 => 'John', 2 => 'Jane', 3 => 'Bob'], Filters::column($data, 'name', 'id')); + Assert::same(['John' => 30, 'Jane' => 25, 'Bob' => 35], Filters::column($data, 'age', 'name')); +}); + + +test('extracts all values when column is null', function () use ($data) { + Assert::same([ + ['id' => 1, 'name' => 'John', 'age' => 30], + ['id' => 2, 'name' => 'Jane', 'age' => 25], + ['id' => 3, 'name' => 'Bob', 'age' => 35], + ], Filters::column($data, null)); +}); + + +test('works with iterable', function () { + $iterator = new ArrayIterator([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], + ]); + + Assert::same(['Alice', 'Bob'], Filters::column($iterator, 'name')); +}); + + +test('works with numeric keys', function () { + $data = [ + [10, 'John', 30], + [20, 'Jane', 25], + [30, 'Bob', 35], + ]; + + Assert::same(['John', 'Jane', 'Bob'], Filters::column($data, 1)); + Assert::same([10, 20, 30], Filters::column($data, 0)); +}); From af2fda1675adccd9b67fb2782d722f4b3b42f857 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 23 Dec 2025 21:58:27 +0100 Subject: [PATCH 3/9] added |commas filter --- src/Latte/Essential/CoreExtension.php | 1 + src/Latte/Essential/Filters.php | 15 ++++++++ tests/filters/commas.phpt | 51 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 tests/filters/commas.phpt diff --git a/src/Latte/Essential/CoreExtension.php b/src/Latte/Essential/CoreExtension.php index 4840d48cd..b6119953e 100644 --- a/src/Latte/Essential/CoreExtension.php +++ b/src/Latte/Essential/CoreExtension.php @@ -124,6 +124,7 @@ public function getFilters(): array 'checkUrl' => $this->filters->checkUrl(...), 'clamp' => $this->filters->clamp(...), 'column' => $this->filters->column(...), + 'commas' => $this->filters->commas(...), 'dataStream' => $this->filters->dataStream(...), 'datastream' => $this->filters->dataStream(...), 'date' => $this->filters->date(...), diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index 50ccd091f..9267a70af 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -145,6 +145,21 @@ public static function implode(array $arr, string $glue = ''): string } + /** + * Join array elements with a comma and space. + * @param string[] $arr + */ + public static function commas(array $arr, ?string $lastGlue = null): string + { + if ($lastGlue === null || count($arr) < 2) { + return implode(', ', $arr); + } + + $last = array_pop($arr); + return implode(', ', $arr) . $lastGlue . $last; + } + + /** * Splits a string by a string. * @return list diff --git a/tests/filters/commas.phpt b/tests/filters/commas.phpt new file mode 100644 index 000000000..37c4da21d --- /dev/null +++ b/tests/filters/commas.phpt @@ -0,0 +1,51 @@ + Date: Wed, 11 Feb 2026 17:51:36 +0100 Subject: [PATCH 4/9] added |slice support for iterators and |limit filter --- src/Latte/Essential/CoreExtension.php | 1 + src/Latte/Essential/Filters.php | 37 ++++++++++++---- src/Latte/Sandbox/SecurityPolicy.php | 2 +- tests/filters/slice.phpt | 62 ++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/Latte/Essential/CoreExtension.php b/src/Latte/Essential/CoreExtension.php index b6119953e..63901f90e 100644 --- a/src/Latte/Essential/CoreExtension.php +++ b/src/Latte/Essential/CoreExtension.php @@ -152,6 +152,7 @@ public function getFilters(): array 'join' => $this->filters->implode(...), 'last' => $this->filters->last(...), 'length' => $this->filters->length(...), + 'limit' => fn(iterable $value, int $length, int $offset = 0) => Filters::slice($value, $offset, $length, preserveKeys: true), 'localDate' => $this->filters->localDate(...), 'lower' => extension_loaded('mbstring') ? $this->filters->lower(...) diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index 9267a70af..af7a9ee7e 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -759,20 +759,41 @@ public static function last(string|array $value): mixed /** - * Extracts a slice of an array or string. - * @param string|array $value - * @return ($value is string ? string : array) + * Extracts a slice of an array, string or iterator. + * @param string|iterable $value + * @return ($value is string ? string : ($value is array ? array : \Generator)) */ public static function slice( - string|array $value, + string|iterable $value, int $start, ?int $length = null, bool $preserveKeys = false, - ): string|array + ): string|array|\Generator { - return is_array($value) - ? array_slice($value, $start, $length, $preserveKeys) - : self::substring($value, $start, $length); + if (is_string($value)) { + return self::substring($value, $start, $length); + } elseif (is_array($value)) { + return array_slice($value, $start, $length, $preserveKeys); + } + + return (function () use ($value, $start, $length, $preserveKeys) { + $i = 0; + $count = 0; + foreach ($value as $key => $val) { + if ($i++ < $start) { + continue; + } + if ($length !== null && $count >= $length) { + break; + } + if ($preserveKeys) { + yield $key => $val; + } else { + yield $val; + } + $count++; + } + })(); } diff --git a/src/Latte/Sandbox/SecurityPolicy.php b/src/Latte/Sandbox/SecurityPolicy.php index 45304ff41..c6e191d3d 100644 --- a/src/Latte/Sandbox/SecurityPolicy.php +++ b/src/Latte/Sandbox/SecurityPolicy.php @@ -62,7 +62,7 @@ public static function createSafePolicy(): self 'batch', 'breaklines', 'breakLines', 'bytes', 'capitalize', 'ceil', 'clamp', 'date', 'escapeCss', 'escapeHtml', 'escapeHtmlComment', 'escapeICal', 'escapeJs', 'escapeUrl', 'escapeXml', 'explode', 'first', 'firstUpper', 'floor', 'checkUrl', 'implode', 'indent', 'join', 'last', 'length', 'lower', - 'number', 'padLeft', 'padRight', 'query', 'random', 'repeat', 'replace', 'replaceRe', 'reverse', + 'limit', 'number', 'padLeft', 'padRight', 'query', 'random', 'repeat', 'replace', 'replaceRe', 'reverse', 'round', 'slice', 'sort', 'spaceless', 'split', 'strip', 'striphtml', 'stripHtml', 'striptags', 'stripTags', 'substr', 'trim', 'truncate', 'upper', 'webalize', ]); diff --git a/tests/filters/slice.phpt b/tests/filters/slice.phpt index 1b9ca8b2a..036997b3f 100644 --- a/tests/filters/slice.phpt +++ b/tests/filters/slice.phpt @@ -1,7 +1,7 @@ 1; + yield 'b' => 2; + yield 'c' => 3; + yield 'd' => 4; + yield 'e' => 5; + }; + + Assert::type(Generator::class, Filters::slice($gen(), 0)); + Assert::same([], iterator_to_array(Filters::slice($gen(), 0, 0))); + Assert::same([1, 2, 3], iterator_to_array(Filters::slice($gen(), 0, 3))); + Assert::same([1, 2, 3, 4, 5], iterator_to_array(Filters::slice($gen(), 0))); + Assert::same([1, 2, 3, 4, 5], iterator_to_array(Filters::slice($gen(), 0, 99))); + Assert::same([2, 3, 4, 5], iterator_to_array(Filters::slice($gen(), 1))); + Assert::same([3, 4, 5], iterator_to_array(Filters::slice($gen(), 2))); + Assert::same([3, 4], iterator_to_array(Filters::slice($gen(), 2, 2))); + Assert::same([], iterator_to_array(Filters::slice($gen(), 5, 1))); +}); + + +test('iterators & preserveKeys', function () { + $gen = function () { + yield 'a' => 1; + yield 'b' => 2; + yield 'c' => 3; + yield 'd' => 4; + yield 'e' => 5; + }; + + Assert::same(['a' => 1, 'b' => 2, 'c' => 3], iterator_to_array(Filters::slice($gen(), 0, 3, preserveKeys: true))); + Assert::same(['c' => 3, 'd' => 4], iterator_to_array(Filters::slice($gen(), 2, 2, preserveKeys: true))); + Assert::same(['b' => 2, 'c' => 3, 'd' => 4, 'e' => 5], iterator_to_array(Filters::slice($gen(), 1, null, preserveKeys: true))); +}); + + +test('limit (slice alias with preserveKeys)', function () { + $limit = fn(iterable $value, int $length, int $offset = 0) => Filters::slice($value, $offset, $length, preserveKeys: true); + + $arr = ['a', 'b', 10 => 'd', 'e']; + + Assert::same([0 => 'a', 1 => 'b'], $limit($arr, 2)); + Assert::same([10 => 'd', 11 => 'e'], $limit($arr, 2, 2)); + Assert::same($arr, $limit($arr, 99)); + Assert::same([], $limit($arr, 0)); + + $gen = function () { + yield 'a' => 1; + yield 'b' => 2; + yield 'c' => 3; + yield 'd' => 4; + yield 'e' => 5; + }; + + Assert::same(['a' => 1, 'b' => 2, 'c' => 3], iterator_to_array($limit($gen(), 3))); + Assert::same(['c' => 3, 'd' => 4], iterator_to_array($limit($gen(), 2, 2))); + Assert::same([], iterator_to_array($limit($gen(), 0))); +}); + + test('strings', function () { $s = "\u{158}ekn\u{11B}te, jak se (dnes) m\u{E1}te?"; // Řekněte, jak se (dnes) máte? From fa49b125b640b4e21bd418f36dc847f3311a7b98 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 5 Jan 2026 11:23:52 +0100 Subject: [PATCH 5/9] Helpers::sortBeforeAfter() reimplemented using Kahn's algorithm for topological sorting --- src/Latte/Helpers.php | 83 ++++++++++++++----- tests/common/Helpers.sortBeforeAfter.phpt | 98 +++++++++++++++++++++++ 2 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 tests/common/Helpers.sortBeforeAfter.phpt diff --git a/src/Latte/Helpers.php b/src/Latte/Helpers.php index f26063274..3364fb6b4 100644 --- a/src/Latte/Helpers.php +++ b/src/Latte/Helpers.php @@ -7,7 +7,7 @@ namespace Latte; -use function array_filter, array_keys, array_search, array_slice, array_unique, count, is_array, is_object, is_string, levenshtein, max, min, strlen, strpos; +use function array_filter, array_keys, array_unique, count, in_array, is_array, is_object, is_string, levenshtein, strlen, strpos; use const PHP_VERSION_ID; @@ -54,45 +54,86 @@ public static function toReflection(mixed $callable): \ReflectionFunctionAbstrac /** + * Sorts items using topological sort based on before/after constraints. * @param array $list * @return array */ public static function sortBeforeAfter(array $list): array { + $names = array_keys($list); + + // Build adjacency list and in-degree count + // Edge A → B means "A must come before B" + $graph = array_fill_keys($names, []); + $inDegree = array_fill_keys($names, 0); + foreach ($list as $name => $info) { - if (!$info instanceof \stdClass || !($info->before ?? $info->after ?? null)) { + if (!$info instanceof \stdClass) { continue; } - unset($list[$name]); - $names = array_keys($list); - $best = null; - - foreach ((array) $info->before as $target) { + // "before: X" means this node → X (this comes before X) + foreach ((array) ($info->before ?? []) as $target) { if ($target === '*') { - $best = 0; - } elseif (isset($list[$target])) { - $pos = (int) array_search($target, $names, strict: true); - $best = min($pos, $best ?? $pos); + foreach ($names as $other) { + if ($other !== $name && !in_array($other, $graph[$name], true)) { + $graph[$name][] = $other; + $inDegree[$other]++; + } + } + } elseif (isset($list[$target]) && $target !== $name) { + if (!in_array($target, $graph[$name], true)) { + $graph[$name][] = $target; + $inDegree[$target]++; + } } } - foreach ((array) ($info->after ?? null) as $target) { + // "after: X" means X → this node (X comes before this) + foreach ((array) ($info->after ?? []) as $target) { if ($target === '*') { - $best = count($names); - } elseif (isset($list[$target])) { - $pos = (int) array_search($target, $names, strict: true); - $best = max($pos + 1, $best); + foreach ($names as $other) { + if ($other !== $name && !in_array($name, $graph[$other], true)) { + $graph[$other][] = $name; + $inDegree[$name]++; + } + } + } elseif (isset($list[$target]) && $target !== $name) { + if (!in_array($name, $graph[$target], true)) { + $graph[$target][] = $name; + $inDegree[$name]++; + } + } + } + } + + // Kahn's algorithm + $queue = []; + foreach ($names as $name) { + if ($inDegree[$name] === 0) { + $queue[] = $name; + } + } + + $result = []; + while ($queue) { + $node = array_shift($queue); + $result[$node] = $list[$node]; + + foreach ($graph[$node] as $neighbor) { + $inDegree[$neighbor]--; + if ($inDegree[$neighbor] === 0) { + $queue[] = $neighbor; } } + } - $best ??= count($names); - $list = array_slice($list, 0, $best, preserve_keys: true) - + [$name => $info] - + array_slice($list, $best, null, preserve_keys: true); + if (count($result) !== count($list)) { + $cycle = array_diff($names, array_keys($result)); + throw new \LogicException('Circular dependency detected among: ' . implode(', ', $cycle)); } - return $list; + return $result; } diff --git a/tests/common/Helpers.sortBeforeAfter.phpt b/tests/common/Helpers.sortBeforeAfter.phpt new file mode 100644 index 000000000..57a3abc99 --- /dev/null +++ b/tests/common/Helpers.sortBeforeAfter.phpt @@ -0,0 +1,98 @@ + Extension::order(fn() => null, $before, $after); + + +// Test: No constraints - preserve original order +$list = ['a' => fn() => 1, 'b' => fn() => 2, 'c' => fn() => 3]; +Assert::same(['a', 'b', 'c'], array_keys(Helpers::sortBeforeAfter($list))); + + +// Test: Simple before constraint (c before a) +$list = ['a' => fn() => 1, 'b' => fn() => 2, 'c' => $order(before: 'a')]; +$result = array_keys(Helpers::sortBeforeAfter($list)); +Assert::true(array_search('c', $result, true) < array_search('a', $result, true), 'c should be before a'); + + +// Test: Simple after constraint (a after c) +$list = ['a' => $order(after: 'c'), 'b' => fn() => 2, 'c' => fn() => 3]; +$result = array_keys(Helpers::sortBeforeAfter($list)); +Assert::true(array_search('a', $result, true) > array_search('c', $result, true), 'a should be after c'); + + +// Test: before: '*' (move to start) +$list = ['a' => fn() => 1, 'b' => fn() => 2, 'c' => $order(before: '*')]; +Assert::same('c', array_keys(Helpers::sortBeforeAfter($list))[0], 'c should be first'); + + +// Test: after: '*' (move to end) +$list = ['a' => $order(after: '*'), 'b' => fn() => 2, 'c' => fn() => 3]; +$result = array_keys(Helpers::sortBeforeAfter($list)); +Assert::same('a', $result[count($result) - 1], 'a should be last'); + + +// Test: Chained dependencies (c before b, b before a) +$list = ['a' => fn() => 1, 'b' => $order(before: 'a'), 'c' => $order(before: 'b')]; +Assert::same(['c', 'b', 'a'], array_keys(Helpers::sortBeforeAfter($list))); + + +// Test: Missing target ignored +$list = ['a' => fn() => 1, 'b' => $order(before: 'nonexistent')]; +Assert::same(['a', 'b'], array_keys(Helpers::sortBeforeAfter($list))); + + +// Test: Cycle detection (a before b, b before a) +Assert::exception(function () use ($order) { + $list = ['a' => $order(before: 'b'), 'b' => $order(before: 'a')]; + Helpers::sortBeforeAfter($list); +}, LogicException::class); + + +// Test: Multiple before targets +$list = ['a' => fn() => 1, 'b' => fn() => 2, 'c' => $order(before: ['a', 'b'])]; +Assert::same(['c', 'a', 'b'], array_keys(Helpers::sortBeforeAfter($list))); + + +// Test: Multiple after targets +$list = ['a' => $order(after: ['b', 'c']), 'b' => fn() => 2, 'c' => fn() => 3]; +Assert::same(['b', 'c', 'a'], array_keys(Helpers::sortBeforeAfter($list))); + + +// Test: Empty list +Assert::same([], Helpers::sortBeforeAfter([])); + + +// Test: Single item +$list = ['a' => fn() => 1]; +Assert::same(['a'], array_keys(Helpers::sortBeforeAfter($list))); + + +// Test: Combined before and after on same item +$list = ['a' => fn() => 1, 'b' => $order(after: 'a', before: 'c'), 'c' => fn() => 3]; +Assert::same(['a', 'b', 'c'], array_keys(Helpers::sortBeforeAfter($list))); + + +// Test: Two items with before: '*' (both want to be first) +Assert::exception(function () use ($order) { + $list = ['a' => $order(before: '*'), 'b' => $order(before: '*')]; + Helpers::sortBeforeAfter($list); +}, LogicException::class); + + +// Test: Two items with after: '*' (both want to be last) +Assert::exception(function () use ($order) { + $list = ['a' => $order(after: '*'), 'b' => $order(after: '*')]; + Helpers::sortBeforeAfter($list); +}, LogicException::class); From be9f93431116b74752d33c3086ab018ac44e5850 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 23 Jan 2026 19:38:43 +0100 Subject: [PATCH 6/9] ForeachNode::print() refactoring --- src/Latte/Essential/Nodes/ForeachNode.php | 54 +++++++++-------------- tests/tags/expected/foreach.else.php | 1 + 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/src/Latte/Essential/Nodes/ForeachNode.php b/src/Latte/Essential/Nodes/ForeachNode.php index 6059b106b..6153a82d0 100644 --- a/src/Latte/Essential/Nodes/ForeachNode.php +++ b/src/Latte/Essential/Nodes/ForeachNode.php @@ -85,46 +85,32 @@ private static function parseArguments(TagParser $parser, self $node): void public function print(PrintContext $context): string { $content = $this->content->print($context); - $iterator = $this->else || ($this->iterator ?? preg_match('#\$iterator\W|\Wget_defined_vars\W#', $content)); + $useIterator = $this->else || ($this->iterator ?? preg_match('#\$iterator\W|\Wget_defined_vars\W#', $content)); + + $code = $context->format( + "foreach (%raw as %raw) %line { %raw\n}\n", + $useIterator + ? $context->format('$iterator = $ʟ_it = new Latte\Essential\CachingIterator(%node, $ʟ_it ?? null)', $this->expression) + : $this->expression->print($context), + $this->printArgs($context), + $this->position, + $content, + ); if ($this->else) { - $content .= $context->format( - '} if ($iterator->isEmpty()) %line { ', + $code .= $context->format( + "if (%raw) %line { %node\n}\n", + $useIterator ? '$iterator->isEmpty()' : $context->format('empty(%node)', $this->expression), $this->elseLine, - ) . $this->else->print($context); - } - - if ($iterator) { - return $context->format( - <<<'XX' - foreach ($iterator = $ʟ_it = new Latte\Essential\CachingIterator(%node, $ʟ_it ?? null) as %raw) %line { - %raw - } - $iterator = $ʟ_it = $ʟ_it->getParent(); - - - XX, - $this->expression, - $this->printArgs($context), - $this->position, - $content, + $this->else, ); + } - } else { - return $context->format( - <<<'XX' - foreach (%node as %raw) %line { - %raw - } - - - XX, - $this->expression, - $this->printArgs($context), - $this->position, - $content, - ); + if ($useIterator) { + $code .= '$iterator = $ʟ_it = $ʟ_it->getParent();' . "\n"; } + + return $code . "\n"; } diff --git a/tests/tags/expected/foreach.else.php b/tests/tags/expected/foreach.else.php index 3a056d4f9..77cf9ff30 100644 --- a/tests/tags/expected/foreach.else.php +++ b/tests/tags/expected/foreach.else.php @@ -3,6 +3,7 @@ foreach ($iterator = $ʟ_it = new Latte\Essential\CachingIterator(['a'], $ʟ_it ?? null) as $item) /* pos 2:1 */ { echo ' item '; + } if ($iterator->isEmpty()) /* pos 4:2 */ { echo ' empty From e51c55184312c27f9b188c7be0481a109e09e1d9 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 23 Jan 2026 19:46:42 +0100 Subject: [PATCH 7/9] added Feature::ScopedLoopVariables --- src/Latte/Essential/CoreExtension.php | 6 +- src/Latte/Essential/Nodes/ForeachNode.php | 39 +++++ src/Latte/Feature.php | 3 + .../tags/expected/foreach.scopedVariables.php | 18 +++ tests/tags/foreach.scopedVariables.phpt | 137 ++++++++++++++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/tags/expected/foreach.scopedVariables.php create mode 100644 tests/tags/foreach.scopedVariables.phpt diff --git a/src/Latte/Essential/CoreExtension.php b/src/Latte/Essential/CoreExtension.php index 63901f90e..3e1f729de 100644 --- a/src/Latte/Essential/CoreExtension.php +++ b/src/Latte/Essential/CoreExtension.php @@ -213,7 +213,11 @@ public function getPasses(): array return [ 'internalVariables' => $passes->forbiddenVariablesPass(...), 'checkUrls' => $passes->checkUrlsPass(...), - 'overwrittenVariables' => Nodes\ForeachNode::overwrittenVariablesPass(...), + 'overwrittenVariables' => function (Latte\Compiler\Nodes\TemplateNode $node): void { + if (!$this->engine->hasFeature(Latte\Feature::ScopedLoopVariables)) { + Nodes\ForeachNode::overwrittenVariablesPass($node); + } + }, 'customFunctions' => $passes->customFunctionsPass(...), 'moveTemplatePrintToHead' => Nodes\TemplatePrintNode::moveToHeadPass(...), 'nElse' => Nodes\NElseNode::processPass(...), diff --git a/src/Latte/Essential/Nodes/ForeachNode.php b/src/Latte/Essential/Nodes/ForeachNode.php index 6153a82d0..8635b8048 100644 --- a/src/Latte/Essential/Nodes/ForeachNode.php +++ b/src/Latte/Essential/Nodes/ForeachNode.php @@ -22,6 +22,7 @@ use Latte\Compiler\PrintContext; use Latte\Compiler\Tag; use Latte\Compiler\TagParser; +use Latte\Feature; use function array_map, array_unshift, implode, is_string, preg_match; @@ -110,6 +111,26 @@ public function print(PrintContext $context): string $code .= '$iterator = $ʟ_it = $ʟ_it->getParent();' . "\n"; } + $vars = array_merge($this->key ? $this->collectVariables($this->key) : [], $this->collectVariables($this->value)); + if ($vars && !$this->byRef && $context->hasFeature(Feature::ScopedLoopVariables)) { + $backup = '$ʟ_fe_' . $context->generateId(); + $unsetList = implode(', ', array_map(fn($var) => $var->print($context), $vars)); + $restoreCode = implode('', array_map(fn($var) => $context->format("if (array_key_exists(%dump, $backup)) { %node = &{$backup}[%0.dump]; }\n", $var->name, $var), $vars)); + $code = <<name)) { + $res[] = $node; + } elseif ($node instanceof ListNode) { + foreach ($node->items as $item) { + $res = array_merge($res, $item ? $this->collectVariables($item->value) : []); + } + } + return $res; + } + + public function &getIterator(): \Generator { yield $this->expression; diff --git a/src/Latte/Feature.php b/src/Latte/Feature.php index 334a93dc4..0e4d81c48 100644 --- a/src/Latte/Feature.php +++ b/src/Latte/Feature.php @@ -21,4 +21,7 @@ enum Feature /** Shows warnings for deprecated Latte 3.0 syntax */ case MigrationWarnings; + + /** Variables from {foreach} exist only within the loop */ + case ScopedLoopVariables; } diff --git a/tests/tags/expected/foreach.scopedVariables.php b/tests/tags/expected/foreach.scopedVariables.php new file mode 100644 index 000000000..177d1d31a --- /dev/null +++ b/tests/tags/expected/foreach.scopedVariables.php @@ -0,0 +1,18 @@ +setFeature(Latte\Feature::ScopedLoopVariables); + Assert::same($expected, $latte->renderToString($template, $params), $title); +} + + +// Default behavior: variables leak outside the loop (no scoping) +$latte = createLatte(); +$code = $latte->compile('{foreach [1, 2] as $v}{/foreach}'); +Assert::contains('foreach (', $code); +Assert::notContains('try {', $code); + + +// With ScopedLoopVariables: generates scope save/restore code +$latte = createLatte(); +$latte->setFeature(Latte\Feature::ScopedLoopVariables); +$code = $latte->compile('{foreach [1, 2] as $v}{/foreach}'); +Assert::contains('try {', $code); +Assert::contains('finally {', $code); + + +testScoped( + 'variable restored after loop', + '{var $v = "X"}before={$v} {foreach [1, 2] as $v}loop={$v} {/foreach}after={$v}', + 'before=X loop=1 loop=2 after=X', +); + + +testScoped( + 'null variable restored after loop', + '{var $v = null}before={$v ?? "null"} {foreach [1, 2] as $v}loop={$v} {/foreach}after={$v ?? "null"}{array_key_exists("v", get_defined_vars()) ? " exists" : " !exists"}', + 'before=null loop=1 loop=2 after=null exists', +); + + +testScoped( + 'variable unset after loop (did not exist before)', + '{foreach [1, 2] as $v}loop={$v} {/foreach}after={$v ?? "unset"}', + 'loop=1 loop=2 after=unset', +); + + +testScoped( + 'key and value both scoped', + '{foreach ["a", "b"] as $k => $v}loop={$k}:{$v} {/foreach}after={$k ?? "unset"}:{$v ?? "unset"}', + 'loop=0:a loop=1:b after=unset:unset', +); + + +testScoped( + 'destructuring: all variables scoped', + '{foreach [[1, 2], [3, 4]] as [$a, $b]}loop={$a}-{$b} {/foreach}after={$a ?? "unset"}:{$b ?? "unset"}', + 'loop=1-2 loop=3-4 after=unset:unset', +); + + +// Property access ($obj->val): not a simple variable, no scoping +$latte = createLatte(); +$latte->setFeature(Latte\Feature::ScopedLoopVariables); +$code = $latte->compile('{var $obj = new stdClass}{foreach [1, 2] as $obj->val}{$obj->val}{/foreach}'); +Assert::notContains('try {', $code); + + +// Reference in foreach ({foreach as &$v}): scoping disabled, would break reference semantics +$latte = createLatte(); +$latte->setFeature(Latte\Feature::ScopedLoopVariables); +$code = $latte->compile('{foreach $arr as &$v}{$v}{/foreach}'); +Assert::notContains('try {', $code); + + +// Dynamic variable name ({foreach as ${"'"}}): scoping disabled, only simple names supported +$latte = createLatte(); +$latte->setFeature(Latte\Feature::ScopedLoopVariables); +$code = $latte->compile('{foreach [1] as ${"\'"}}{/foreach}'); +Assert::notContains('try {', $code); + + +testScoped( + '{else} branch (non-empty)', + '{foreach [1, 2] as $v}loop={$v} {else}empty {/foreach}after={$v ?? "unset"}', + 'loop=1 loop=2 after=unset', +); + +testScoped( + '{else} branch (empty)', + '{foreach [] as $v}loop={$v} {else}empty {/foreach}after={$v ?? "unset"}', + 'empty after=unset', +); + + +// overwrittenVariablesPass: skipped when ScopedLoopVariables enabled (no warning) +$latte = createLatte(); +$latte->setFeature(Latte\Feature::ScopedLoopVariables); +Assert::noError(fn() => $latte->renderToString('{foreach [1] as $v}{/foreach}', ['v' => 'param'])); + + +testScoped( + 'empty foreach body', + '{var $v = "X"}before={$v} {foreach [1, 2] as $v}{/foreach}after={$v}', + 'before=X after=X', +); + + +testScoped( + 'reference preservation', + '{var $v = "X", $ref = &$v}{foreach [1] as $v}{/foreach}v={$v} ref={$ref}', + 'v=X ref=X', +); + + +testScoped( + 'nested loops: independent scopes', + '{foreach ["A", "B"] as $v}outer={$v} {foreach ["x", "y"] as $v}inner={$v} {/foreach}restored={$v} {/foreach}after={$v ?? "unset"}', + 'outer=A inner=x inner=y restored=A outer=B inner=x inner=y restored=B after=unset', +); + + +// Cleanup: helper array entries are unset after restore +$latte = createLatte(); +$latte->setFeature(Latte\Feature::ScopedLoopVariables); +Assert::matchFile( + __DIR__ . '/expected/foreach.scopedVariables.php', + $latte->compile('{foreach [1] as $v}{/foreach}'), +); From 78909e27a683daa85360ddf9e7e7e2f7f91107d1 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 26 Feb 2026 00:20:08 +0100 Subject: [PATCH 8/9] github actions: code coverage job is non-blocking --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 251ad16cf..ae43586a0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,6 +32,7 @@ jobs: code_coverage: name: Code Coverage runs-on: ubuntu-latest + continue-on-error: true steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 From a67de139e2a99a06add4c5a560d7ada59bdb1e28 Mon Sep 17 00:00:00 2001 From: Rich Lott / Artful Robot Date: Tue, 3 Mar 2026 15:40:12 +0000 Subject: [PATCH 9/9] Stop encoding {{ with empty comment --- src/Latte/Runtime/HtmlHelpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Latte/Runtime/HtmlHelpers.php b/src/Latte/Runtime/HtmlHelpers.php index 0f4ca9c4b..42d98126d 100644 --- a/src/Latte/Runtime/HtmlHelpers.php +++ b/src/Latte/Runtime/HtmlHelpers.php @@ -43,7 +43,7 @@ public static function escapeText(mixed $s): string } $s = htmlspecialchars((string) $s, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8'); - $s = strtr($s, ['{{' => '{{', '{' => '{']); + $s = strtr($s, ['{{' => '{{', '{' => '{']); return $s; }