lib/jamespath/vm.rb



module Jamespath
  # The virtual machine that interprets compiled expressions and searches for
  # objects. The VM implements a handful of instructions that can be used to
  # navigate through an object structure.
  #
  # # VM Overview
  #
  # The VM iterates over the instructions attempting to navigate through
  # the given object. As instructions are evaluated, the "object" is tracked
  # and replaced as each selection is made. The result of a search is the
  # object value after evaluating all instructions.
  #
  # The VM understands "hash-like" and "array-like" objects. "Array-like"
  # objects are defined as any object that subclasses Array. "Hash-like"
  # objects are defined as either Hash or Struct objects.
  #
  # ## Instruction list
  #
  # ### `:get_key <key>`
  #
  # Gets a "key" from the hash-like object on the stack. If the object is
  # not hash-like, this instruction sets the object value to nil.
  #
  # ### `:get_idx <idx>`
  #
  # Gets an object at index "idx" from the array-like object on the stack.
  # If the object is not array-like, this instruction sets the object value
  # to nil. "idx" must be a number, but can be negative. Negative values
  # index from the end of the array, where -1 is the last value.
  #
  # ### `:get_key_all`
  #
  # Gets all values from the hash-like object value. If the object is not
  # hash-like, this instruction sets the object value to nil.
  #
  # ### `:get_idx_all`
  #
  # Gets all items from an array-like object. If the object is hash-like,
  # the object is set to the keys of the hash-like structure. If the object
  # is not array-like or hash-like, this instruction sets the object value
  # to nil.
  #
  # ### `:flatten_list`
  #
  # Flattens a list of subarrays into a single array. If the object is not
  # array-like, this instruction sets the object value to an empty array.
  #
  # ### `:ret_if_match`
  #
  # Breaks from parsing instructions if the object value is non-nil. If the
  # object is nil, this instruction should reset the object value to the
  # original object that was being searched.
  #
  class VM
    # @api private
    class ArrayGroup < Array
      def initialize(arr) replace(arr) end
    end

    # @return [Array(Symbol, Object)] the instructions the VM executes.
    attr_reader :instructions

    # Creates a virtual machine that can evaluate a set of instructions.
    # Use the {Parser} to turn an expression into a set of instructions.
    #
    # @param instructions [Array(Symbol, Object)] a list of instructions to
    #   execute.
    # @see Parser#parse
    # @example VM for expression "foo.bar[-1]"
    #   vm = VM.new [
    #    [:get_key, 'foo'],
    #    [:get_key, 'bar'],
    #    [:get_idx, -1]
    #   ]
    #   vm.search(foo: {bar: [1, 2, 3]}) #=> 3
    def initialize(instructions)
      @instructions = instructions
    end

    # Searches for the compile expression against the object passed in.
    #
    # @param object_to_search [Object] the object to search for results.
    # @return (see Jamespath.search)
    def search(object_to_search)
      object = object_to_search
      @instructions.each do |instruction|
        if instruction.first == :ret_if_match
          if object
            break # short-circuit or expression
          else
            object = object_to_search  # reset search
          end
        else
          object = send(instruction[0], object, instruction[1])
        end
      end

      object
    end

    protected

    def get_key(object, key)
      if struct?(object)
        object[key]
      elsif ArrayGroup === object
        object = object.map {|o| get_key(o, key) }.compact
        object.length > 0 ? ArrayGroup.new(object) : ArrayGroup.new([])
      end
    end

    def get_idx(object, idx)
      if ArrayGroup === object
        object = object.map {|o| get_idx(o, idx) }.compact
        object.length > 0 ? ArrayGroup.new(object) : nil
      elsif array?(object)
        object[idx]
      end
    end

    def get_key_all(object, *)
      object.respond_to?(:values) ? ArrayGroup.new(object.values) : nil
    end

    def get_idx_all(object, *)
      if array?(object)
        new_object = object.map do |o|
          Array === o ? ArrayGroup.new(o) : o
        end
        ArrayGroup.new(new_object)
      elsif object.respond_to?(:keys)
        ArrayGroup.new(object.keys)
      elsif object.respond_to?(:members)
        ArrayGroup.new(object.members.map(&:to_s))
      end
    end

    def flatten_list(object, *)
      if array?(object)
        new_object = []
        object.each {|o| array?(o) ? (new_object += o) : new_object << o }
        ArrayGroup.new(new_object)
      else
        []
      end
    end

    private

    def struct?(object)
      Hash === object || Struct === object
    end

    def array?(object)
      Array === object
    end
  end
end