Address liquid-spec issues without ActiveSupport loaded

Implement ActiveSupport-compatible behaviors internally so Liquid works
correctly without ActiveSupport being loaded:

1. String first/last via property access (name.first, name.last)
   - VariableLookup now handles string[0] and string[-1] for first/last

2. String first/last via filters (name | first, name | last)
   - StandardFilters#first and #last now handle strings

3. blank?/empty? comparisons for types without these methods
   - Condition now implements liquid_blank? and liquid_empty? internally
   - blank? matches ActiveSupport: nil, false, empty/whitespace strings,
     empty arrays/hashes are all blank
   - empty? checks length == 0 only (whitespace is NOT empty)

This fixes spec failures for templates like:
- {{ name.first }} / {{ name | first }} on strings
- {% if x == blank %} for whitespace strings, empty hashes/arrays
- {% case ' ' %}{% when blank %} matching whitespace
This commit is contained in:
Tobi Lutke
2026-01-01 20:14:21 -05:00
parent a60a6c0d93
commit 33bac87a5c
5 changed files with 246 additions and 10 deletions
+52 -10
View File
@@ -113,24 +113,66 @@ module Liquid
def equal_variables(left, right)
if left.is_a?(MethodLiteral)
if right.respond_to?(left.method_name)
return right.send(left.method_name)
else
return nil
end
return call_method_literal(left, right)
end
if right.is_a?(MethodLiteral)
if left.respond_to?(right.method_name)
return left.send(right.method_name)
else
return nil
end
return call_method_literal(right, left)
end
left == right
end
def call_method_literal(literal, value)
method_name = literal.method_name
# If the object responds to the method, use it
if value.respond_to?(method_name)
return value.send(method_name)
end
# Implement blank?/empty? for common types that don't have it
# (ActiveSupport adds these, but Liquid should work without it)
case method_name
when :blank?
liquid_blank?(value)
when :empty?
liquid_empty?(value)
else
nil
end
end
# Implement blank? semantics matching ActiveSupport
def liquid_blank?(value)
case value
when NilClass, FalseClass
true
when TrueClass, Numeric
false
when String
# Blank if empty or whitespace only
value.empty? || value.match?(/\A\s*\z/)
when Array, Hash
value.empty?
else
# Fall back to empty? if available, otherwise false
value.respond_to?(:empty?) ? value.empty? : false
end
end
# Implement empty? semantics
def liquid_empty?(value)
case value
when NilClass
true
when String, Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : false
end
end
def interpret_condition(left, right, op, context)
# If the operator is empty this means that the decision statement is just
# a single variable. We can just poll this variable from the context and
+2
View File
@@ -768,6 +768,7 @@ module Liquid
# @liquid_syntax array | first
# @liquid_return [untyped]
def first(array)
return array[0] if array.is_a?(String)
array.first if array.respond_to?(:first)
end
@@ -779,6 +780,7 @@ module Liquid
# @liquid_syntax array | last
# @liquid_return [untyped]
def last(array)
return array[-1] if array.is_a?(String)
array.last if array.respond_to?(:last)
end
+4
View File
@@ -70,6 +70,10 @@ module Liquid
elsif lookup_command?(i) && object.respond_to?(key)
object = object.send(key).to_liquid
# Handle string first/last like ActiveSupport does (returns first/last character)
elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last")
object = key == "first" ? object[0] : object[-1]
# No key was present with the desired value and it wasn't one of the directly supported
# keywords either. The only thing we got left is to return nil or
# raise an exception if `strict_variables` option is set to true