From ecf3c51a44487d06af943a9389fc43dbfdabc510 Mon Sep 17 00:00:00 2001 From: ryo-morimoto Date: Sun, 30 Nov 2025 23:54:26 +0900 Subject: [PATCH 1/6] Add test for capitalize first character only --- tests/Liquid/StandardFiltersTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Liquid/StandardFiltersTest.php b/tests/Liquid/StandardFiltersTest.php index c7b04f1..12e5576 100644 --- a/tests/Liquid/StandardFiltersTest.php +++ b/tests/Liquid/StandardFiltersTest.php @@ -129,11 +129,11 @@ public function testUpcase() public function testCapitalize() { $data = [ - 'one Word not' => 'One Word Not', - '1test' => '1Test', + 'one Word not' => 'One word not', + '1test' => '1test', '' => '', // UTF-8 - 'владимир владимирович' => 'Владимир Владимирович', + 'владимир владимирович' => 'Владимир владимирович', ]; foreach ($data as $element => $expected) { From 68ff56592941db14c640b9171e647a5ba137aef2 Mon Sep 17 00:00:00 2001 From: ryo-morimoto Date: Sun, 30 Nov 2025 23:54:38 +0900 Subject: [PATCH 2/6] Add test for divided_by integer division --- tests/Liquid/StandardFiltersTest.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Liquid/StandardFiltersTest.php b/tests/Liquid/StandardFiltersTest.php index 12e5576..76dcc31 100644 --- a/tests/Liquid/StandardFiltersTest.php +++ b/tests/Liquid/StandardFiltersTest.php @@ -1083,6 +1083,33 @@ public function testDivideBy() foreach ($data as $item) { $this->assertEqualsWithDelta($item[2], StandardFilters::divided_by($item[0], $item[1]), 0.00001); } + + $data = [ + [ + 12, + 3, + 4, + ], + [ + 14, + 3, + 4, + ], + [ + 15, + 3, + 5, + ], + [ + 5, + 3, + 1, + ], + ]; + + foreach ($data as $item) { + $this->assertSame($item[2], StandardFilters::divided_by($item[0], $item[1])); + } } public function testModulo() From 5f9dd0160f1c6e59bec93cacaf3c91ecdf1543e4 Mon Sep 17 00:00:00 2001 From: ryo-morimoto Date: Sun, 30 Nov 2025 23:54:34 +0900 Subject: [PATCH 3/6] Add test for map single hash and nested array flatten --- tests/Liquid/StandardFiltersTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Liquid/StandardFiltersTest.php b/tests/Liquid/StandardFiltersTest.php index 76dcc31..498e5e9 100644 --- a/tests/Liquid/StandardFiltersTest.php +++ b/tests/Liquid/StandardFiltersTest.php @@ -765,6 +765,14 @@ function () { 0, 0, ], + [ + ['attr' => 'single value'], + ['single value'], + ], + [ + [[['attr' => 1], ['attr' => 2]], [['attr' => 3]]], + [1, 2, 3], + ], ]; foreach ($data as $item) { From 3175b1bda9943d0cb30fb5eaa6ddeb4e5a719f5b Mon Sep 17 00:00:00 2001 From: ryo-morimoto Date: Tue, 2 Dec 2025 04:40:40 +0900 Subject: [PATCH 4/6] Fix map filter to handle single hash and nested arrays --- src/Liquid/Liquid.php | 28 ++++++++++++++-- src/Liquid/StandardFilters.php | 11 ++++++- tests/Liquid/LiquidTest.php | 58 ++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/Liquid/Liquid.php b/src/Liquid/Liquid.php index 6a02d04..f628a6f 100644 --- a/src/Liquid/Liquid.php +++ b/src/Liquid/Liquid.php @@ -151,20 +151,44 @@ public static function set($key, $value) * Flatten a multidimensional array into a single array. Does not maintain keys. * * @param array $array + * @param bool $skipHash If true, associative arrays (hashes) are preserved without flattening. + * This mimics Ruby's Array#flatten behavior which preserves Hash objects. * * @return array */ - public static function arrayFlatten($array) + public static function arrayFlatten($array, $skipHash = false) { $return = []; foreach ($array as $element) { if (is_array($element)) { - $return = array_merge($return, self::arrayFlatten($element)); + if ($skipHash && self::isHash($element)) { + $return[] = $element; + } else { + $return = array_merge($return, self::arrayFlatten($element, $skipHash)); + } } else { $return[] = $element; } } return $return; } + + /** + * Determine if an array is a hash (associative array). + * This is a polyfill for !array_is_list() (PHP 8.1+). + * + * @param array $array + * + * @return bool + * + * @see https://www.php.net/manual/en/function.array-is-list.php + */ + public static function isHash(array $array): bool + { + if (empty($array)) { + return false; + } + return array_keys($array) !== range(0, count($array) - 1); + } } diff --git a/src/Liquid/StandardFilters.php b/src/Liquid/StandardFilters.php index 086b517..b8dac0b 100644 --- a/src/Liquid/StandardFilters.php +++ b/src/Liquid/StandardFilters.php @@ -316,7 +316,7 @@ public static function lstrip($input) * @param array|\Traversable $input * @param string $property * - * @return string + * @return mixed */ public static function map($input, $property) { @@ -326,6 +326,15 @@ public static function map($input, $property) if (!is_array($input)) { return $input; } + + if (Liquid::isHash($input)) { + $input = [$input]; + } + + // Flatten nested arrays while preserving hashes + // [[['attr' => 1]]] => [['attr' => 1]] + $input = Liquid::arrayFlatten($input, true); + return array_map(function ($elem) use ($property) { if (is_callable($elem)) { return $elem(); diff --git a/tests/Liquid/LiquidTest.php b/tests/Liquid/LiquidTest.php index f90ba53..c48683a 100644 --- a/tests/Liquid/LiquidTest.php +++ b/tests/Liquid/LiquidTest.php @@ -85,4 +85,62 @@ public function testArrayFlattenNestedArray() $this->assertEquals($expected, Liquid::arrayFlatten($original)); } + + public function testArrayFlattenSkipHash() + { + $original = [ + [['attr' => 1], ['attr' => 2]], + [['attr' => 3]], + ]; + + $expected = [ + ['attr' => 1], + ['attr' => 2], + ['attr' => 3], + ]; + + $this->assertEquals($expected, Liquid::arrayFlatten($original, true)); + } + + public function testArrayFlattenSkipHashMixedContent() + { + $original = [ + ['name' => 'John', 'age' => 30], + [1, 2, 3], + ['key' => 'value'], + ]; + + $expected = [ + ['name' => 'John', 'age' => 30], + 1, + 2, + 3, + ['key' => 'value'], + ]; + + $this->assertEquals($expected, Liquid::arrayFlatten($original, true)); + } + + public function testIsHashWithEmptyArray() + { + $this->assertFalse(Liquid::isHash([])); + } + + public function testIsHashWithIndexedArray() + { + $this->assertFalse(Liquid::isHash(['a', 'b', 'c'])); + $this->assertFalse(Liquid::isHash([0 => 'a', 1 => 'b', 2 => 'c'])); + } + + public function testIsHashWithAssociativeArray() + { + $this->assertTrue(Liquid::isHash(['name' => 'John'])); + $this->assertTrue(Liquid::isHash(['a' => 1, 'b' => 2])); + } + + public function testIsHashWithNonSequentialKeys() + { + $this->assertTrue(Liquid::isHash([1 => 'a', 2 => 'b'])); + $this->assertTrue(Liquid::isHash([0 => 'a', 2 => 'b'])); + } } From 7c5d4c355f5d0433d0b691119ed2ce746fd89d4e Mon Sep 17 00:00:00 2001 From: ryo-morimoto Date: Tue, 2 Dec 2025 04:42:51 +0900 Subject: [PATCH 5/6] Fix math filters to preserve integer type for integer operands --- src/Liquid/Liquid.php | 21 ++++ src/Liquid/StandardFilters.php | 57 +++++---- tests/Liquid/LiquidTest.php | 37 ++++++ tests/Liquid/StandardFiltersTest.php | 176 +++++++++++++++++++++------ 4 files changed, 236 insertions(+), 55 deletions(-) diff --git a/src/Liquid/Liquid.php b/src/Liquid/Liquid.php index f628a6f..f917b88 100644 --- a/src/Liquid/Liquid.php +++ b/src/Liquid/Liquid.php @@ -191,4 +191,25 @@ public static function isHash(array $array): bool } return array_keys($array) !== range(0, count($array) - 1); } + + /** + * Determine if a value represents an integer. + * Returns true for int type or string integers (e.g., "20", "-5"). + * Returns false for floats (e.g., 20.0) to preserve float division behavior. + * + * @param mixed $value + * + * @return bool + */ + public static function isInteger($value): bool + { + if (is_int($value)) { + return true; + } + if (is_string($value)) { + $trimmed = ltrim($value, '-'); + return $trimmed !== '' && ctype_digit($trimmed); + } + return false; + } } diff --git a/src/Liquid/StandardFilters.php b/src/Liquid/StandardFilters.php index b8dac0b..cfdc259 100644 --- a/src/Liquid/StandardFilters.php +++ b/src/Liquid/StandardFilters.php @@ -111,16 +111,19 @@ public static function _default($input, $default_value) /** - * division + * Division * - * @param float $input - * @param float $operand + * @param int|float|string $input + * @param int|float|string $operand * - * @return float + * @return int|float */ public static function divided_by($input, $operand) { - return (float)$input / (float)$operand; + if (Liquid::isInteger($input) && Liquid::isInteger($operand)) { + return (int) floor($input / $operand); + } + return (float) $input / (float) $operand; } @@ -347,29 +350,35 @@ public static function map($input, $property) /** - * subtraction + * Subtraction * - * @param float $input - * @param float $operand + * @param int|float|string $input + * @param int|float|string $operand * - * @return float + * @return int|float */ public static function minus($input, $operand) { + if (Liquid::isInteger($input) && Liquid::isInteger($operand)) { + return (int)$input - (int)$operand; + } return (float)$input - (float)$operand; } /** - * modulo + * Modulo * - * @param float $input - * @param float $operand + * @param int|float|string $input + * @param int|float|string $operand * - * @return float + * @return int|float */ public static function modulo($input, $operand) { + if (Liquid::isInteger($input) && Liquid::isInteger($operand)) { + return (int)$input % (int)$operand; + } return fmod((float)$input, (float)$operand); } @@ -388,15 +397,18 @@ public static function newline_to_br($input) /** - * addition + * Addition * - * @param float $input - * @param float $operand + * @param int|float|string $input + * @param int|float|string $operand * - * @return float + * @return int|float */ public static function plus($input, $operand) { + if (Liquid::isInteger($input) && Liquid::isInteger($operand)) { + return (int)$input + (int)$operand; + } return (float)$input + (float)$operand; } @@ -682,15 +694,18 @@ public static function strip_newlines($input) /** - * multiplication + * Multiplication * - * @param float $input - * @param float $operand + * @param int|float|string $input + * @param int|float|string $operand * - * @return float + * @return int|float */ public static function times($input, $operand) { + if (Liquid::isInteger($input) && Liquid::isInteger($operand)) { + return (int)$input * (int)$operand; + } return (float)$input * (float)$operand; } diff --git a/tests/Liquid/LiquidTest.php b/tests/Liquid/LiquidTest.php index c48683a..1fdc3ec 100644 --- a/tests/Liquid/LiquidTest.php +++ b/tests/Liquid/LiquidTest.php @@ -143,4 +143,41 @@ public function testIsHashWithNonSequentialKeys() $this->assertTrue(Liquid::isHash([1 => 'a', 2 => 'b'])); $this->assertTrue(Liquid::isHash([0 => 'a', 2 => 'b'])); } + + public function testIsIntegerWithIntType() + { + $this->assertTrue(Liquid::isInteger(20)); + $this->assertTrue(Liquid::isInteger(0)); + $this->assertTrue(Liquid::isInteger(-5)); + } + + public function testIsIntegerWithStringInteger() + { + $this->assertTrue(Liquid::isInteger('20')); + $this->assertTrue(Liquid::isInteger('0')); + $this->assertTrue(Liquid::isInteger('-5')); + } + + public function testIsIntegerWithFloat() + { + $this->assertFalse(Liquid::isInteger(20.0)); + $this->assertFalse(Liquid::isInteger(20.5)); + $this->assertFalse(Liquid::isInteger(-5.0)); + } + + public function testIsIntegerWithStringFloat() + { + $this->assertFalse(Liquid::isInteger('20.0')); + $this->assertFalse(Liquid::isInteger('20.5')); + $this->assertFalse(Liquid::isInteger('-5.5')); + } + + public function testIsIntegerWithInvalidValues() + { + $this->assertFalse(Liquid::isInteger('')); + $this->assertFalse(Liquid::isInteger('-')); + $this->assertFalse(Liquid::isInteger('abc')); + $this->assertFalse(Liquid::isInteger(null)); + $this->assertFalse(Liquid::isInteger([])); + } } diff --git a/tests/Liquid/StandardFiltersTest.php b/tests/Liquid/StandardFiltersTest.php index 498e5e9..555b0bc 100644 --- a/tests/Liquid/StandardFiltersTest.php +++ b/tests/Liquid/StandardFiltersTest.php @@ -982,20 +982,47 @@ public function testPlus() { $data = [ [ - '', - '', - 0, + 1, + 1, + 2, + ], + [ + '3', + '4', + 7, + ], + [ + '-5', + '3', + -2, ], [ 10, 20, 30, ], + ]; + + foreach ($data as $item) { + $this->assertSame($item[2], StandardFilters::plus($item[0], $item[1])); + } + + $data = [ + [ + '1', + '1.0', + 2.0, + ], [ 1.5, 2.7, 4.2, ], + [ + '', + '', + 0.0, + ], ]; foreach ($data as $item) { @@ -1007,15 +1034,32 @@ public function testMinus() { $data = [ [ - '', - '', - 0, + 5, + 1, + 4, + ], + [ + '10', + '3', + 7, ], [ 10, 20, -10, ], + ]; + + foreach ($data as $item) { + $this->assertSame($item[2], StandardFilters::minus($item[0], $item[1])); + } + + $data = [ + [ + '4.3', + '2', + 2.3, + ], [ 1.5, 2.7, @@ -1024,7 +1068,12 @@ public function testMinus() [ 3.1, 3.1, - 0, + 0.0, + ], + [ + '', + '', + 0.0, ], ]; @@ -1037,62 +1086,77 @@ public function testTimes() { $data = [ [ - '', - '', - 0, + 3, + 4, + 12, ], [ + '5', + '2', 10, - 20, - 200, ], [ - 1.5, - 2.7, - 4.05, + -3, + 4, + -12, ], [ - 7.5, - 0, - 0, + 10, + 20, + 200, ], ]; foreach ($data as $item) { - $this->assertEqualsWithDelta($item[2], StandardFilters::times($item[0], $item[1]), 0.00001); + $this->assertSame($item[2], StandardFilters::times($item[0], $item[1])); } - } - public function testDivideBy() - { $data = [ [ - '20', - 10, - 2, + 0.0725, + 100, + 7.25, ], [ - 10, - 20, - 0.5, + '-0.0725', + 100, + -7.25, ], [ + '-0.0725', + -100, + 7.25, + ], + [ + 1.5, + 2.7, + 4.05, + ], + [ + 7.5, 0, - 200, - 0, + 0.0, ], [ - 10, - 0.5, - 20, + '', + '', + 0.0, ], ]; foreach ($data as $item) { - $this->assertEqualsWithDelta($item[2], StandardFilters::divided_by($item[0], $item[1]), 0.00001); + $this->assertEqualsWithDelta($item[2], StandardFilters::times($item[0], $item[1]), 0.00001); } + } + public function testDivideBy() + { $data = [ + [ + '20', + 10, + 2, + ], [ 12, 3, @@ -1118,11 +1182,48 @@ public function testDivideBy() foreach ($data as $item) { $this->assertSame($item[2], StandardFilters::divided_by($item[0], $item[1])); } + + $data = [ + [ + '20.0', + 10, + 2.0, + ], + [ + 10.0, + 20, + 0.5, + ], + [ + 10, + 20.0, + 0.5, + ], + [ + 0, + 200.0, + 0.0, + ], + [ + 10, + 0.5, + 20.0, + ], + ]; + + foreach ($data as $item) { + $this->assertEqualsWithDelta($item[2], StandardFilters::divided_by($item[0], $item[1]), 0.00001); + } } public function testModulo() { $data = [ + [ + '7', + '3', + 1, + ], [ '20', 10, @@ -1138,6 +1239,13 @@ public function testModulo() 3, 2, ], + ]; + + foreach ($data as $item) { + $this->assertSame($item[2], StandardFilters::modulo($item[0], $item[1])); + } + + $data = [ [ 8.9, 3.5, From d6ca7fb9e58201b0c37574cf4a68aac8d19cf157 Mon Sep 17 00:00:00 2001 From: ryo-morimoto Date: Tue, 2 Dec 2025 04:43:53 +0900 Subject: [PATCH 6/6] Fix capitalize filter to uppercase first character only --- src/Liquid/StandardFilters.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Liquid/StandardFilters.php b/src/Liquid/StandardFilters.php index cfdc259..43ed9c0 100644 --- a/src/Liquid/StandardFilters.php +++ b/src/Liquid/StandardFilters.php @@ -34,7 +34,7 @@ public static function append($input, $string) /** - * Capitalize words in the input sentence + * Capitalize the first character and downcase the rest * * @param string $input * @@ -42,10 +42,9 @@ public static function append($input, $string) */ public static function capitalize($input) { - return preg_replace_callback("/(^|[^\p{L}'])([\p{Ll}])/u", function ($matches) { - $first_char = mb_substr($matches[2], 0, 1); - return $matches[1] . mb_strtoupper($first_char) . mb_substr($matches[2], 1); - }, ucwords($input)); + $firstChar = mb_strtoupper(mb_substr($input, 0, 1, 'UTF-8'), 'UTF-8'); + $rest = mb_strtolower(mb_substr($input, 1, null, 'UTF-8'), 'UTF-8'); + return $firstChar . $rest; }