Conversation
|
It seems difficult to replicate Ruby's hash behavior in PHP. I will spend a little more time on it, but I think I will separate the PR for associative array handling. |
|
Thanks for the PR! LMK when you're ready for a review. |
There was a problem hiding this comment.
Pull request overview
This PR fixes compatibility issues with Ruby's Liquid implementation by updating several filters to match their Ruby counterparts. It addresses issues #233 and #234 by modifying the behavior of the capitalize filter, arithmetic filters (plus, minus, times, modulo, divided_by), and the map filter. The changes include adding utility functions isHash and isInteger to the Liquid class to support proper type detection.
Key Changes:
- Modified
capitalizeto only capitalize the first character and lowercase the rest (instead of capitalizing each word) - Updated arithmetic filters to return integers when both operands are integers, and floats otherwise
- Fixed
mapfilter to properly handle single hashes and nested arrays by adding hash detection and selective flattening
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| src/Liquid/StandardFilters.php | Updated capitalize, arithmetic filters (plus, minus, times, modulo, divided_by), and map filter for Ruby Liquid compatibility |
| src/Liquid/Liquid.php | Added utility methods isHash, isInteger, and modified arrayFlatten to support hash-aware flattening |
| tests/Liquid/LiquidTest.php | Added comprehensive tests for new isHash and isInteger utility methods, and arrayFlatten with skipHash parameter |
| tests/Liquid/StandardFiltersTest.php | Updated tests for capitalize behavior and added extensive test coverage for arithmetic filters with both integer and float operands |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| { | ||
| return (float)$input / (float)$operand; | ||
| if (Liquid::isInteger($input) && Liquid::isInteger($operand)) { | ||
| return (int) floor($input / $operand); |
There was a problem hiding this comment.
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.
| return (int) floor($input / $operand); | |
| return intdiv($input, $operand); |
| $trimmed = ltrim($value, '-'); | ||
| return $trimmed !== '' && ctype_digit($trimmed); |
There was a problem hiding this comment.
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.
| $trimmed = ltrim($value, '-'); | |
| return $trimmed !== '' && ctype_digit($trimmed); | |
| return preg_match('/^-?\d+$/', $value) === 1; |
| if (empty($array)) { | ||
| return false; | ||
| } | ||
| return array_keys($array) !== range(0, count($array) - 1); |
There was a problem hiding this comment.
[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.
| 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; |
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| public static function modulo($input, $operand) | ||
| { | ||
| if (Liquid::isInteger($input) && Liquid::isInteger($operand)) { | ||
| return (int)$input % (int)$operand; | ||
| } | ||
| return fmod((float)$input, (float)$operand); | ||
| } |
There was a problem hiding this comment.
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.
|
|
||
| // Flatten nested arrays while preserving hashes | ||
| // [[['attr' => 1]]] => [['attr' => 1]] | ||
| $input = Liquid::arrayFlatten($input, true); |
There was a problem hiding this comment.
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();
}|
Will it help if I'd merge #237? |
|
@sanmai That would be greatly appreciated, thank you! |
|
Done! Feel free to merge with the main branch. |
|
My apologies for the silence. I'll get a PR ready this weekend, organizing what can be merged first. |
vendor/bin/phpunitbuild/coverage/index.htmlvendor/bin/php-cs-fixer fixFix #233 , #234
Changes
capitalizeplus, minus, times, modulo, divided_bymapWhat I'd like reviewed
src/Liquid/Liquid.php::isHash,isIntegerI'm currently adding utility functions directly to Liquid/Liquid.
I did consider making a separate Liquid/Util module, but I generally avoid creating modules with such broad, generic purposes.
Given the small scope right now, I think keeping them in Liquid/Liquid is fine for the time being.
However, if this section grows significantly, it will definitely require more maintenance. So, I'd appreciate your decision on how to proceed.