diff --git a/lib/liquid.rb b/lib/liquid.rb index 5eda04e7..d560c792 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -68,6 +68,7 @@ require 'liquid/parser_switching' require 'liquid/tag' require 'liquid/tag/disabler' require 'liquid/tag/disableable' +require 'liquid/parse_context' require 'liquid/block' require 'liquid/block_body' require 'liquid/document' @@ -81,7 +82,6 @@ require 'liquid/standardfilters' require 'liquid/condition' require 'liquid/utils' require 'liquid/tokenizer' -require 'liquid/parse_context' require 'liquid/partial_cache' require 'liquid/usage' require 'liquid/registers' diff --git a/lib/liquid/block.rb b/lib/liquid/block.rb index dc22a477..46139374 100644 --- a/lib/liquid/block.rb +++ b/lib/liquid/block.rb @@ -60,6 +60,21 @@ module Liquid @block_delimiter ||= "end#{block_name}" end + def self.migrate_body(start_tag_name, tokenizer, parse_context) + new_body, unknown_tag = BlockBody.migrate(tokenizer, parse_context) + + raise SyntaxError unless unknown_tag + + block_delimiter = "end#{start_tag_name}" + if unknown_tag.tag_name == block_delimiter + new_body << unknown_tag.replaced_markup("") # markup was ignored on end tags + return [new_body, nil] + end + + # handle the delimiter tag in the caller + [new_body, unknown_tag] + end + private # @api public diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 4674c5f3..988b40d7 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -4,8 +4,8 @@ require 'English' module Liquid class BlockBody - LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o - FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om + LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\s*\z/o + FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)\s*?#{WhitespaceControl}?#{TagEnd}\z/om ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om WhitespaceOrNothing = /\A\s*\z/ TAGSTART = "{%" @@ -30,6 +30,16 @@ module Liquid end end + def self.migrate(tokenizer, parse_context, &block) + parse_context.line_number = tokenizer.line_number + + if tokenizer.for_liquid_tag + migrate_for_liquid_tag(tokenizer, parse_context, &block) + else + migrate_for_document(tokenizer, parse_context, &block) + end + end + def freeze @nodelist.freeze super @@ -60,6 +70,56 @@ module Liquid yield nil, nil end + class UnknownTagMigrator + attr_reader :tag_name, :markup + + def initialize(match:, markup_capture_number:, tag_name:, markup:) + @match = match + @tag_name = tag_name + @markup = markup + @markup_capture_number = markup_capture_number + end + + def replaced_markup(new_markup) + Utils.match_capture_replace(@match, @markup_capture_number, new_markup) + end + end + + private_class_method def self.migrate_for_liquid_tag(tokenizer, parse_context) + result = +"" + while (token = tokenizer.shift) + if token.empty? || token.match?(WhitespaceOrNothing) + result << token + result << "\n" if tokenizer.more? + else + match = token.match(LiquidTagToken) + unless match + # Missing tag name, which was allowed in comment tags through its + # unknown tag handling + raise NotImplementedError, "TODO" + end + tag_name = match[1] + markup = match[2] + unless (tag = Template.tags[tag_name]) + # delegate handling of unknown tags to the caller, where a block tag may treat + # it as an end tag or body delimiter. + unknown_tag = UnknownTagMigrator.new( + match: match, markup_capture_number: 2, tag_name: tag_name, markup: markup + ) + return [result, unknown_tag] + end + has_more_tokens = tokenizer.more? + new_markup, new_tag_body = tag.migrate(tag_name, markup, tokenizer, parse_context) + result << Utils.match_capture_replace(match, 2, new_markup) + result << "\n" if has_more_tokens + result << new_tag_body.to_s + end + parse_context.line_number = tokenizer.line_number + end + + [result, nil] + end + # @api private def self.unknown_tag_in_liquid_tag(tag, parse_context) Block.raise_unknown_tag(tag, 'liquid', '%}', parse_context) @@ -109,6 +169,15 @@ module Liquid end end + private_class_method def self.migrate_liquid_tag(markup, parse_context) + liquid_tag_tokenizer = parse_context.new_tokenizer( + markup, start_line_number: parse_context.line_number, for_liquid_tag: true + ) + result, unknown_tag = migrate_for_liquid_tag(liquid_tag_tokenizer, parse_context) + raise SyntaxError if unknown_tag + result + end + private def handle_invalid_tag_token(token, parse_context) if token.end_with?('%}') yield token, token @@ -166,6 +235,56 @@ module Liquid yield nil, nil end + private_class_method def self.migrate_for_document(tokenizer, parse_context, &block) + result = +"" + while (token = tokenizer.shift) + next if token.empty? + + case + when token.start_with?(TAGSTART) + raise SyntaxError unless token.end_with?('%}') + match = token.match(FullToken) + unless match + # Missing tag name, which was allowed in comment tags through its + # unknown tag handling + raise NotImplementedError, "TODO" + end + tag_name = match[2] + markup = match[4] + + if parse_context.line_number + # newlines inside the tag should increase the line number, + # particularly important for multiline {% liquid %} tags + parse_context.line_number += Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n") + end + + if tag_name == 'liquid' + new_markup = migrate_liquid_tag(markup, parse_context) + result << Utils.match_capture_replace(match, 4, new_markup) + next + end + + unless (tag = Template.tags[tag_name]) + # delegate handling of unknown tags to the caller, where a block tag may treat + # it as an end tag or body delimiter. + unknown_tag = UnknownTagMigrator.new( + match: match, markup_capture_number: 4, tag_name: tag_name, markup: markup + ) + return [result, unknown_tag] + end + new_markup, new_tag_body = tag.migrate(tag_name, markup, tokenizer, parse_context) + result << Utils.match_capture_replace(match, 4, new_markup) << new_tag_body.to_s + when token.start_with?(VARSTART) + result << migrate_variable(token, parse_context) + else + result << token + end + parse_context.line_number = tokenizer.line_number + end + + [result, nil] + end + def whitespace_handler(token, parse_context) if token[2] == WhitespaceControl previous_token = @nodelist.last @@ -246,6 +365,17 @@ module Liquid BlockBody.raise_missing_variable_terminator(token, parse_context) end + private_class_method def self.migrate_variable(token, parse_context) + match = token.match(ContentOfVariable) + if match + new_markup = Utils.migrate_stripped(match[1]) do |markup| + Variable.migrate(markup, parse_context) + end + return Utils.match_capture_replace(match, 1, new_markup) + end + BlockBody.raise_missing_variable_terminator(token, parse_context) + end + # @deprecated Use {.raise_missing_tag_terminator} instead def raise_missing_tag_terminator(token, parse_context) BlockBody.raise_missing_tag_terminator(token, parse_context) diff --git a/lib/liquid/document.rb b/lib/liquid/document.rb index 7742ae5f..9b158834 100644 --- a/lib/liquid/document.rb +++ b/lib/liquid/document.rb @@ -28,6 +28,16 @@ module Liquid raise end + def self.migrate(tokenizer, parse_context) + new_body, unknown_tag = BlockBody.migrate(tokenizer, parse_context) + raise SyntaxError if unknown_tag + + new_body + rescue SyntaxError => e + e.line_number ||= parse_context.line_number + raise + end + def unknown_tag(tag, _markup, _tokenizer) case tag when 'else', 'end' diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index fcdd4d8a..f57656c5 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -41,5 +41,36 @@ module Liquid end end end + + def self.lax_migrate(markup) + Utils.migrate_stripped(markup) do |markup| + raise ArgumentError, "unexpected empty expression" if markup.empty? + + if (markup.start_with?('"') && markup.end_with?('"')) || + (markup.start_with?("'") && markup.end_with?("'")) + markup + else + case markup + when INTEGERS_REGEX + markup + when RANGES_REGEX + match = Regexp.last_match + new_start, new_end = RangeLookup.lax_migrate(match[1], match[2]) + Utils.match_captures_replace(match, 1 => new_start, 2 => new_end) + when FLOATS_REGEX + # lax parser allowed multiple periods, but the second period and following characters were ignored + new_markup = markup.slice(/\A(-?\d+\.\d*)/) + new_markup << "0" if new_markup.end_with?(".") + new_markup + else + if LITERALS.key?(markup) + markup + else + VariableLookup.lax_migrate(markup) + end + end + end + end + end end end diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index 78afd58a..a804d52c 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -2,6 +2,30 @@ module Liquid module ParserSwitching + module ClassMethods + def migrate_with_selected_parser(tag_name, markup, tokenizer, parse_context) + case parse_context.error_mode + when :strict then strict_migrate(tag_name, markup, tokenizer, parse_context) + when :lax then lax_migrate(tag_name, markup, tokenizer, parse_context) + when :warn + begin + parse_context.error_mode = :strict + begin + # Use exception side effect to conditionally branch to lax migration + parse(tag_name, markup, tokenizer, parse_context) + ensure + parse_context.error_mode = :warn + end + + strict_migrate(tag_name, markup, tokenizer, parse_context) + rescue SyntaxError => e + parse_context.warnings << e + lax_migrate(markup) + end + end + end + end + def strict_parse_with_error_mode_fallback(markup) strict_parse_with_error_context(markup) rescue SyntaxError => e diff --git a/lib/liquid/range_lookup.rb b/lib/liquid/range_lookup.rb index fd208a67..b76e2437 100644 --- a/lib/liquid/range_lookup.rb +++ b/lib/liquid/range_lookup.rb @@ -22,6 +22,37 @@ module Liquid end end + def self.lax_migrate(start_markup, end_markup) + new_start = Expression.lax_migrate(start_markup) + new_end = Expression.lax_migrate(end_markup) + + # cast literals + start_obj = Expression.parse(new_start) + end_obj = Expression.parse(new_end) + if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) + new_start = lax_migrate_range_expression(new_start, start_obj) + new_end = lax_migrate_range_expression(new_end, end_obj) + else + new_start = start_obj.to_i.to_s unless start_obj.is_a?(Integer) + new_end = end_obj.to_i.to_s unless end_obj.is_a?(Integer) + end + + [new_start, new_end] + end + + def self.lax_migrate_range_expression(markup, expression) + return markup if expression.respond_to?(:evaluate) + + case expression + when Integer + markup + when NilClass, String + expression.to_i.to_s + else + Utils.to_integer(input).to_s + end + end + attr_reader :start_obj, :end_obj def initialize(start_obj, end_obj) diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 2f7bc301..d2ada481 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -7,6 +7,8 @@ module Liquid include ParserSwitching class << self + include ParserSwitching::ClassMethods + def parse(tag_name, markup, tokenizer, parse_context) tag = new(tag_name, markup, parse_context) tag.parse(tokenizer) diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 7c0abd87..a877dce4 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -21,6 +21,18 @@ module Liquid raise Liquid::SyntaxError, parse_context.locale.t('errors.syntax.assign') end + def self.migrate(tag_name, markup, tokenizer, parse_context) + match = markup.match(/\s*#{Syntax}/) + new_variable_markup = Variable.migrate(match[2], parse_context) + new_markup = Utils.match_captures_replace(match, 2 => new_variable_markup) + + # replace scanned over characters with a space to ensure there is a space + # to separate the tag name and the variable name + new_markup.prepend(" ") if match.begin(0) > 0 + + new_markup + end + attr_reader :to, :from def initialize(tag_name, markup, parse_context) diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index d15173f8..5e176372 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -20,6 +20,39 @@ module Liquid attr_reader :blocks + def self.migrate(tag_name, markup, tokenizer, parse_context) + new_markup = migrate_with_selected_parser(tag_name, markup, tokenizer, parse_context) + new_body = migrate_body(tag_name, tokenizer, parse_context) + [new_markup, new_body] + end + + def self.migrate_body(start_tag_name, tokenizer, parse_context) + result = +"" + + loop do + new_body, delimiter_tag = super(start_tag_name, tokenizer, parse_context) + result << new_body + + break unless delimiter_tag + + case delimiter_tag.tag_name + when "else" + result << delimiter_tag.replaced_markup("") # markup was ignored on end tags + when "elsif" + new_markup = migrate_with_selected_parser(delimiter_tag.tag_name, delimiter_tag.markup, tokenizer, parse_context) + result << delimiter_tag.replaced_markup(new_markup) + else + raise SyntaxError + end + end + + result + end + + private_class_method def self.strict_migrate(tag_name, markup, tokenizer, parse_context) + markup + end + def initialize(tag_name, markup, options) super @blocks = [] @@ -81,6 +114,10 @@ module Liquid Condition.parse_expression(parse_context, markup) end + private_class_method def self.lax_migrate_expression(markup) + Expression.lax_migrate(markup) + end + def lax_parse(markup) expressions = markup.scan(ExpressionsAndOperators) raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop =~ Syntax @@ -101,6 +138,40 @@ module Liquid condition end + private_class_method def self.lax_migrate(tag_name, markup, tokenizer, parse_context) + expressions = markup.scan(ExpressionsAndOperators) + + new_markup = lax_migrate_condition(expressions.pop) + until expressions.empty? + operator = expressions.pop + new_left_markup = lax_migrate_condition(expressions.pop) + new_markup = new_left_markup << operator << new_markup + end + + new_markup + end + + private_class_method def self.lax_migrate_condition(markup) + Utils.migrate_stripped(markup) do |markup| + match = markup.match(Syntax) + left = lax_migrate_expression(match[1]) + op = match[2] + right_capture = match[3] + if op + right = lax_migrate_expression(right_capture) if right_capture + elsif right_capture + right = "" # remove right capture, since it is ignored with no operator + end + new_markup = Utils.match_captures_replace(match, { 1 => left, 2 => op, 3 => right }.compact) + new_markup.prepend(' ') if match.begin(0) > 0 + new_markup << ' ' if match.end(0) < markup.length + if op && !right # missing right operand missing + new_markup << " nil" # replace with nil, which it was semantically treated as + end + new_markup + end + end + def strict_parse(markup) p = Parser.new(markup) condition = parse_binary_comparisons(p) diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 39bde833..c18fff24 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -96,6 +96,11 @@ module Liquid def parse(source, options = {}) new.parse(source, options) end + + def migrate(source, parse_options = {}) + parse(source, parse_options) # raise if source has syntax errors + new.migrate(source, parse_options) + end end def initialize @@ -112,6 +117,12 @@ module Liquid self end + def migrate(source, parse_options = {}) + parse_context = configure_options(parse_options) + tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1) + Document.migrate(tokenizer, parse_context) + end + def registers @registers ||= {} end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 740f8599..f03aa08c 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -25,6 +25,10 @@ module Liquid token end + def more? + @offset < @tokens.length + end + private def tokenize diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index 38a406ef..964ed2fb 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -89,5 +89,33 @@ module Liquid # Otherwise return the object itself obj end + + def self.migrate_stripped(markup) + match = markup.match(/\A\s*(.*?)\s*\z/m) + new_markup = yield match[1] + Utils.match_captures_replace(match, 1 => new_markup) + end + + # @api private + def self.match_capture_replace(match, capture_number, replacement_string) + match_captures_replace(match, { capture_number => replacement_string }) + end + + def self.match_captures_replace(match, replacements = {}) + new_string = match[0].dup + capture_numbers = replacements.keys + unless capture_numbers.all?(Integer) + raise TypeError, "Currently, only numbered captures are supported" + end + # replace from later captures first, to avoid affecting the position for following replacements + match_begin = match.begin(0) + capture_numbers.sort.reverse_each do |capture_number| + replacement_string = replacements.fetch(capture_number) + capture_start = match.begin(capture_number) + capture_length = match.end(capture_number) - capture_start + new_string[capture_start - match_begin, capture_length] = replacement_string + end + new_string + end end end diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 1a4c0719..a1b06ea9 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -41,6 +41,77 @@ module Liquid "in \"{{#{markup}}}\"" end + STRICT_PARSE_CONTEXT = ParseContext.new(error_mode: :strict).freeze + private_constant :STRICT_PARSE_CONTEXT + + def self.migrate(markup, parse_context) + new(markup, STRICT_PARSE_CONTEXT) + # TODO: migrate non-integer range expression literals + markup + rescue Liquid::SyntaxError + raise if parse_context.error_mode == :strict + lax_migrate(markup, parse_context) + end + + def self.lax_migrate(markup, parse_context) + # unanchored match that may skip over characters preceding the name expression + markup_match = markup.match(MarkupWithQuotedFragment) + unless markup_match + # Treated as a blank variable (e.g. `{{ -}}`), which outputs nothing + # but may still have an effect on whitespace trimming + return "" + end + + name_markup = markup_match[1] + filters_markup = markup_match[2] + + new_name_markup = Expression.lax_migrate(name_markup) + + new_filter_markup = "" + # unanchored match that may skip over characters preceding the pipe for the first filter + if (filters_match = filters_markup.match(/\s*#{FilterMarkupRegex}/o)) + filters = filters_match[1].scan(FilterParser) # may skip over unterminated quote characters + filters.map! do |f| + filter_match = f.match(/\A(\s*)\W*(\w+)(\s*)/) + next unless filter_match + + # omit non-word characters preceding the filter name that the lax parser skips over + transformed_filter = +"#{filter_match[1]}#{filter_match[2]}#{filter_match[3]}" + + filter_args = [] + f.scan(/#{FilterArgsRegex}\s*/o) do # may skip over characters before the argument separator + filter_arg_match = Regexp.last_match + new_filter_arg = lax_migrate_filter_argument(filter_arg_match[1]) + filter_arg_string = Utils.match_captures_replace(filter_arg_match, 1 => new_filter_arg) + filter_arg_string = filter_arg_string[1...] # remove separator character + filter_args << filter_arg_string + end + + unless filter_args.empty? + transformed_filter << ":" << filter_args.join(",") + end + + transformed_filter + end + filters.compact! + new_filters_markup = filters.join('|') + + # include pipe separator along with whitespace surrounding it + new_filter_markup = Utils.match_captures_replace(filters_match, 1 => new_filters_markup) + end + + Utils.match_captures_replace(markup_match, 1 => new_name_markup, 2 => new_filter_markup) + end + + def self.lax_migrate_filter_argument(unparsed_arg) + if (match = unparsed_arg.match(JustTagAttributes)) + new_value_markup = Expression.lax_migrate(match[2]) + Utils.match_captures_replace(match, 2 => new_value_markup) + else + Expression.lax_migrate(unparsed_arg) + end + end + def lax_parse(markup) @filters = [] return unless markup =~ MarkupWithQuotedFragment diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb index 1ade30ef..b2fec489 100644 --- a/lib/liquid/variable_lookup.rb +++ b/lib/liquid/variable_lookup.rb @@ -10,6 +10,41 @@ module Liquid new(markup) end + LITERALS = Expression::LITERALS.keys.freeze + private_constant :LITERALS + + def self.lax_migrate(markup) + new_markup = nil + last_match = nil + first_match = nil + markup.scan(VariableParser) do |lookup| + last_match = Regexp.last_match + first_match ||= last_match + if lookup&.start_with?('[') && lookup&.end_with?(']') + new_markup ||= +"" + new_markup << "[" << Expression.lax_migrate(lookup[1..-2]) << "]" + else + new_markup << "." if new_markup + new_markup ||= +"" + new_markup << lookup + end + end + + case new_markup + when nil + # `markup.scan(VariableParser)` may skip over all characters + new_markup ||= " nil " + when Expression::INTEGERS_REGEX, Expression::RANGES_REGEX, Expression::FLOATS_REGEX, *LITERALS + # Quote variable lookups that match literals after characters are skipped by regex scanning + new_markup = "['#{new_markup}']" + else + new_markup.prepend(" ") if first_match.begin(0) > 0 + new_markup << ' ' if last_match.end(0) < markup.length + end + + new_markup + end + def initialize(markup) lookups = markup.scan(VariableParser) diff --git a/test/unit/migrate_unit_test.rb b/test/unit/migrate_unit_test.rb new file mode 100644 index 00000000..e9292c02 --- /dev/null +++ b/test/unit/migrate_unit_test.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'test_helper' + +class MigrateUnitTest < Minitest::Test + def test_migrate_preserves_valid_markup + [ + "{{a}}", + " {{- \ta\n -}} ", + "{{a.b['c'].d[5]|default:6,allow_false:true|truncate:7,'..'}}", + "{{ a . b [ 'c' ] . d [ 5 ] | default : 6 , allow_false : true | truncate : 4 , '..' }}", + "{%assign x=a.b['c'].d[5]|default:6,allow_false:true|truncate:7,'..'%}", + "{% assign x =\na . b [ 'c' ] . d [ 5 ] | default : 6 , allow_false : true | truncate : 4 , '..' %}", + "{% if a and b > c %}A{% elsif d or f contains g %}B{% else %}C{% endif %}", + <<~LIQUID, + {% liquid + if x > 0 + assign x = x | plus: 1 + + endif + %} + LIQUID + ].each do |source| + assert_no_migration(source) + end + end + + def test_migrate_variable + with_error_mode(:lax) do + assert_migration({ + %({{ ,|"' }}) => "{{ }}", # no MarkupWithQuotedFragment match, skipping characters + %({{ ,|"' 123 }}) => "{{ 123 }}", # MarkupWithQuotedFragment skipped characters + "{{ 12 34 }}" => "{{ 12 }}", # no FilterMarkupRegex match, skipping characters + "{{ -12 34 | abs }}" => "{{ -12 | abs }}", # FilterMarkupRegex skipped characters + %({{ -12 | '" abs }}) => "{{ -12 | abs }}", # FilterParser skipped characters + "{{ -1 | abs ' plus: 1 }}" => "{{ -1 | abs | plus: 1 }}", # FilterParser unexpected separator + "{{ -1 | ! abs }}" => "{{ -1 | abs }}", # ignored non-word characters preceding filter name + "{{ 'a' | append WAT: 'b' }}" => "{{ 'a' | append : 'b' }}", # FilterArgsRegex skipped characters + "{{ '!' | replace, '!': '?' }}" => "{{ '!' | replace: '!', '?' }}", # FilterArgsRegex unexpected separators + "{{!-a-!}}" => "{{ -a- }}", # preserve separators when removing ignored characters + }) + end + end + + def test_migrate_expression + with_error_mode(:lax) do + assert_migration({ + "{{ (1.9...2.8) }}" => "{{ (1..2) }}", # apply constant range coercion + "{{ 1.2.3.4 }}" => "{{ 1.2 }}", # multiple periods allowed by FLOATS_REGEX, truncated by to_f + "{{ 1. }}" => "{{ 1.0 }}", # FLOATS_REGEX didn't require digits after the period + "{{ .empty }}" => "{{ ['empty'] }}", # skipped character prevents exact literal lookup + }) + end + end + + def test_migrate_variable_lookup + with_error_mode(:lax) do + assert_migration({ + "{{@-a[b].c-@}}" => "{{ -a[b].c- }}", # VariableParser skipped characters + "{{ a!b$c }}" => "{{ a.b.c }}", # VariableParser unexpected separators + }) + end + end + + def test_migrate_assign + with_error_mode(:lax) do + assert_migration({ + "{% assign!a = b-!%}" => "{% assign a = b- %}", # Syntax skipped characters + "{% assign a = @b ! %}" => "{% assign a = b %}", # Variable skipped characters + "{% assign|x=1 %}" => "{% assign x=1 %}", # ensure tag name separated from markup + }) + end + end + + def test_lax_migrate_if + with_error_mode(:lax) do + assert_migration({ + "{% if@a-@%}Y{% endif %}" => "{% if a- %}Y{% endif %}", # Syntax skipped character + "{% if &a contains^b and *c %}A{% elsif %d or$e %}B{% endif %}" => + "{% if a contains b and c %}A{% elsif d or e %}B{% endif %}", # test more expressions + "{% if b 1 %}Y{% endif %}" => "{% if b %}Y{% endif %}", # missing operator with right operand + "{% if c == %}Y{% endif %}" => "{% if c == nil %}Y{% endif %}", # operator with missing right operand + "{% if!a-!%}T{% endif %}" => "{% if a- %}T{% endif %}", # VariableParser skipped characters + "{% if!%}T{% endif %}" => "{% if nil %}T{% endif %}", # VariableParser skipping all characters + }) + end + end + + def test_migrate_liquid_tag + with_error_mode(:lax) do + source = <<~LIQUID + {% liquid + assign ! a = 1 + assign a = @b ! + %} + LIQUID + expect = <<~LIQUID + {% liquid + assign a = 1 + assign a = b + %} + LIQUID + assert_migration({ source => expect }) + end + end + + private + + def assert_no_migration(source) + assert_equal(source, Liquid::Template.migrate(source)) + end + + def assert_migration(source_to_expected_output_hash) + source_to_expected_output_hash.each do |source, expect| + message = "source: #{source.inspect}" + assert_equal(expect, Liquid::Template.migrate(source), message) + assert_no_migration(expect) + assert_equal(Liquid::Template.parse(expect).render!, Liquid::Template.parse(source).render!, message) + end + end +end