medcat/liquidscript

View on GitHub
lib/liquidscript/icr/context.rb

Summary

Maintainability
A
0 mins
Test Coverage
require "hashie"

module Liquidscript
  module ICR

    # Handles variables within blocks.  Each variable will get a
    # reference in each context.  When retrieving the value of
    # a variable, if the variable was not introduced in the scope,
    # it will look to its parent for the value of the variable.
    # When setting the value of a variable, a new variable is
    # forcibly created.
    class Context

      include Representable

      # The variables that are allowed to be used as a global scope,
      # i.e. used in a `get` context without a previous `set`.
      DEFAULT_ALLOWED_VARIABLES = %w(
        window global exports document $ console this arguments
        Infinity NaN undefined eval isFinite isNaN parseFloat
        parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent
        Object Function Boolean Error EvalError InternalError RangeError
        ReferenceError SyntaxError TypeError URIError Number Math
        Date String  RegExp Array Float32Array Float64Array Int16Array
        Int32Array Int8Array Uint16Array Uint32Array Uint8Array
        Uint8ClampedArray ArrayBuffer DataView JSON Intl setTimeout
        clearTimeout setInterval clearInterval
      ).map(&:intern).freeze

      attr_accessor :parent
      attr_reader :variables

      def initialize
        @variables = {}
        @undefined = []
      end

      def allowed_variables
        DEFAULT_ALLOWED_VARIABLES.dup
      end

      # If the context delegates setting variables to its parents.
      # This keeps this context from getting any variables set on
      # it, and instead sets variables on the parent.
      #
      # @see {#delegate!}
      # @return [Boolean]
      def delegate?
        @delegate
      end

      # Sets whether or not the context will delegate setting
      # variables to its parent.
      #
      # @see {#delegate?}
      # @return [Boolean]
      def delegate!
        @delegate = !delegate?
      end

      # Delegates a block, such that the contents don't affect
      # the current context.
      #
      # @return [Object] the value of the block
      def delegate
        old, @delegate = @delegate, true
        out = yield
        @delegate = old
        out
      end

      # If this context is associated with a class.  The context
      # will forward any errors until after the context is completely
      # finalized.
      #
      # @see {#class!}
      # @return [Boolean]
      def class?
        @class
      end

      # Sets this context to be associated with a class.
      # @see {#class?}
      # @return [Boolean]
      def class!
        @class = true
      end

      # Retrieves a reference to a variable.  If the local
      # context doesn't have a reference, then it will try
      # a few things; first, it will check to see if that
      # variable is one of our allowed variables; second,
      # it will check if the parent has a reference; otherwise,
      # it will add an undefined reference if this context
      # is associated with a class.
      #
      # @see {#parent}
      # @see {#allowed_variables}
      # @see {#add_undefined}
      # @see {#variables}
      # @param name [Symbol] the variable to reference.
      # @param options [Hash] Extra options.
      # @option options [Boolean] :dry_run if this is a dry
      #   run.  In that case, it won't add an undefined
      #   reference.
      # @raise [InvalidReferenceError] if the variable could
      #   not be handled correctly.
      # @return [Variable, Boolean]
      def get(name, options = {})
        variables.fetch(name) do
          case true
          # If the asking variable is an allowed variable, we'll
          # allow it, and just return a variable instance.
          when allowed_variables.include?(name)
            Variable.new(self, name, :allowed => true)
          # If we have a parent, we can ask the parent for the
          # variable.  This takes precedence over the class
          # so we can get a proper reference to the correct
          # variable.
          when !!parent
            parent_get(name, options)
          # If this context is associated with a class, and
          # we're not doing a dry run, then we'll add an
          # undefined.
          when @class && !options[:dry_run]
            add_undefined(name)
          # If we are doing a dry run, however, then just let
          # the caller know that it would have been successful.
          when @class && options[:dry_run]
            true
          # If none of those options fit, raise an error.
          else
            raise InvalidReferenceError.new(name)
          end
        end
      end

      # If this context delegates, it delegates to the parent;
      # otherwise, it will check if we have set this variable
      # before.  Otherwise, it will first reject any undefined
      # variables that existed with the specific name, and then
      # create a new variable with the given options.
      #
      # @see {#variables}
      # @see {#parent}
      # @see {#delegate?}
      # @param name [Symbol] the name of the variable.
      # @param options [Hash] the options to pass to the new
      #   variable instance.
      # @return [Variable]
      def set(name, options = {})
        return parent.set(name, options) if delegate?

        variables.fetch(name) do
          @undefined.reject! { |(n, _)| n == name }
          variables[name] = Variable.new(self, name,
            { :class => class? }.merge(options))
        end
      end

      # Retrieves all of the variables that are parameters
      # in the current context.
      #
      # @see {Variable#parameter?}
      # @return [Array<Variable>]
      def parameters
        variables.values.select(&:parameter?)
      end

      # Retrieves all of the variables that are hidden in
      # the current context.
      #
      # @see {Variable#hidden?}
      # @return [Array<Variable>]
      def hidden
        variables.values.select(&:hidden?)
      end

      # Returns the name of the variables in this context.
      #
      # @return [Array<Symbol>]
      def to_a
        variables.keys
      end

      # Check if there are any undefined variables, and if
      # there are, raise the first one it sees.
      #
      #
      def force_defined!
        @undefined.each { |f| raise f[1] }
      end

      private

      # Retrieves a variable from a parent.  If this context
      # is associated with a class, it will add an undefined
      # variable if the parent raises an
      # InvalidReferenceError.  Otherwise, it will just allow
      # the error to be raised.
      #
      # @see {#parent}
      # @see {#add_undefined}
      # @raise [InvalidReferenceError]
      # @param name [Symbol] the name of the variable.
      # @param options [Hash] the options to be passed to the
      #   parent.
      # @return [Variable]
      def parent_get(name, options)
        parent.get(name, options)

      rescue InvalidReferenceError => e
        if class? && !options[:dry_run]
          add_undefined(name, e)
        else
          raise
        end
      end

      # Adds an undefined variable to the list.  If there are
      # any at the end of compiling, then the corresponding
      # errors will be raised.  This only applies if the context
      # is associated with a class.
      #
      # @see {#class?}
      # @param name [Symbol] the name of the variable.
      # @param error [InvalidReferenceError] the error to be
      #   raised if the undefined variable is not defined
      #   by the end of compilation.
      # @return [Variable]
      def add_undefined(name, error = InvalidReferenceError.new(name))
        @undefined << [name, error]
        Variable.new(self, name, :class => true)
      end

    end

  end
end