diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index dce2d281..3b66161d 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 0bd4557b..73dfe346 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -40,27 +40,6 @@ module Liquid end input end - - def stringify_object(object) - output = String.new - self.stringify_inner(object, output) - output - end - - def stringify_inner(obj, output) - case obj - when NilClass - # Do nothing - when Array - obj.each do |o| - self.stringify_object(o, output) - end - when Hash - output << obj.to_s_legacy - when - output << obj.to_s - end - end end # @liquid_public_docs @@ -85,7 +64,7 @@ module Liquid # @liquid_syntax string | downcase # @liquid_return [string] def downcase(input) - StandardFilters.stringify_object(input).downcase + Utils.to_s(input).downcase end # @liquid_public_docs @@ -96,7 +75,7 @@ module Liquid # @liquid_syntax string | upcase # @liquid_return [string] def upcase(input) - StandardFilters.stringify_object(input).upcase + Utils.to_s(input).upcase end # @liquid_public_docs @@ -107,7 +86,7 @@ module Liquid # @liquid_syntax string | capitalize # @liquid_return [string] def capitalize(input) - StandardFilters.stringify_object(input).capitalize + Utils.to_s(input).capitalize end # @liquid_public_docs @@ -118,7 +97,7 @@ module Liquid # @liquid_syntax string | escape # @liquid_return [string] def escape(input) - CGI.escapeHTML(StandardFilters.stringify_object(input)) unless input.nil? + CGI.escapeHTML(Utils.to_s(input)) unless input.nil? end alias_method :h, :escape @@ -130,7 +109,7 @@ module Liquid # @liquid_syntax string | escape_once # @liquid_return [string] def escape_once(input) - StandardFilters.stringify_object(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) + Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) end # @liquid_public_docs @@ -145,7 +124,7 @@ module Liquid # @liquid_syntax string | url_encode # @liquid_return [string] def url_encode(input) - CGI.escape(StandardFilters.stringify_object(input)) unless input.nil? + CGI.escape(Utils.to_s(input)) unless input.nil? end # @liquid_public_docs @@ -159,7 +138,7 @@ module Liquid def url_decode(input) return if input.nil? - result = CGI.unescape(StandardFilters.stringify_object(input)) + result = CGI.unescape(Utils.to_s(input)) raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding? result @@ -173,7 +152,7 @@ module Liquid # @liquid_syntax string | base64_encode # @liquid_return [string] def base64_encode(input) - Base64.strict_encode64(StandardFilters.stringify_object(input)) + Base64.strict_encode64(Utils.to_s(input)) end # @liquid_public_docs @@ -184,7 +163,7 @@ module Liquid # @liquid_syntax string | base64_decode # @liquid_return [string] def base64_decode(input) - input = StandardFilters.stringify_object(input) + 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" @@ -198,7 +177,7 @@ module Liquid # @liquid_syntax string | base64_url_safe_encode # @liquid_return [string] def base64_url_safe_encode(input) - Base64.urlsafe_encode64(StandardFilters.stringify_object(input)) + Base64.urlsafe_encode64(Utils.to_s(input)) end # @liquid_public_docs @@ -209,7 +188,7 @@ module Liquid # @liquid_syntax string | base64_url_safe_decode # @liquid_return [string] def base64_url_safe_decode(input) - input = StandardFilters.stringify_object(input) + 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" @@ -233,7 +212,7 @@ module Liquid if input.is_a?(Array) input.slice(offset, length) || [] else - StandardFilters.stringify_object(input).slice(offset, length) || '' + Utils.to_s(input).slice(offset, length) || '' end rescue RangeError if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset) @@ -257,10 +236,10 @@ module Liquid # @liquid_return [string] def truncate(input, length = 50, truncate_string = "...") return if input.nil? - input_str = StandardFilters.stringify_object(input) + 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 @@ -284,7 +263,7 @@ module Liquid # @liquid_return [string] def truncatewords(input, words = 15, truncate_string = "...") return if input.nil? - input = StandardFilters.stringify_object(input) + input = Utils.to_s(input) words = Utils.to_integer(words) words = 1 if words <= 0 @@ -298,7 +277,8 @@ module Liquid 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 @@ -309,7 +289,9 @@ module Liquid # @liquid_syntax string | split: string # @liquid_return [array[string]] def split(input, pattern) - StandardFilters.stringify_object(input).split(pattern.to_s) + pattern = Utils.to_s(pattern) + input = Utils.to_s(input) + input.split(pattern) end # @liquid_public_docs @@ -320,7 +302,8 @@ module Liquid # @liquid_syntax string | strip # @liquid_return [string] def strip(input) - StandardFilters.stringify_object(input).strip + input = Utils.to_s(input) + input.strip end # @liquid_public_docs @@ -331,7 +314,8 @@ module Liquid # @liquid_syntax string | lstrip # @liquid_return [string] def lstrip(input) - StandardFilters.stringify_object(input).lstrip + input = Utils.to_s(input) + input.lstrip end # @liquid_public_docs @@ -342,7 +326,8 @@ module Liquid # @liquid_syntax string | rstrip # @liquid_return [string] def rstrip(input) - StandardFilters.stringify_object(input).rstrip + input = Utils.to_s(input) + input.rstrip end # @liquid_public_docs @@ -353,8 +338,9 @@ module Liquid # @liquid_syntax string | strip_html # @liquid_return [string] def strip_html(input) + input = Utils.to_s(input) empty = '' - result = StandardFilters.stringify_object(input).gsub(STRIP_HTML_BLOCKS, empty) + result = input.gsub(STRIP_HTML_BLOCKS, empty) result.gsub!(STRIP_HTML_TAGS, empty) result end @@ -367,7 +353,8 @@ module Liquid # @liquid_syntax string | strip_newlines # @liquid_return [string] def strip_newlines(input) - StandardFilters.stringify_object(input).gsub(/\r?\n/, '') + input = Utils.to_s(input) + input.gsub(/\r?\n/, '') end # @liquid_public_docs @@ -378,6 +365,7 @@ module Liquid # @liquid_syntax array | join # @liquid_return [string] def join(input, glue = ' ') + glue = Utils.to_s(glue) InputIterator.new(input, context).join(glue) end @@ -594,7 +582,10 @@ module Liquid # @liquid_syntax string | replace: string, string # @liquid_return [string] def replace(input, string, replacement = '') - StandardFilters.stringify_object(input).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 @@ -605,7 +596,10 @@ module Liquid # @liquid_syntax string | replace_first: string, string # @liquid_return [string] def replace_first(input, string, replacement = '') - StandardFilters.stringify_object(input).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 @@ -616,9 +610,9 @@ module Liquid # @liquid_syntax string | replace_last: string, string # @liquid_return [string] def replace_last(input, string, replacement) - input = StandardFilters.stringify_object(input) - 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) @@ -670,7 +664,9 @@ module Liquid # @liquid_syntax string | append: string # @liquid_return [string] def append(input, string) - StandardFilters.stringify_object(input) + string.to_s + input = Utils.to_s(input) + string = Utils.to_s(string) + input + string end # @liquid_public_docs @@ -699,7 +695,9 @@ module Liquid # @liquid_syntax string | prepend: string # @liquid_return [string] def prepend(input, string) - string.to_s + StandardFilters.stringify_object(input) + input = Utils.to_s(input) + string = Utils.to_s(string) + string + input end # @liquid_public_docs @@ -710,7 +708,8 @@ module Liquid # @liquid_syntax string | newline_to_br # @liquid_return [string] def newline_to_br(input) - StandardFilters.stringify_object(input).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 @@ -745,11 +744,12 @@ module Liquid # # 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 23441b1a..8acfbbf5 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -89,11 +89,90 @@ module Liquid # Otherwise return the object itself obj end - end -end -class Hash - def to_s_legacy - return "LEGACY RUBY" + 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 + 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 efa8ceb4..20957065 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -87,7 +87,6 @@ module Liquid @filters.each do |filter_name, filter_args, filter_kwargs| filter_args = evaluate_filter_expressions(context, filter_args, filter_kwargs) - binding.irb obj = context.invoke(filter_name, obj, *filter_args) end @@ -108,10 +107,8 @@ module Liquid obj.each do |o| render_obj_to_output(o, output) end - when Hash - output << obj.to_s_legacy - when - output << obj.to_s + else + output << Liquid::Utils.to_s(obj) end end diff --git a/test/integration/hash_rendering_test.rb b/test/integration/hash_rendering_test.rb new file mode 100644 index 00000000..c465208f --- /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