diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a6..d37369ae 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -57,6 +57,7 @@ require 'liquid/file_system' require 'liquid/parser_switching' require 'liquid/tag' require 'liquid/block' +require 'liquid/hybrid_tag' require 'liquid/parse_tree_visitor' require 'liquid/interrupts' require 'liquid/tags' diff --git a/lib/liquid/hybrid_tag.rb b/lib/liquid/hybrid_tag.rb new file mode 100644 index 00000000..558fae3b --- /dev/null +++ b/lib/liquid/hybrid_tag.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Liquid + class HybridTag < Block + def parse(tokens) + if tokens.matching_end_tag?(tag_name) + @block_form = true + super + else + @block_form = false + end + end + + def block_form? + @block_form + end + + def nodelist + @body ? @body.nodelist : Const::EMPTY_ARRAY + end + + def render_to_output_buffer(context, output) + if @block_form + render_block_form_to_output_buffer(context, output) + else + render_self_closing_to_output_buffer(context, output) + end + end + + private + + def render_block_form_to_output_buffer(_context, _output) + raise NotImplementedError, "#{self.class} must implement render_block_form_to_output_buffer" + end + + def render_self_closing_to_output_buffer(_context, _output) + raise NotImplementedError, "#{self.class} must implement render_self_closing_to_output_buffer" + end + end +end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 8b331d93..e0566b43 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -9,6 +9,7 @@ module Liquid TAG_END = /%\}/ TAG_OR_VARIABLE_START = /\{[\{\%]/ NEWLINE = /\n/ + TAG_NAME_PATTERN = /\A\{%-?\s*(\w+)/ OPEN_CURLEY = "{".ord CLOSE_CURLEY = "}".ord @@ -48,6 +49,49 @@ module Liquid token end + def peek + @tokens[@offset] + end + + def position + [@offset, @line_number] + end + + def position=(pos) + @offset, @line_number = pos + end + + # Depth-aware forward scan of pre-tokenized tokens starting from the + # current position. + def matching_end_tag?(tag_name) + end_tag_name = "end#{tag_name}" + depth = 0 + i = @offset + + while i < @tokens.length + token = @tokens[i] + i += 1 + + next unless token.start_with?("{%") + + # TODO: use tag_name + match = TAG_NAME_PATTERN.match(token) + next unless match + + name = match[1] + + if name == tag_name + depth += 1 + elsif name == end_tag_name + return true if depth == 0 + + depth -= 1 + end + end + + false + end + private def tokenize diff --git a/test/unit/tags/hybrid_tag_unit_test.rb b/test/unit/tags/hybrid_tag_unit_test.rb new file mode 100644 index 00000000..82a2d23f --- /dev/null +++ b/test/unit/tags/hybrid_tag_unit_test.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'test_helper' + +class HybridTagUnitTest < Minitest::Test + class TestHybridTag < Liquid::HybridTag + private + + def render_self_closing_to_output_buffer(_context, output) + output << "self-closing" + end + + def render_block_form_to_output_buffer(context, output) + output << "block[" + @body.render_to_output_buffer(context, output) + output << "]" + end + end + + def setup + @environment = Liquid::Environment.build do |env| + env.tags = Liquid::Tags::STANDARD_TAGS.merge('hybrid' => TestHybridTag) + end + end + + def test_self_closing_form + template = Liquid::Template.parse('{% hybrid %}', environment: @environment) + assert_equal('self-closing', template.render) + end + + def test_self_closing_block_form_predicate_is_false + tag = parse_hybrid_tag('{% hybrid %}') + refute(tag.block_form?) + end + + def test_block_form + template = Liquid::Template.parse('{% hybrid %}content{% endhybrid %}', environment: @environment) + assert_equal('block[content]', template.render) + end + + def test_block_form_predicate_is_true + tag = parse_hybrid_tag('{% hybrid %}content{% endhybrid %}') + assert(tag.block_form?) + end + + def test_body_accessible_in_block_form + tag = parse_hybrid_tag('{% hybrid %}hello world{% endhybrid %}') + assert(tag.block_form?) + assert_equal('hello world', tag.nodelist.map(&:to_s).join) + end + + def test_self_closing_does_not_consume_tokens + template = Liquid::Template.parse('{% hybrid %}after', environment: @environment) + assert_equal('self-closingafter', template.render) + end + + def test_self_closing_followed_by_block_form + template = Liquid::Template.parse( + '{% hybrid %}{% hybrid %}inner{% endhybrid %}', + environment: @environment, + ) + assert_equal('self-closingblock[inner]', template.render) + end + + def test_block_form_followed_by_self_closing + template = Liquid::Template.parse( + '{% hybrid %}inner{% endhybrid %}{% hybrid %}', + environment: @environment, + ) + assert_equal('block[inner]self-closing', template.render) + end + + def test_multiple_consecutive_self_closing + template = Liquid::Template.parse( + '{% hybrid %}{% hybrid %}{% hybrid %}', + environment: @environment, + ) + assert_equal('self-closingself-closingself-closing', template.render) + end + + def test_multiple_consecutive_block_forms + template = Liquid::Template.parse( + '{% hybrid %}a{% endhybrid %}{% hybrid %}b{% endhybrid %}', + environment: @environment, + ) + assert_equal('block[a]block[b]', template.render) + end + + def test_mixed_forms + template = Liquid::Template.parse( + '{% hybrid %}{% hybrid %}inner{% endhybrid %}{% hybrid %}', + environment: @environment, + ) + assert_equal('self-closingblock[inner]self-closing', template.render) + end + + def test_self_closing_inside_block_tag + template = Liquid::Template.parse( + '{% if true %}{% hybrid %}{% endif %}', + environment: @environment, + ) + assert_equal('self-closing', template.render) + end + + def test_block_form_inside_block_tag + template = Liquid::Template.parse( + '{% if true %}{% hybrid %}content{% endhybrid %}{% endif %}', + environment: @environment, + ) + assert_equal('block[content]', template.render) + end + + def test_block_form_with_wrong_end_tag + error = assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse( + '{% hybrid %}content{% endwrong %}', + environment: @environment, + ) + end + assert_match(/endwrong/, error.message) + end + + def test_empty_block_form + template = Liquid::Template.parse('{% hybrid %}{% endhybrid %}', environment: @environment) + assert_equal('block[]', template.render) + end + + def test_block_form_with_liquid_tags_in_body + template = Liquid::Template.parse( + '{% hybrid %}{% if true %}yes{% endif %}{% endhybrid %}', + environment: @environment, + ) + assert_equal('block[yes]', template.render) + end + + def test_hybrid_tag_is_subclass_of_block + assert(Liquid::HybridTag < Liquid::Block) + end + + private + + def parse_hybrid_tag(source) + template = Liquid::Template.parse(source, environment: @environment) + template.root.nodelist.find { |node| node.is_a?(TestHybridTag) } + end +end diff --git a/test/unit/tokenizer_unit_test.rb b/test/unit/tokenizer_unit_test.rb index 76d379d3..552ec37a 100644 --- a/test/unit/tokenizer_unit_test.rb +++ b/test/unit/tokenizer_unit_test.rb @@ -48,6 +48,103 @@ class TokenizerTest < Minitest::Test assert_equal(["{%%}", "}"], tokenize('{%%}}')) end + def test_peek_returns_next_token_without_advancing + tokenizer = new_tokenizer('{{a}} {{b}}') + first = tokenizer.peek + assert_equal('{{a}}', first) + # peek again returns the same token (no advancement) + assert_equal('{{a}}', tokenizer.peek) + # shift returns the same token peek returned + assert_equal('{{a}}', tokenizer.send(:shift)) + # now peek returns the next token + assert_equal(' ', tokenizer.peek) + end + + def test_peek_returns_nil_when_no_tokens_remain + tokenizer = new_tokenizer('{{a}}') + tokenizer.send(:shift) + assert_nil(tokenizer.peek) + end + + def test_position_round_trips_correctly + tokenizer = new_tokenizer('{{a}} {{b}} {{c}}', start_line_number: 1) + # Shift once to get past first token + tokenizer.send(:shift) + saved = tokenizer.position + # Shift more tokens + second = tokenizer.send(:shift) + tokenizer.send(:shift) + # Restore position + tokenizer.position = saved + # Shifting again returns the same second token + assert_equal(second, tokenizer.send(:shift)) + end + + def test_position_restores_line_number + tokenizer = new_tokenizer("hello\n{{a}}\n{{b}}", start_line_number: 1) + saved = tokenizer.position + assert_equal(1, tokenizer.line_number) + tokenizer.send(:shift) # "hello\n" - line_number advances + assert_equal(2, tokenizer.line_number) + tokenizer.send(:shift) # "{{a}}" + tokenizer.send(:shift) # "\n" + # Restore to beginning + tokenizer.position = saved + assert_equal(1, tokenizer.line_number) + end + + def test_matching_end_tag_finds_matching_end_tag + tokenizer = new_tokenizer('{% render "a" %}hello{% endrender %}') + # Shift past the opening tag token + tokenizer.send(:shift) # {% render "a" %} + assert(tokenizer.matching_end_tag?("render")) + end + + def test_matching_end_tag_returns_false_when_no_match + tokenizer = new_tokenizer('{% render "a" %}hello{{ var }}') + tokenizer.send(:shift) # {% render "a" %} + refute(tokenizer.matching_end_tag?("render")) + end + + def test_matching_end_tag_handles_nested_same_name_tags + tokenizer = new_tokenizer( + '{% render "a" %}{% render "b" %}inner{% endrender %}outer{% endrender %}' + ) + tokenizer.send(:shift) # {% render "a" %} + # Should find the outer endrender (depth-aware), not the inner one + assert(tokenizer.matching_end_tag?("render")) + end + + def test_matching_end_tag_does_not_mutate_cursor_position + tokenizer = new_tokenizer('{% render "a" %}hello{% endrender %}more') + tokenizer.send(:shift) # {% render "a" %} + saved = tokenizer.position + tokenizer.matching_end_tag?("render") + assert_equal(saved, tokenizer.position) + end + + def test_matching_end_tag_returns_false_when_only_nested_end_tag + # Only a nested endrender exists (consumed by the inner render), no outer endrender + tokenizer = new_tokenizer( + '{% render "a" %}{% render "b" %}{% endrender %}' + ) + tokenizer.send(:shift) # {% render "a" %} + # The endrender belongs to the inner render (depth 1 -> 0), not the outer (depth 0) + refute(tokenizer.matching_end_tag?("render")) + end + + def test_matching_end_tag_with_whitespace_control + tokenizer = new_tokenizer('{% render "a" %}hello{%- endrender -%}') + tokenizer.send(:shift) # {% render "a" %} + assert(tokenizer.matching_end_tag?("render")) + end + + def test_matching_end_tag_at_eof + tokenizer = new_tokenizer('{% render "a" %}') + tokenizer.send(:shift) # {% render "a" %} + refute(tokenizer.matching_end_tag?("render")) + end + private def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil)