diff --git a/doc/functions/cycle.rst b/doc/functions/cycle.rst index 3b6db61c4..8b159e1e5 100644 --- a/doc/functions/cycle.rst +++ b/doc/functions/cycle.rst @@ -1,7 +1,7 @@ ``cycle`` ========= -The ``cycle`` function cycles on a sequence or mapping: +The ``cycle`` function cycles on a sequence: .. code-block:: twig @@ -23,7 +23,9 @@ The ``cycle`` function cycles on a sequence or mapping: #} -The array can contain any number of values: +The ``cycle`` function takes two arguments: the ``sequence`` to cycle through and the ``position`` in the sequence. + +The ``sequence`` must be non-empty and can contain any number of values: .. code-block:: twig @@ -52,5 +54,5 @@ The array can contain any number of values: Arguments --------- -* ``values``: The list of values to cycle on -* ``position``: The cycle position +* ``values``: The sequence to cycle on +* ``position``: The position in the sequence diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index e8cf5f6f2..fa2d90c45 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -330,26 +330,39 @@ final class CoreExtension extends AbstractExtension } /** - * Cycles over a value. + * Cycles over a sequence. * - * @param \ArrayAccess|array $values - * @param int $position The cycle position + * @param array|\ArrayAccess $values A non-empty sequence of values + * @param positive-int $position The position of the value to return in the cycle * - * @return string The next value in the cycle + * @return mixed The value at the given position in the sequence, wrapping around as needed * * @internal */ - public static function cycle($values, $position): string + public static function cycle($values, $position): mixed { - if (!\is_array($values) && !$values instanceof \ArrayAccess) { - return $values; + if (!\is_array($values)) { + if (!$values instanceof \ArrayAccess) { + throw new RuntimeError('The "cycle" function expects an array or "ArrayAccess" as first argument.'); + } + + if (!\is_countable($values)) { + // To be uncommented in 4.0 + // throw new RuntimeError('The "cycle" function expects a countable sequence as first argument.'); + + trigger_deprecation('twig/twig', '3.12', 'Passing a non-countable sequence of values to "%s()" is deprecated.', __METHOD__); + + return $values; + } + + $values = self::toArray($values, false); } - if (!\count($values)) { - throw new RuntimeError('The "cycle" function does not work on empty sequences/mappings.'); + if (!$count = \count($values)) { + throw new RuntimeError('The "cycle" function does not work on empty sequences.'); } - return $values[$position % \count($values)]; + return $values[$position % $count]; } /** diff --git a/tests/Extension/CoreTest.php b/tests/Extension/CoreTest.php index 314586281..f61980bf9 100644 --- a/tests/Extension/CoreTest.php +++ b/tests/Extension/CoreTest.php @@ -17,6 +17,46 @@ use Twig\Extension\CoreExtension; class CoreTest extends TestCase { + /** + * @dataProvider provideCycleCases + */ + public function testCycleFunction($values, $position, $expected) + { + $this->assertSame($expected, CoreExtension::cycle($values, $position)); + } + + public static function provideCycleCases() + { + return [ + [[1, 2, 3], 0, 1], + [[1, 2, 3], 1, 2], + [[1, 2, 3], 2, 3], + [[1, 2, 3], 3, 1], + [[false, 0, null], 0, false], + [[false, 0, null], 1, 0], + [[false, 0, null], 2, null], + + [[['a', 'b'], ['c', 'd']], 3, ['c', 'd']], + ]; + } + + /** + * @dataProvider provideCycleInvalidCases + */ + public function testCycleFunctionThrowRuntimeError($values, mixed $position = null) + { + $this->expectException(RuntimeError::class); + CoreExtension::cycle($values, $position ?? 0); + } + + public static function provideCycleInvalidCases() + { + return [ + 'empty' => [[]], + 'non-countable' => [new class extends \ArrayObject{}], + ]; + } + /** * @dataProvider getRandomFunctionTestData */ diff --git a/tests/Fixtures/functions/cycle.test b/tests/Fixtures/functions/cycle.test index 0ac6dccd3..e54f43318 100644 --- a/tests/Fixtures/functions/cycle.test +++ b/tests/Fixtures/functions/cycle.test @@ -2,15 +2,19 @@ "cycle" function --TEMPLATE-- {% for i in 0..6 %} -{{ cycle(array1, i) }}-{{ cycle(array2, i) }} +{{ cycle(array1, i) }}-{{ cycle(array2, i) }}-{{ cycle(array3, i) }} {% endfor %} --DATA-- -return ['array1' => ['odd', 'even'], 'array2' => ['apple', 'orange', 'citrus']] +return [ + 'array1' => ['odd', 'even'], + 'array2' => ['apple', 'orange', 'citrus'], + 'array3' => [1, 2, false, null], +]; --EXPECT-- -odd-apple -even-orange -odd-citrus -even-apple -odd-orange -even-citrus -odd-apple +odd-apple-1 +even-orange-2 +odd-citrus- +even-apple- +odd-orange-1 +even-citrus-2 +odd-apple- diff --git a/tests/Fixtures/functions/cycle_empty_mapping.test b/tests/Fixtures/functions/cycle_empty_mapping.test index 6296c2c39..ca241d8f3 100644 --- a/tests/Fixtures/functions/cycle_empty_mapping.test +++ b/tests/Fixtures/functions/cycle_empty_mapping.test @@ -5,4 +5,4 @@ --DATA-- return [] --EXCEPTION-- -Twig\Error\RuntimeError: The "cycle" function does not work on empty sequences/mappings in "index.twig" at line 2. +Twig\Error\RuntimeError: The "cycle" function does not work on empty sequences in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/cycle_empty_sequence.test b/tests/Fixtures/functions/cycle_empty_sequence.test index 01d9fe127..846913b20 100644 --- a/tests/Fixtures/functions/cycle_empty_sequence.test +++ b/tests/Fixtures/functions/cycle_empty_sequence.test @@ -5,4 +5,4 @@ --DATA-- return [] --EXCEPTION-- -Twig\Error\RuntimeError: The "cycle" function does not work on empty sequences/mappings in "index.twig" at line 2. +Twig\Error\RuntimeError: The "cycle" function does not work on empty sequences in "index.twig" at line 2.