diff --git a/src/Liquid/Liquid.php b/src/Liquid/Liquid.php index 6a02d04..f917b88 100644 --- a/src/Liquid/Liquid.php +++ b/src/Liquid/Liquid.php @@ -151,20 +151,65 @@ 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); + } + + /** + * 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 086b517..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; } @@ -111,16 +110,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; } @@ -316,7 +318,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 +328,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(); @@ -338,29 +349,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); } @@ -379,15 +396,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; } @@ -673,15 +693,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 f90ba53..1fdc3ec 100644 --- a/tests/Liquid/LiquidTest.php +++ b/tests/Liquid/LiquidTest.php @@ -85,4 +85,99 @@ 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'])); + } + + 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 c7b04f1..555b0bc 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) { @@ -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) { @@ -974,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) { @@ -999,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, @@ -1016,7 +1068,12 @@ public function testMinus() [ 3.1, 3.1, - 0, + 0.0, + ], + [ + '', + '', + 0.0, ], ]; @@ -1029,24 +1086,61 @@ public function testTimes() { $data = [ [ - '', - '', - 0, + 3, + 4, + 12, + ], + [ + '5', + '2', + 10, + ], + [ + -3, + 4, + -12, ], [ 10, 20, 200, ], + ]; + + foreach ($data as $item) { + $this->assertSame($item[2], StandardFilters::times($item[0], $item[1])); + } + + $data = [ + [ + 0.0725, + 100, + 7.25, + ], + [ + '-0.0725', + 100, + -7.25, + ], + [ + '-0.0725', + -100, + 7.25, + ], [ 1.5, 2.7, 4.05, ], [ - 7.5, - 0, - 0, + 7.5, + 0, + 0.0, + ], + [ + '', + '', + 0.0, ], ]; @@ -1064,19 +1158,56 @@ public function testDivideBy() 2, ], [ + 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])); + } + + $data = [ + [ + '20.0', 10, + 2.0, + ], + [ + 10.0, 20, 0.5, ], + [ + 10, + 20.0, + 0.5, + ], [ 0, - 200, - 0, + 200.0, + 0.0, ], [ 10, 0.5, - 20, + 20.0, ], ]; @@ -1088,6 +1219,11 @@ public function testDivideBy() public function testModulo() { $data = [ + [ + '7', + '3', + 1, + ], [ '20', 10, @@ -1103,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,