diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml
index dce2d2811..3b66161da 100644
--- a/.github/workflows/liquid.yml
+++ b/.github/workflows/liquid.yml
@@ -14,7 +14,7 @@ jobs:
- { ruby: 3.0, allowed-failure: false } # minimum supported
- { ruby: 3.2, allowed-failure: false }
- { ruby: 3.3, allowed-failure: false }
- - { ruby: "3.4.0-rc1", allowed-failure: false } # latest
+ - { ruby: 3.4, allowed-failure: false } # latest
- { ruby: ruby-head, allowed-failure: false }
name: Test Ruby ${{ matrix.entry.ruby }}
steps:
diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb
index fa506f91c..73dfe346d 100644
--- a/lib/liquid/standardfilters.rb
+++ b/lib/liquid/standardfilters.rb
@@ -64,7 +64,7 @@ def size(input)
# @liquid_syntax string | downcase
# @liquid_return [string]
def downcase(input)
- input.to_s.downcase
+ Utils.to_s(input).downcase
end
# @liquid_public_docs
@@ -75,7 +75,7 @@ def downcase(input)
# @liquid_syntax string | upcase
# @liquid_return [string]
def upcase(input)
- input.to_s.upcase
+ Utils.to_s(input).upcase
end
# @liquid_public_docs
@@ -86,7 +86,7 @@ def upcase(input)
# @liquid_syntax string | capitalize
# @liquid_return [string]
def capitalize(input)
- input.to_s.capitalize
+ Utils.to_s(input).capitalize
end
# @liquid_public_docs
@@ -97,7 +97,7 @@ def capitalize(input)
# @liquid_syntax string | escape
# @liquid_return [string]
def escape(input)
- CGI.escapeHTML(input.to_s) unless input.nil?
+ CGI.escapeHTML(Utils.to_s(input)) unless input.nil?
end
alias_method :h, :escape
@@ -109,7 +109,7 @@ def escape(input)
# @liquid_syntax string | escape_once
# @liquid_return [string]
def escape_once(input)
- input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
+ Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
end
# @liquid_public_docs
@@ -124,7 +124,7 @@ def escape_once(input)
# @liquid_syntax string | url_encode
# @liquid_return [string]
def url_encode(input)
- CGI.escape(input.to_s) unless input.nil?
+ CGI.escape(Utils.to_s(input)) unless input.nil?
end
# @liquid_public_docs
@@ -138,7 +138,7 @@ def url_encode(input)
def url_decode(input)
return if input.nil?
- result = CGI.unescape(input.to_s)
+ result = CGI.unescape(Utils.to_s(input))
raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding?
result
@@ -152,7 +152,7 @@ def url_decode(input)
# @liquid_syntax string | base64_encode
# @liquid_return [string]
def base64_encode(input)
- Base64.strict_encode64(input.to_s)
+ Base64.strict_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -163,7 +163,7 @@ def base64_encode(input)
# @liquid_syntax string | base64_decode
# @liquid_return [string]
def base64_decode(input)
- input = input.to_s
+ input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
@@ -177,7 +177,7 @@ def base64_decode(input)
# @liquid_syntax string | base64_url_safe_encode
# @liquid_return [string]
def base64_url_safe_encode(input)
- Base64.urlsafe_encode64(input.to_s)
+ Base64.urlsafe_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -188,7 +188,7 @@ def base64_url_safe_encode(input)
# @liquid_syntax string | base64_url_safe_decode
# @liquid_return [string]
def base64_url_safe_decode(input)
- input = input.to_s
+ input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
@@ -212,7 +212,7 @@ def slice(input, offset, length = nil)
if input.is_a?(Array)
input.slice(offset, length) || []
else
- input.to_s.slice(offset, length) || ''
+ Utils.to_s(input).slice(offset, length) || ''
end
rescue RangeError
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
@@ -236,10 +236,10 @@ def slice(input, offset, length = nil)
# @liquid_return [string]
def truncate(input, length = 50, truncate_string = "...")
return if input.nil?
- input_str = input.to_s
+ input_str = Utils.to_s(input)
length = Utils.to_integer(length)
- truncate_string_str = truncate_string.to_s
+ truncate_string_str = Utils.to_s(truncate_string)
l = length - truncate_string_str.length
l = 0 if l < 0
@@ -263,7 +263,7 @@ def truncate(input, length = 50, truncate_string = "...")
# @liquid_return [string]
def truncatewords(input, words = 15, truncate_string = "...")
return if input.nil?
- input = input.to_s
+ input = Utils.to_s(input)
words = Utils.to_integer(words)
words = 1 if words <= 0
@@ -277,7 +277,8 @@ def truncatewords(input, words = 15, truncate_string = "...")
return input if wordlist.length <= words
wordlist.pop
- wordlist.join(" ").concat(truncate_string.to_s)
+ truncate_string = Utils.to_s(truncate_string)
+ wordlist.join(" ").concat(truncate_string)
end
# @liquid_public_docs
@@ -288,7 +289,9 @@ def truncatewords(input, words = 15, truncate_string = "...")
# @liquid_syntax string | split: string
# @liquid_return [array[string]]
def split(input, pattern)
- input.to_s.split(pattern.to_s)
+ pattern = Utils.to_s(pattern)
+ input = Utils.to_s(input)
+ input.split(pattern)
end
# @liquid_public_docs
@@ -299,7 +302,8 @@ def split(input, pattern)
# @liquid_syntax string | strip
# @liquid_return [string]
def strip(input)
- input.to_s.strip
+ input = Utils.to_s(input)
+ input.strip
end
# @liquid_public_docs
@@ -310,7 +314,8 @@ def strip(input)
# @liquid_syntax string | lstrip
# @liquid_return [string]
def lstrip(input)
- input.to_s.lstrip
+ input = Utils.to_s(input)
+ input.lstrip
end
# @liquid_public_docs
@@ -321,7 +326,8 @@ def lstrip(input)
# @liquid_syntax string | rstrip
# @liquid_return [string]
def rstrip(input)
- input.to_s.rstrip
+ input = Utils.to_s(input)
+ input.rstrip
end
# @liquid_public_docs
@@ -332,8 +338,9 @@ def rstrip(input)
# @liquid_syntax string | strip_html
# @liquid_return [string]
def strip_html(input)
+ input = Utils.to_s(input)
empty = ''
- result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty)
+ result = input.gsub(STRIP_HTML_BLOCKS, empty)
result.gsub!(STRIP_HTML_TAGS, empty)
result
end
@@ -346,7 +353,8 @@ def strip_html(input)
# @liquid_syntax string | strip_newlines
# @liquid_return [string]
def strip_newlines(input)
- input.to_s.gsub(/\r?\n/, '')
+ input = Utils.to_s(input)
+ input.gsub(/\r?\n/, '')
end
# @liquid_public_docs
@@ -357,6 +365,7 @@ def strip_newlines(input)
# @liquid_syntax array | join
# @liquid_return [string]
def join(input, glue = ' ')
+ glue = Utils.to_s(glue)
InputIterator.new(input, context).join(glue)
end
@@ -573,7 +582,10 @@ def compact(input, property = nil)
# @liquid_syntax string | replace: string, string
# @liquid_return [string]
def replace(input, string, replacement = '')
- input.to_s.gsub(string.to_s, replacement.to_s)
+ string = Utils.to_s(string)
+ replacement = Utils.to_s(replacement)
+ input = Utils.to_s(input)
+ input.gsub(string, replacement)
end
# @liquid_public_docs
@@ -584,7 +596,10 @@ def replace(input, string, replacement = '')
# @liquid_syntax string | replace_first: string, string
# @liquid_return [string]
def replace_first(input, string, replacement = '')
- input.to_s.sub(string.to_s, replacement.to_s)
+ string = Utils.to_s(string)
+ replacement = Utils.to_s(replacement)
+ input = Utils.to_s(input)
+ input.sub(string, replacement)
end
# @liquid_public_docs
@@ -595,9 +610,9 @@ def replace_first(input, string, replacement = '')
# @liquid_syntax string | replace_last: string, string
# @liquid_return [string]
def replace_last(input, string, replacement)
- input = input.to_s
- string = string.to_s
- replacement = replacement.to_s
+ input = Utils.to_s(input)
+ string = Utils.to_s(string)
+ replacement = Utils.to_s(replacement)
start_index = input.rindex(string)
@@ -649,7 +664,9 @@ def remove_last(input, string)
# @liquid_syntax string | append: string
# @liquid_return [string]
def append(input, string)
- input.to_s + string.to_s
+ input = Utils.to_s(input)
+ string = Utils.to_s(string)
+ input + string
end
# @liquid_public_docs
@@ -678,7 +695,9 @@ def concat(input, array)
# @liquid_syntax string | prepend: string
# @liquid_return [string]
def prepend(input, string)
- string.to_s + input.to_s
+ input = Utils.to_s(input)
+ string = Utils.to_s(string)
+ string + input
end
# @liquid_public_docs
@@ -689,7 +708,8 @@ def prepend(input, string)
# @liquid_syntax string | newline_to_br
# @liquid_return [string]
def newline_to_br(input)
- input.to_s.gsub(/\r?\n/, "
\n")
+ input = Utils.to_s(input)
+ input.gsub(/\r?\n/, "
\n")
end
# Reformat a date using Ruby's core Time#strftime( string ) -> string
@@ -724,11 +744,12 @@ def newline_to_br(input)
#
# See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
def date(input, format)
- return input if format.to_s.empty?
+ str_format = Utils.to_s(format)
+ return input if str_format.empty?
return input unless (date = Utils.to_date(input))
- date.strftime(format.to_s)
+ date.strftime(str_format)
end
# @liquid_public_docs
diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb
index 38a406ef2..e7d2a4e32 100644
--- a/lib/liquid/utils.rb
+++ b/lib/liquid/utils.rb
@@ -89,5 +89,91 @@ def self.to_liquid_value(obj)
# Otherwise return the object itself
obj
end
+
+ if RUBY_VERSION >= '3.4'
+ def self.to_s(obj, seen = {})
+ case obj
+ when Hash
+ hash_inspect(obj, seen)
+ when Array
+ array_inspect(obj, seen)
+ else
+ obj.to_s
+ end
+ end
+
+ def self.inspect(obj, seen = {})
+ case obj
+ when Hash
+ hash_inspect(obj, seen)
+ when Array
+ array_inspect(obj, seen)
+ else
+ obj.inspect
+ end
+ end
+ else
+ def self.to_s(obj, seen = nil)
+ obj.to_s
+ end
+
+ def self.inspect(obj, seen = nil)
+ obj.inspect
+ end
+ end
+
+ def self.array_inspect(arr, seen = {})
+ if seen[arr.object_id]
+ return "[...]"
+ end
+
+ seen[arr.object_id] = true
+ str = +"["
+ cursor = 0
+ len = arr.length
+
+ while cursor < len
+ if cursor > 0
+ str << ", "
+ end
+
+ item_str = inspect(arr[cursor], seen)
+ str << item_str
+ cursor += 1
+ end
+
+ str << "]"
+ str
+ ensure
+ seen.delete(arr.object_id)
+ end
+
+ def self.hash_inspect(hash, seen = {})
+ if seen[hash.object_id]
+ return "{...}"
+ end
+ seen[hash.object_id] = true
+
+ str = +"{"
+ first = true
+ hash.each do |key, value|
+ if first
+ first = false
+ else
+ str << ", "
+ end
+
+ key_str = inspect(key, seen)
+ str << key_str
+ str << "=>"
+
+ value_str = inspect(value, seen)
+ str << value_str
+ end
+ str << "}"
+ str
+ ensure
+ seen.delete(hash.object_id)
+ end
end
end
diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb
index 090690559..209570654 100644
--- a/lib/liquid/variable.rb
+++ b/lib/liquid/variable.rb
@@ -107,8 +107,8 @@ def render_obj_to_output(obj, output)
obj.each do |o|
render_obj_to_output(o, output)
end
- when
- output << obj.to_s
+ else
+ output << Liquid::Utils.to_s(obj)
end
end
diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb
index 0cc2adc85..00fa6e312 100644
--- a/lib/liquid/version.rb
+++ b/lib/liquid/version.rb
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
- VERSION = "5.6.4"
+ VERSION = "5.6.5"
end
diff --git a/test/integration/hash_rendering_test.rb b/test/integration/hash_rendering_test.rb
new file mode 100644
index 000000000..c465208fd
--- /dev/null
+++ b/test/integration/hash_rendering_test.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class HashRenderingTest < Minitest::Test
+ def test_render_empty_hash
+ assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} })
+ end
+
+ def test_render_hash_with_string_keys_and_values
+ assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } })
+ end
+
+ def test_render_hash_with_symbol_keys_and_integer_values
+ assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } })
+ end
+
+ def test_render_nested_hash
+ assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } })
+ end
+
+ def test_render_hash_with_array_values
+ assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } })
+ end
+
+ def test_render_recursive_hash
+ recursive_hash = { "self" => {} }
+ recursive_hash["self"]["self"] = recursive_hash
+ assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash })
+ end
+
+ def test_hash_with_downcase_filter
+ assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_upcase_filter
+ assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_strip_filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_escape_filter
+ assert_template_result("{"Key"=>"Value", "AnotherKey"=>"AnotherValue"}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_url_encode_filter
+ assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_strip_html_filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_truncate__20_filter
+ assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_replace___key____replaced_key__filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_append____appended_text__filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_prepend___prepended_text___filter
+ assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_render_hash_with_array_values_empty
+ assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } })
+ end
+
+ def test_render_hash_with_array_values_hash
+ assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } })
+ end
+
+ def test_render_hash_with_hash_key
+ assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } })
+ end
+end