From a6c93c35f8caae6034b045ceb7096a7bbd4ad954 Mon Sep 17 00:00:00 2001 From: Didier Lafforgue Date: Tue, 17 Sep 2024 11:21:08 +0200 Subject: [PATCH] working POC --- Gemfile | 2 + Gemfile.lock | 5 + lib/locomotive/steam/liquid.rb | 2 + .../steam/liquid/tags/with_scope.rb | 233 +++++++++++++++--- spec/unit/liquid/tags/with_scope_spec.rb | 59 ++++- 5 files changed, 265 insertions(+), 36 deletions(-) diff --git a/Gemfile b/Gemfile index 2e0158dd..5fff49ef 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,8 @@ source 'https://rubygems.org' gemspec +gem 'parser' + group :development do # gem 'locomotivecms_common', github: 'locomotivecms/common', ref: '4d1bd56' # gem 'locomotivecms_common', path: '../common' diff --git a/Gemfile.lock b/Gemfile.lock index 334e353c..b5cd0b3a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -41,6 +41,7 @@ GEM tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + ast (2.4.2) attr_extras (7.1.0) base64 (0.2.0) bcrypt (3.1.20) @@ -123,6 +124,9 @@ GEM nokogiri (1.15.6-x86_64-darwin) racc (~> 1.4) origin (2.3.1) + parser (3.3.5.0) + ast (~> 2.4.1) + racc pony (1.13.1) mail (>= 2.0) public_suffix (6.0.0) @@ -190,6 +194,7 @@ DEPENDENCIES memory_profiler mongo (~> 2.18.2) origin (~> 2.3.1) + parser puma (~> 6.4.0) rack (~> 3.0) rack-mini-profiler (~> 0.10.1) diff --git a/lib/locomotive/steam/liquid.rb b/lib/locomotive/steam/liquid.rb index a41d0cb7..52f13b61 100644 --- a/lib/locomotive/steam/liquid.rb +++ b/lib/locomotive/steam/liquid.rb @@ -1,4 +1,6 @@ require 'liquid' +require 'parser/current' +require 'ast' require_relative 'liquid/errors' require_relative 'liquid/patches' diff --git a/lib/locomotive/steam/liquid/tags/with_scope.rb b/lib/locomotive/steam/liquid/tags/with_scope.rb index 8d2a300d..1680d787 100644 --- a/lib/locomotive/steam/liquid/tags/with_scope.rb +++ b/lib/locomotive/steam/liquid/tags/with_scope.rb @@ -12,10 +12,120 @@ module Tags # {{ project.name }} # {% endfor %} # {% endwith_scope %} - # + # + # s(:hash, + # s(:pair, + # s(:sym, :key), + # s(:array, + # s(:int, 1), + # s(:int, 2), + # s(:int, 3)))) class WithScope < ::Liquid::Block + class HashProcessor + include AST::Processor::Mixin + + def on_hash(node) + nodes = process_all(node) + nodes.inject({}) { |memo, sub_hash| memo.merge(sub_hash) } + end + + def on_pair(node) + key_expr, right_expr = *node + { process(key_expr) => process(right_expr) } + end + + def on_sym(node) + node.children.first.to_sym + end + + def on_array(node) + process_all(node) + end + + def on_int(node) + node.children.first.to_i + end + + def on_float(node) + node.children.first.to_f + end + + def on_str(node) + node.children.first.to_s + end + + def on_true(node) + true + end + + def on_false(node) + false + end + + def on_regexp(node) + regexp_expr, opts_expr = *node + Regexp.new(process(regexp_expr), process(opts_expr)) + end + + def on_regopt(node) + node.children ? node.children.join('') : nil + end + + def on_deep_send(node) + source_expr, name_expr = *node + + if source_expr.nil? + [name_expr.to_s] + elsif source_expr.type == :send + process(source_expr.updated(:deep_send, nil)) << name_expr.to_s + else + raise 'NOT IMPLEMENTED [DEEP_SEND]' # TODO + end + end + + def on_send(node) + pp node.location + pp node.location.expression.source + + ::Liquid::Expression.parse(node.location.expression.source) + + # raise 'TODO' + + # source_expr, name_expr = *node + + # if source_expr.nil? + # ::Liquid::Expression.parse(name_expr.to_s) + # elsif source_expr.type == :send + # ::Liquid::Expression.parse( + # (process(source_expr.updated(:deep_send, nil)) << name_expr.to_s).join('.') + # ) + # else + # raise 'NOT IMPLEMENTED [SEND]' # TODO + # end + end + + # TODO: create our own mixin + def process(node) + return if node.nil? + + node = node.to_ast + + # Invoke a specific handler + on_handler = :"on_#{node.type}" + if respond_to? on_handler + new_node = send on_handler, node + else + new_node = handler_missing(node) + end + + node = new_node unless new_node.nil? + + node + end + end + include Concerns::Attributes # Regexps and Arrays are allowed @@ -25,7 +135,8 @@ class WithScope < ::Liquid::Block # a slight different from the Shopify implementation because we allow stuff like `started_at.le` TagAttributes = /([a-zA-Z_0-9\.]+)\s*\:\s*(#{ArrayFragment}|#{RegexpFragment}|#{::Liquid::QuotedFragment})/o.freeze - SingleVariable = /(#{::Liquid::VariableSignature}+)/om.freeze + # SingleVariable = /(#{::Liquid::VariableSignature}+)/om.freeze + SingleVariable = /\A\w*([a-zA-Z_0-9]+)\w*\z/om.freeze REGEX_OPTIONS = { 'i' => Regexp::IGNORECASE, @@ -33,23 +144,51 @@ class WithScope < ::Liquid::Block 'x' => Regexp::EXTENDED }.freeze - attr_reader :attributes, :attributes_var_name + OPERATORS = %w(all exists gt gte in lt lte ne nin size near within) + + SYMBOL_OPERATORS_REGEXP = /(\w+\.(#{OPERATORS.join('|')})){1}\s*\:/o + + attr_reader :attributes, :attributes_var_name, :ast def initialize(tag_name, markup, options) super - # simple hash? - parse_attributes(markup) { |value| parse_attribute(value) } + # convert symbol operators into valid ruby code + markup.gsub!(SYMBOL_OPERATORS_REGEXP, ':"\1" =>') - if attributes.empty? && markup =~ SingleVariable + pp markup + + if markup =~ SingleVariable + puts "HERE?" # alright, maybe we'vot got a single variable built # with the Action liquid tag instead? @attributes_var_name = Regexp.last_match(1) + elsif markup.present? + ast = Parser::CurrentRuby.parse("{%s}" % markup) + pp ast + @attributes = HashProcessor.new.process(ast) + puts "-----" + pp @attributes end - if attributes.empty? && attributes_var_name.blank? + if attributes.blank? && attributes_var_name.blank? raise ::Liquid::SyntaxError.new("Syntax Error in 'with_scope' - Valid syntax: with_scope : , ..., : ") end + + # raise 'TODO' + + # # simple hash? + # parse_attributes(markup) { |value| parse_attribute(value) } + + # if attributes.empty? && markup =~ SingleVariable + # # alright, maybe we'vot got a single variable built + # # with the Action liquid tag instead? + # @attributes_var_name = Regexp.last_match(1) + # end + + # if attributes.empty? && attributes_var_name.blank? + # raise ::Liquid::SyntaxError.new("Syntax Error in 'with_scope' - Valid syntax: with_scope : , ..., : ") + # end end def render(context) @@ -65,44 +204,74 @@ def render(context) protected - def parse_attribute(value) - case value - when StrictRegexpFragment - # let the cast_value attribute create the Regexp (done during the rendering phase) - value - when ArrayFragment - $1.split(',').map { |_value| parse_attribute(_value) } - else - ::Liquid::Expression.parse(value) - end - end + # def parse_attribute(value) + # case value + # when StrictRegexpFragment + # # let the cast_value attribute create the Regexp (done during the rendering phase) + # value + # when ArrayFragment + # $1.split(',').map { |_value| parse_attribute(_value) } + # else + # ::Liquid::Expression.parse(value) + # end + # end def evaluate_attributes(context) @attributes = context[attributes_var_name] || {} if attributes_var_name.present? - HashWithIndifferentAccess.new.tap do |hash| - attributes.each do |key, value| - # _slug instead of _permalink - _key = key.to_s == '_permalink' ? '_slug' : key.to_s + attributes.inject({}) do |memo, (key, value)| + # _slug instead of _permalink + _key = key.to_s == '_permalink' ? '_slug' : key.to_s - # evaluate the value if possible before casting it - _value = value.is_a?(::Liquid::VariableLookup) ? context.evaluate(value) : value + # puts [_key, evaluate_attribute(context, value)] - hash[_key] = cast_value(context, _value) - end + memo.merge({ _key => evaluate_attribute(context, value) }) end end - def cast_value(context, value) + def evaluate_attribute(context, value) + pp "evaluate_attribute = #{value}" case value - when Array then value.map { |_value| cast_value(context, _value) } - when StrictRegexpFragment then create_regexp($1, $2) - else - _value = context.evaluate(value) - _value.respond_to?(:_id) ? _value.send(:_source) : _value + when Array + value.map { |v| evaluate_attribute(context, v) } + when Hash + Hash[value.map { |k, v| [k.to_s, evaluate_attribute(context, v)] }] + when ::Liquid::VariableLookup + evaluated_value = context.evaluate(value) + evaluated_value.respond_to?(:_id) ? evaluated_value.send(:_source) : evaluate_attribute(context, evaluated_value) + when StrictRegexpFragment + create_regexp($1, $2) + else + value end end + # def evaluate_attributes(context) + # @attributes = context[attributes_var_name] || {} if attributes_var_name.present? + + # HashWithIndifferentAccess.new.tap do |hash| + # attributes.each do |key, value| + # # _slug instead of _permalink + # _key = key.to_s == '_permalink' ? '_slug' : key.to_s + + # # evaluate the value if possible before casting it + # _value = value.is_a?(::Liquid::VariableLookup) ? context.evaluate(value) : value + + # hash[_key] = cast_value(context, _value) + # end + # end + # end + + # def cast_value(context, value) + # case value + # when Array then value.map { |_value| cast_value(context, _value) } + # when StrictRegexpFragment then create_regexp($1, $2) + # else + # _value = context.evaluate(value) + # _value.respond_to?(:_id) ? _value.send(:_source) : _value + # end + # end + def create_regexp(value, unparsed_options) options = unparsed_options.blank? ? nil : unparsed_options.split('').uniq.inject(0) do |_options, letter| _options |= REGEX_OPTIONS[letter] diff --git a/spec/unit/liquid/tags/with_scope_spec.rb b/spec/unit/liquid/tags/with_scope_spec.rb index 296b29c2..1fbf7c2d 100644 --- a/spec/unit/liquid/tags/with_scope_spec.rb +++ b/spec/unit/liquid/tags/with_scope_spec.rb @@ -54,7 +54,7 @@ describe 'decode basic options (boolean, integer, ...)' do - let(:source) { "{% with_scope active: true, price: 42, title: 'foo', hidden: false %}{% assign conditions = with_scope %}{% endwith_scope %}" } + let(:source) { "{% with_scope active: true, price: 41 + 1, title: 'foo', hidden: false %}{% assign conditions = with_scope %}{% endwith_scope %}" } it { expect(conditions['active']).to eq true } it { expect(conditions['price']).to eq 42 } @@ -147,9 +147,8 @@ end describe 'In a loop context, each scope should be evaluated correctly' do - let(:assigns) { {'list' => ['A', 'B', 'C']} } - - let(:source) { "{% for key in list %}{% with_scope foo: key %}{% assign conditions = with_scope %}{% endwith_scope %}{{ conditions }}{% endfor %}" } + let(:assigns) { {'list' => ['A', 'B', 'C']} } + let(:source) { "{% for key in list %}{% with_scope foo: key %}{% assign conditions = with_scope %}{% endwith_scope %}{{ conditions }}{% endfor %}" } it { expect(output).to eq '{"foo"=>"A"}{"foo"=>"B"}{"foo"=>"C"}' } @@ -157,4 +156,56 @@ end + describe 'decode advanced options' do + let(:options) { "" } + let(:source) { "{% with_scope key: #{options} %}{% assign conditions = with_scope %}{% endwith_scope %}" } + + before { output } + + context "Array" do + context "of Integer" do + let(:options) { "[1, 2, 3]" } + it { expect(conditions['key']).to eq [1, 2, 3] } + end + + context "of String" do + let(:options) { "['a', 'b', 'c']" } + it { expect(conditions['key']).to eq ['a', 'b', 'c'] } + end + + context "With variable" do + let(:assigns) { {'a' => 1, 'c' => 3} } + let(:options) { "[a, 2, c, 'd']" } + it { expect(conditions['key']).to eq [1, 2, 3, 'd'] } + end + end + + context "Hash" do + context "With key value" do + let(:options) { "{a: 1, b: 2, c: 3, d: 'foo'}" } + it { expect(conditions['key'].keys).to eq(%w(a b c d)) } + it { expect(conditions['key']['a']).to eq 1 } + it { expect(conditions['key']['b']).to eq 2 } + it { expect(conditions['key']['c']).to eq 3 } + it { expect(conditions['key']['d']).to eq 'foo' } + end + + context "With key variable" do + let(:assigns) { {'a' => 1, 'c' => 3} } + let(:options) { "{a: a, b: 2, c: c, d: 'foo'}" } + it { expect(conditions['key'].keys).to eq(%w(a b c d)) } + it { expect(conditions['key']['a']).to eq 1 } + it { expect(conditions['key']['b']).to eq 2 } + it { expect(conditions['key']['c']).to eq 3 } + it { expect(conditions['key']['d']).to eq 'foo' } + end + + context "With params" do + let(:assigns) { { 'params' => Locomotive::Steam::Liquid::Drops::Params.new({ foo: 'bar' }) } } + let(:options) { "{'a': params.foo}" } + it { expect(conditions['key'].keys).to eq(%w(a)) } + it { expect(conditions['key']['a']).to eq 'bar' } + end + end + end end