Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions src/Liquid/Liquid.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Using !== for array comparison may be inefficient for large arrays. While this works correctly, array_keys($array) !== range(0, count($array) - 1) creates and compares two potentially large arrays. For PHP 8.1+, consider using the native !array_is_list($array) function with a version check fallback. Alternatively, iterate through keys and return early on the first non-sequential key for better performance.

Suggested change
return array_keys($array) !== range(0, count($array) - 1);
// Use native array_is_list() if available (PHP 8.1+)
if (function_exists('array_is_list')) {
return !array_is_list($array);
}
// Manual check for sequential integer keys starting from 0
$i = 0;
foreach ($array as $key => $_) {
if ($key !== $i++) {
return true;
}
}
return false;

Copilot uses AI. Check for mistakes.
}

/**
* 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);
Comment on lines +210 to +211
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ltrim($value, '-') approach is unsafe for edge cases. For the string "-", ltrim removes the minus sign leaving an empty string, but the check $trimmed !== '' correctly returns false. However, consider strings like "--5" or "-+5" which would pass the empty check but fail ctype_digit(). While ctype_digit() will catch these, a cleaner approach would be to use a regex like /^-?\d+$/ which is more explicit and handles all cases in one check.

Suggested change
$trimmed = ltrim($value, '-');
return $trimmed !== '' && ctype_digit($trimmed);
return preg_match('/^-?\d+$/', $value) === 1;

Copilot uses AI. Check for mistakes.
}
return false;
}
}
77 changes: 50 additions & 27 deletions src/Liquid/StandardFilters.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,17 @@ public static function append($input, $string)


/**
* Capitalize words in the input sentence
* Capitalize the first character and downcase the rest
*
* @param string $input
*
* @return 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;
}


Expand Down Expand Up @@ -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);
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using floor() for integer division produces incorrect results for negative numbers. For example, floor(-14 / 3) returns -5 instead of the expected -4 (which PHP's integer division operator intdiv() or truncation would return). Consider using intdiv($input, $operand) for PHP 7.0+, or (int)($input / $operand) for consistent truncation behavior that matches most programming languages' integer division.

Suggested change
return (int) floor($input / $operand);
return intdiv($input, $operand);

Copilot uses AI. Check for mistakes.
}
return (float) $input / (float) $operand;
}
Comment on lines 120 to 126
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No division by zero handling in divided_by filter. When $operand is 0 (or evaluates to 0), this will result in a PHP warning/error and potentially INF or NAN. Consider adding a check and returning a sensible default (like 0 or null) or throwing a more descriptive exception, depending on how Ruby's Liquid handles this case.

Copilot uses AI. Check for mistakes.


Expand Down Expand Up @@ -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)
{
Expand All @@ -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);
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map now flattens nested arrays via Liquid::arrayFlatten($input, true) before iterating, which increases the chance that elements evaluated below as callables (is_callable($elem)) will be surfaced and executed. An attacker controlling input can place nested PHP callables (e.g., "phpinfo", [ClassName, "method"], or a closure) so they are flattened and then invoked, enabling arbitrary function execution from templates. Remove callable invocation or strictly whitelist safe callables; e.g., delete the is_callable branch or change it to only allow vetted closures you create, and avoid flattening untrusted arrays before iteration:

// Either remove callable execution entirely
return array_map(function ($elem) use ($property) {
    // no is_callable branch here
    // ... only property extraction logic
}, $input);

// Or, if callable support is strictly needed, restrict:
if ($elem instanceof \Closure && $this->isTrustedClosure($elem)) {
    return $elem();
}

Copilot uses AI. Check for mistakes.

return array_map(function ($elem) use ($property) {
if (is_callable($elem)) {
return $elem();
Expand All @@ -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);
}
Comment on lines 376 to 382
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No modulo by zero handling in modulo filter. When $operand is 0 (or evaluates to 0), this will result in a PHP warning "Division by zero" for the integer path (line 379) or undefined behavior for fmod(). Consider adding a check and handling this edge case appropriately, consistent with Ruby's Liquid behavior.

Copilot uses AI. Check for mistakes.

Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
95 changes: 95 additions & 0 deletions tests/Liquid/LiquidTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([]));
}
}
Loading
Loading