diff --git a/Gemfile b/Gemfile index 37ffe1d9..4fa0c03e 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,6 @@ group :test do gem 'rubocop', '~> 0.53.0' platform :mri do - gem 'liquid-c', github: 'Shopify/liquid-c', ref: '9168659de45d6d576fce30c735f857e597fa26f6' + gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'liquid-tag' end end diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index c2478ce1..27b4eef0 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -1,6 +1,7 @@ module Liquid class BlockBody - FullToken = /\A#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*?)#{WhitespaceControl}?#{TagEnd}\z/om + LiquidTagToken = /\A\s*(\w+)\s*(.*?)\z/o + FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(\w+)(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om WhitespaceOrNothing = /\A\s*\z/ TAGSTART = "{%".freeze @@ -13,8 +14,42 @@ module Liquid @blank = true end - def parse(tokenizer, parse_context) + def parse(tokenizer, parse_context, &block) parse_context.line_number = tokenizer.line_number + + if tokenizer.for_liquid_tag + parse_for_liquid_tag(tokenizer, parse_context, &block) + else + parse_for_document(tokenizer, parse_context, &block) + end + end + + private def parse_for_liquid_tag(tokenizer, parse_context) + while token = tokenizer.shift + unless token.empty? || token =~ WhitespaceOrNothing + unless token =~ LiquidTagToken + # line isn't empty but didn't match tag syntax, yield and let the + # caller raise a syntax error + return yield token, token + end + tag_name = $1 + markup = $2 + unless tag = registered_tags[tag_name] + # end parsing if we reach an unknown tag and let the caller decide + # determine how to proceed + return yield tag_name, markup + end + new_tag = tag.parse(tag_name, markup, tokenizer, parse_context) + @blank &&= new_tag.blank? + @nodelist << new_tag + end + parse_context.line_number = tokenizer.line_number + end + + yield nil, nil + end + + private def parse_for_document(tokenizer, parse_context, &block) while token = tokenizer.shift next if token.empty? case @@ -23,9 +58,20 @@ module Liquid unless token =~ FullToken raise_missing_tag_terminator(token, parse_context) end - tag_name = $1 - markup = $2 - # fetch the tag from registered blocks + tag_name = $2 + markup = $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 += $1.count("\n".freeze) + $3.count("\n".freeze) + end + + if tag_name == 'liquid'.freeze + liquid_tag_tokenizer = Tokenizer.new(markup, line_number: parse_context.line_number, for_liquid_tag: true) + next parse_for_liquid_tag(liquid_tag_tokenizer, parse_context, &block) + end + unless tag = registered_tags[tag_name] # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 5099ccb2..13b7e4bb 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -5,8 +5,8 @@ module Liquid include ParserSwitching class << self - def parse(tag_name, markup, tokenizer, options) - tag = new(tag_name, markup, options) + def parse(tag_name, markup, tokenizer, parse_context) + tag = new(tag_name, markup, parse_context) tag.parse(tokenizer) tag end diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 767d874c..50a95536 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -10,6 +10,10 @@ module Liquid class Assign < Tag Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om + def self.syntax_error_translation_key + "errors.syntax.assign".freeze + end + attr_reader :to, :from def initialize(tag_name, markup, options) @@ -18,7 +22,7 @@ module Liquid @to = $1 @from = Variable.new($2, options) else - raise SyntaxError.new options[:locale].t("errors.syntax.assign".freeze) + raise SyntaxError.new(options[:locale].t(self.class.syntax_error_translation_key)) end end diff --git a/lib/liquid/tags/echo.rb b/lib/liquid/tags/echo.rb new file mode 100644 index 00000000..acb9ab45 --- /dev/null +++ b/lib/liquid/tags/echo.rb @@ -0,0 +1,24 @@ +module Liquid + # Echo outputs an expression + # + # {% echo monkey %} + # {% echo user.name %} + # + # This is identical to variable output syntax, like {{ foo }}, but works + # inside {% liquid %} tags. The full syntax is supported, including filters: + # + # {% echo user | link %} + # + class Echo < Tag + def initialize(tag_name, markup, parse_context) + super + @variable = Variable.new(markup, parse_context) + end + + def render(context) + @variable.render(context) + end + end + + Template.register_tag('echo'.freeze, Echo) +end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index d03657e8..d3fd6766 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -1,25 +1,31 @@ module Liquid class Tokenizer - attr_reader :line_number + attr_reader :line_number, :for_liquid_tag - def initialize(source, line_numbers = false) + def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) @source = source - @line_number = line_numbers ? 1 : nil + @line_number = line_number || (line_numbers ? 1 : nil) + @for_liquid_tag = for_liquid_tag @tokens = tokenize end def shift - token = @tokens.shift - @line_number += token.count("\n") if @line_number && token + token = @tokens.shift or return + + if @line_number + @line_number += @for_liquid_tag ? 1 : token.count("\n") + end + token end private def tokenize - @source = @source.source if @source.respond_to?(:source) return [] if @source.to_s.empty? + return @source.split("\n") if @for_liquid_tag + tokens = @source.split(TemplateParser) # removes the rogue empty element at the beginning of the array diff --git a/test/integration/tags/echo_test.rb b/test/integration/tags/echo_test.rb new file mode 100644 index 00000000..ed5b821d --- /dev/null +++ b/test/integration/tags/echo_test.rb @@ -0,0 +1,11 @@ +require 'test_helper' + +class EchoTest < Minitest::Test + include Liquid + + def test_echo_outputs_its_input + assert_template_result('BAR', <<~LIQUID, { 'variable-name' => 'bar' }) + {%- echo variable-name | upcase -%} + LIQUID + end +end diff --git a/test/integration/tags/liquid_tag_test.rb b/test/integration/tags/liquid_tag_test.rb new file mode 100644 index 00000000..d4be1282 --- /dev/null +++ b/test/integration/tags/liquid_tag_test.rb @@ -0,0 +1,104 @@ +require 'test_helper' + +class LiquidTagTest < Minitest::Test + include Liquid + + def test_liquid_tag + assert_template_result('1 2 3', <<~LIQUID, 'array' => [1, 2, 3]) + {%- liquid + echo array | join: " " + -%} + LIQUID + + assert_template_result('1 2 3', <<~LIQUID, 'array' => [1, 2, 3]) + {%- liquid + for value in array + echo value + unless forloop.last + echo " " + endunless + endfor + -%} + LIQUID + + assert_template_result('4 8 12 6', <<~LIQUID, 'array' => [1, 2, 3]) + {%- liquid + for value in array + assign double_value = value | times: 2 + echo double_value | times: 2 + unless forloop.last + echo " " + endunless + endfor + + echo " " + echo double_value + -%} + LIQUID + + assert_template_result('abc', <<~LIQUID) + {%- liquid echo "a" -%} + b + {%- liquid echo "c" -%} + LIQUID + end + + def test_liquid_tag_errors + assert_match_syntax_error("syntax error (line 1): Unknown tag 'error'", <<~LIQUID) + {%- liquid error no such tag -%} + LIQUID + + assert_match_syntax_error("syntax error (line 7): Unknown tag 'error'", <<~LIQUID) + {{ test }} + + {%- + liquid + for value in array + + error no such tag + endfor + -%} + LIQUID + + assert_match_syntax_error("syntax error (line 2): Unknown tag '!!! the guards are vigilant'", <<~LIQUID) + {%- liquid + !!! the guards are vigilant + -%} + LIQUID + + assert_match_syntax_error("syntax error (line 4): 'for' tag was never closed", <<~LIQUID) + {%- liquid + for value in array + echo 'forgot to close the for tag' + -%} + LIQUID + end + + def test_line_number_is_correct_after_a_blank_token + assert_match_syntax_error("syntax error (line 3): Unknown tag 'error'", "{% liquid echo ''\n\n error %}") + assert_match_syntax_error("syntax error (line 3): Unknown tag 'error'", "{% liquid echo ''\n \n error %}") + end + + def test_cannot_open_blocks_living_past_a_liquid_tag + assert_match_syntax_error("syntax error (line 3): 'if' tag was never closed", <<~LIQUID) + {%- liquid + if true + -%} + {%- endif -%} + LIQUID + end + + def test_quirk_can_close_blocks_created_before_a_liquid_tag + assert_template_result("42", <<~LIQUID) + {%- if true -%} + 42 + {%- liquid endif -%} + LIQUID + end + + def test_liquid_tag_in_raw + assert_template_result("{% liquid echo 'test' %}\n", <<~LIQUID) + {% raw %}{% liquid echo 'test' %}{% endraw %} + LIQUID + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 34e7553c..1ce82b3c 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -37,18 +37,18 @@ module Minitest include Liquid def assert_template_result(expected, template, assigns = {}, message = nil) - assert_equal expected, Template.parse(template).render!(assigns), message + assert_equal expected, Template.parse(template, line_numbers: true).render!(assigns), message end def assert_template_result_matches(expected, template, assigns = {}, message = nil) return assert_template_result(expected, template, assigns, message) unless expected.is_a? Regexp - assert_match expected, Template.parse(template).render!(assigns), message + assert_match expected, Template.parse(template, line_numbers: true).render!(assigns), message end def assert_match_syntax_error(match, template, assigns = {}) exception = assert_raises(Liquid::SyntaxError) do - Template.parse(template).render(assigns) + Template.parse(template, line_numbers: true).render(assigns) end assert_match match, exception.message end