From 2aa9bbbac269f473b4b2c4b92d3b3aba0a7412e4 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Mon, 21 Jul 2014 22:16:24 -0400 Subject: [PATCH] Separate expression parsing and rendering from Context#resolve. --- lib/liquid.rb | 3 + lib/liquid/context.rb | 193 ++++++++++------------------------ lib/liquid/expression.rb | 33 ++++++ lib/liquid/range_lookup.rb | 22 ++++ lib/liquid/variable_lookup.rb | 68 ++++++++++++ 5 files changed, 179 insertions(+), 140 deletions(-) create mode 100644 lib/liquid/expression.rb create mode 100644 lib/liquid/range_lookup.rb create mode 100644 lib/liquid/variable_lookup.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index bb81745..dc437d1 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -52,11 +52,14 @@ require 'liquid/extensions' require 'liquid/errors' require 'liquid/interrupts' require 'liquid/strainer' +require 'liquid/expression' require 'liquid/context' require 'liquid/tag' require 'liquid/block' require 'liquid/document' require 'liquid/variable' +require 'liquid/variable_lookup' +require 'liquid/range_lookup' require 'liquid/file_system' require 'liquid/template' require 'liquid/standardfilters' diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index efd74cb..73ffca0 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -16,15 +16,13 @@ module Liquid attr_reader :scopes, :errors, :registers, :environments, :resource_limits attr_accessor :exception_handler - SQUARE_BRACKETED = /\A\[(.*)\]\z/m - def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = {}) @environments = [environments].flatten @scopes = [(outer_scope || {})] @registers = registers @errors = [] @resource_limits = (resource_limits || {}).merge!({ :render_score_current => 0, :assign_score_current => 0 }) - @parsed_variables = Hash.new{ |cache, markup| cache[markup] = variable_parse(markup) } + @parsed_expression = Hash.new{ |cache, markup| cache[markup] = Expression.parse(markup) } squash_instance_assigns_with_environments @this_stack_used = false @@ -163,149 +161,64 @@ module Liquid @scopes[0][key] = value end - def [](key) - resolve(key) + # Look up variable, either resolve directly after considering the name. We can directly handle + # Strings, digits, floats and booleans (true,false). + # If no match is made we lookup the variable in the current scope and + # later move up to the parent blocks to see if we can resolve the variable somewhere up the tree. + # Some special keywords return symbols. Those symbols are to be called on the rhs object in expressions + # + # Example: + # products == empty #=> products.empty? + def [](expression) + evaluate(@parsed_expression[expression]) end def has_key?(key) - resolve(key) != nil + self[key] != nil + end + + def evaluate(object) + object.respond_to?(:evaluate) ? object.evaluate(self) : object + end + + # Fetches an object starting at the local scope and then moving up the hierachy + def find_variable(key) + + # This was changed from find() to find_index() because this is a very hot + # path and find_index() is optimized in MRI to reduce object allocation + index = @scopes.find_index { |s| s.has_key?(key) } + scope = @scopes[index] if index + + variable = nil + + if scope.nil? + @environments.each do |e| + variable = lookup_and_evaluate(e, key) + unless variable.nil? + scope = e + break + end + end + end + + scope ||= @environments.last || @scopes.last + variable ||= lookup_and_evaluate(scope, key) + + variable = variable.to_liquid + variable.context = self if variable.respond_to?(:context=) + + return variable + end + + def lookup_and_evaluate(obj, key) + if (value = obj[key]).is_a?(Proc) && obj.respond_to?(:[]=) + obj[key] = (value.arity == 0) ? value.call : value.call(self) + else + value + end end private - LITERALS = { - nil => nil, 'nil'.freeze => nil, 'null'.freeze => nil, ''.freeze => nil, - 'true'.freeze => true, - 'false'.freeze => false, - 'blank'.freeze => :blank?, - 'empty'.freeze => :empty? - } - - # Look up variable, either resolve directly after considering the name. We can directly handle - # Strings, digits, floats and booleans (true,false). - # If no match is made we lookup the variable in the current scope and - # later move up to the parent blocks to see if we can resolve the variable somewhere up the tree. - # Some special keywords return symbols. Those symbols are to be called on the rhs object in expressions - # - # Example: - # products == empty #=> products.empty? - def resolve(key) - if LITERALS.key?(key) - LITERALS[key] - else - case key - when /\A'(.*)'\z/m # Single quoted strings - $1 - when /\A"(.*)"\z/m # Double quoted strings - $1 - when /\A(-?\d+)\z/ # Integer and floats - $1.to_i - when /\A\((\S+)\.\.(\S+)\)\z/ # Ranges - (resolve($1).to_i..resolve($2).to_i) - when /\A(-?\d[\d\.]+)\z/ # Floats - $1.to_f - else - variable(key) - end - end - end - - # Fetches an object starting at the local scope and then moving up the hierachy - def find_variable(key) - - # This was changed from find() to find_index() because this is a very hot - # path and find_index() is optimized in MRI to reduce object allocation - index = @scopes.find_index { |s| s.has_key?(key) } - scope = @scopes[index] if index - - variable = nil - - if scope.nil? - @environments.each do |e| - variable = lookup_and_evaluate(e, key) - unless variable.nil? - scope = e - break - end - end - end - - scope ||= @environments.last || @scopes.last - variable ||= lookup_and_evaluate(scope, key) - - variable = variable.to_liquid - variable.context = self if variable.respond_to?(:context=) - - return variable - end - - def variable_parse(markup) - parts = markup.scan(VariableParser) - needs_resolution = false - if parts.first =~ SQUARE_BRACKETED - needs_resolution = true - parts[0] = $1 - end - {:first => parts.shift, :needs_resolution => needs_resolution, :rest => parts} - end - - # Resolves namespaced queries gracefully. - # - # Example - # @context['hash'] = {"name" => 'tobi'} - # assert_equal 'tobi', @context['hash.name'] - # assert_equal 'tobi', @context['hash["name"]'] - def variable(markup) - parts = @parsed_variables[markup] - - first_part = parts[:first] - if parts[:needs_resolution] - first_part = resolve(parts[:first]) - end - - if object = find_variable(first_part) - - parts[:rest].each do |part| - part = resolve($1) if part_resolved = (part =~ SQUARE_BRACKETED) - - # If object is a hash- or array-like object we look for the - # presence of the key and if its available we return it - if object.respond_to?(:[]) and - ((object.respond_to?(:has_key?) and object.has_key?(part)) or - (object.respond_to?(:fetch) and part.is_a?(Integer))) - - # if its a proc we will replace the entry with the proc - res = lookup_and_evaluate(object, part) - object = res.to_liquid - - # Some special cases. If the part wasn't in square brackets and - # no key with the same name was found we interpret following calls - # as commands and call them on the current object - elsif !part_resolved and object.respond_to?(part) and ['size'.freeze, 'first'.freeze, 'last'.freeze].include?(part) - - object = object.send(part.intern).to_liquid - - # No key was present with the desired value and it wasn't one of the directly supported - # keywords either. The only thing we got left is to return nil - else - return nil - end - - # If we are dealing with a drop here we have to - object.context = self if object.respond_to?(:context=) - end - end - - object - end # variable - - def lookup_and_evaluate(obj, key) - if (value = obj[key]).is_a?(Proc) && obj.respond_to?(:[]=) - obj[key] = (value.arity == 0) ? value.call : value.call(self) - else - value - end - end # lookup_and_evaluate - def squash_instance_assigns_with_environments @scopes.last.each_key do |k| @environments.each do |env| diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb new file mode 100644 index 0000000..3c8a585 --- /dev/null +++ b/lib/liquid/expression.rb @@ -0,0 +1,33 @@ +module Liquid + class Expression + LITERALS = { + nil => nil, 'nil'.freeze => nil, 'null'.freeze => nil, ''.freeze => nil, + 'true'.freeze => true, + 'false'.freeze => false, + 'blank'.freeze => :blank?, + 'empty'.freeze => :empty? + } + + def self.parse(markup) + if LITERALS.key?(markup) + LITERALS[markup] + else + case markup + when /\A'(.*)'\z/m # Single quoted strings + $1 + when /\A"(.*)"\z/m # Double quoted strings + $1 + when /\A(-?\d+)\z/ # Integer and floats + $1.to_i + when /\A\((\S+)\.\.(\S+)\)\z/ # Ranges + RangeLookup.parse($1, $2) + when /\A(-?\d[\d\.]+)\z/ # Floats + $1.to_f + else + VariableLookup.parse(markup) + end + end + end + + end +end diff --git a/lib/liquid/range_lookup.rb b/lib/liquid/range_lookup.rb new file mode 100644 index 0000000..efebd2c --- /dev/null +++ b/lib/liquid/range_lookup.rb @@ -0,0 +1,22 @@ +module Liquid + class RangeLookup + def self.parse(start_markup, end_markup) + start_obj = Expression.parse(start_markup) + end_obj = Expression.parse(end_markup) + if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) + new(start_obj, end_obj) + else + start_obj.to_i..end_obj.to_i + end + end + + def initialize(start_obj, end_obj) + @start_obj = start_obj + @end_obj = end_obj + end + + def evaluate(context) + context.evaluate(@start_obj).to_i..context.evaluate(@end_obj).to_i + end + end +end diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb new file mode 100644 index 0000000..f84d7a4 --- /dev/null +++ b/lib/liquid/variable_lookup.rb @@ -0,0 +1,68 @@ +module Liquid + class VariableLookup + SQUARE_BRACKETED = /\A\[(.*)\]\z/m + COMMAND_METHODS = ['size'.freeze, 'first'.freeze, 'last'.freeze] + + def self.parse(markup) + new(markup) + end + + def initialize(markup) + lookups = markup.scan(VariableParser) + + name = lookups.shift + if name =~ SQUARE_BRACKETED + name = Expression.parse($1) + end + @name = name + + @lookups = lookups + @command_flags = 0 + + @lookups.each_index do |i| + lookup = lookups[i] + if lookup =~ SQUARE_BRACKETED + lookups[i] = Expression.parse($1) + elsif COMMAND_METHODS.include?(lookup) + @command_flags |= 1 << i + end + end + end + + def evaluate(context) + name = context.evaluate(@name) + object = context.find_variable(name) + + @lookups.each_index do |i| + key = context.evaluate(@lookups[i]) + + # If object is a hash- or array-like object we look for the + # presence of the key and if its available we return it + if object.respond_to?(:[]) && + ((object.respond_to?(:has_key?) && object.has_key?(key)) || + (object.respond_to?(:fetch) && key.is_a?(Integer))) + + # if its a proc we will replace the entry with the proc + res = context.lookup_and_evaluate(object, key) + object = res.to_liquid + + # Some special cases. If the part wasn't in square brackets and + # no key with the same name was found we interpret following calls + # as commands and call them on the current object + elsif @command_flags & (1 << i) != 0 && object.respond_to?(key) + object = object.send(key).to_liquid + + # No key was present with the desired value and it wasn't one of the directly supported + # keywords either. The only thing we got left is to return nil + else + return nil + end + + # If we are dealing with a drop here we have to + object.context = context if object.respond_to?(:context=) + end + + object + end + end +end