jfinkhaeuser/collapsium

View on GitHub
lib/collapsium/support/methods.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# coding: utf-8
#
# collapsium
# https://github.com/jfinkhaeuser/collapsium
#
# Copyright (c) 2016-2017 Jens Finkhaeuser and other collapsium contributors.
# All rights reserved.
#

require 'zlib'

module Collapsium

  ##
  # Support functionality for Collapsium
  module Support

    ##
    # Functionality for extending the behaviour of Hash methods
    module Methods
      WRAPPER_HASH = "@__collapsium_methods_wrappers".freeze

      # Try to determine built-in Classes and Modules early on, so we
      # can ignore them in our wrappers search.
      class << self
        ##
        # Return built-in symbols. It's called to initialize the BUILTINS
        # constant early on. It also caches its result, so should be safe to
        # call later, too.
        def builtins
          @builtins ||= nil
          if not @builtins.nil?
            return @builtins
          end

          # Object's constants contain all Module and Class definitions to date.
          # We need to get the constant values, though, not just their names.
          # Note: the $VERBOSE mess is to silence deprecation warnings, which
          #       occur on newer Ruby versions.
          verbose = $VERBOSE
          $VERBOSE = nil
          builtins = Object.constants.sort.map do |const_name|
            Object.const_get(const_name)
          end
          $VERBOSE = verbose

          # If JSON was required, there will be some generator methods that
          # override the above generators. We want to filter those out as well.
          # If, however, JSON was not required, we'll get a NameError and won't
          # add anything new.
          # rubocop:disable Lint/HandleExceptions
          begin
            json_builtins = JSON::Ext::Generator::GeneratorMethods.constants.sort
            json_builtins.map! do |const_name|
              JSON::Ext::Generator::GeneratorMethods.const_get(const_name)
            end
            builtins += json_builtins
          rescue NameError
            # We just ignore this; if JSON is automatically required, there will
            # be some mixin Modules here, otherwise we will just process them.
          end
          # rubocop:enable Lint/HandleExceptions

          # Last, we want to filter, so only Class and Module items are kept.
          builtins.select! do |item|
            item.is_a?(Module) or item.is_a?(Class)
          end

          @builtins = builtins
          return @builtins
        end
      end # class << self
      BUILTINS = Methods.builtins.freeze

      ##
      # Given the base module, wraps the given method name in the given block.
      # The block must accept the wrapped_method as the first parameter, followed
      # by any arguments and blocks the super method might accept.
      #
      # The canonical usage example is of a module that when prepended wraps
      # some methods with extra functionality:
      #
      # ```ruby
      #   module MyModule
      #     class << self
      #       include ::Collapsium::Support::Methods
      #
      #       def prepended(base)
      #         wrap_method(base, :method_name) do |wrapped_method, *args, &block|
      #           # modify args, if desired
      #           result = wrapped_method.call(*args, &block)
      #           # do something with the result, if desired
      #           next result
      #         end
      #       end
      #     end
      #   end
      # ```
      def wrap_method(base, method_name, options = {}, &wrapper_block)
        # Option defaults (need to check for nil if we default to true)
        if options[:raise_on_missing].nil?
          options[:raise_on_missing] = true
        end

        # Grab helper methods
        base_method, def_method = resolve_helpers(base, method_name,
                                                  options[:raise_on_missing])
        if base_method.nil?
          # Indicates that we're not done building a Module yet
          return
        end

        wrap_method_block = proc do |*args, &method_block|
          # We're trying to prevent loops by maintaining a stack of wrapped
          # method invocations.
          @__collapsium_methods_callstack ||= []

          # Our current binding is based on the wrapper block and our own class,
          # as well as the arguments (CRC32).
          signature = Zlib.crc32(args.to_s)
          the_binding = [wrapper_block.object_id, self.class.object_id, signature]

          # We'll either pass the wrapped method to the wrapper block, or invoke
          # it ourselves.
          wrapped_method = base_method.bind(self)

          # If we do find a loop with the current binding involved, we'll just
          # call the wrapped method.
          if Methods.loop_detected?(the_binding, @__collapsium_methods_callstack)
            next wrapped_method.call(*args, &method_block)
          end

          # If there is no loop, call the wrapper block and pass along the
          # wrapped method as the first argument.
          args.unshift(wrapped_method)

          # Then yield to the given wrapper block. The wrapper should decide
          # whether to call the old method or not. But by modifying our stack
          # before/after the invocation, we allow the loop detection above to
          # work.
          @__collapsium_methods_callstack << the_binding
          result = wrapper_block.call(*args, &method_block)
          @__collapsium_methods_callstack.pop

          next result
        end

        # Hack for calling the private method "define_method"
        def_method.call(method_name, &wrap_method_block)

        # Register this wrapper with the base
        base_wrappers = base.instance_variable_get(WRAPPER_HASH)
        base_wrappers ||= {}
        base_wrappers[method_name] ||= []
        base_wrappers[method_name] << wrapper_block
        base.instance_variable_set(WRAPPER_HASH, base_wrappers)
      end

      def resolve_helpers(base, method_name, raise_on_missing)
        # The base class must define an instance method of method_name, otherwise
        # this will NameError. That's also a good check that sensible things are
        # being done.
        base_method = nil
        def_method = nil
        if base.is_a? Module
          # Modules *may* not be fully defined when this is called, so in some
          # cases it's best to ignore NameErrors.
          begin
            base_method = base.instance_method(method_name.to_sym)
          rescue NameError
            if raise_on_missing
              raise
            end
            return nil, nil, nil
          end
          def_method = base.method(:define_method)
        else
          # For Objects and Classes, the unbound method will later be bound to
          # the object or class to define the method on.
          begin
            base_method = base.method(method_name.to_s).unbind
          rescue NameError
            if raise_on_missing
              raise
            end
            return nil, nil, nil
          end
          # With regards to method defintion, we only want to define methods
          # for the specific instance (i.e. use :define_singleton_method).
          def_method = base.method(:define_singleton_method)
        end

        return base_method, def_method
      end

      class << self
        ##
        # Given any base (value, class, module) and a method name, returns the
        # wrappers defined for the base, in order of definition. If no wrappers
        # are defined, an empty Array is returned.
        def wrappers(base, method_name, visited = nil)
          # First, check the instance, then its class for a wrapper. If either of
          # them succeeds, exit with a result.
          [base, base.class].each do |item|
            item_wrappers = item.instance_variable_get(WRAPPER_HASH)
            if not item_wrappers.nil? and item_wrappers.include?(method_name)
              return item_wrappers[method_name]
            end
          end

          # If neither of the above contained a wrapper, look at ancestors
          # recursively.
          ancestors = nil
          begin
            ancestors = base.ancestors
          rescue NoMethodError
            ancestors = base.class.ancestors
          end
          ancestors = ancestors - Object.ancestors - BUILTINS

          # Bail out if there are no ancestors to process.
          if ancestors.empty?
            return []
          end

          # We add the base and its class to the set of visited items. Note
          # that we're doing it late, so we only have to do it when we have
          # ancestors to visit.
          if visited.nil?
            visited = Set.new
          end
          visited.add(base)
          visited.add(base.class)

          ancestors.each do |ancestor|
            # Skip an visited item...
            if visited.include?(ancestor)
              next
            end
            visited.add(ancestor)

            # ... and recurse into unvisited ones
            anc_wrappers = wrappers(ancestor, method_name, visited)
            if not anc_wrappers.empty?
              return anc_wrappers
            end
          end

          # Return an empty list if we couldn't find anything.
          return []
        end

        # Given an input array, return repeated sequences from the array. It's
        # used in loop detection.
        def repeated(array)
          counts = Hash.new(0)
          array.each { |val| counts[val] += 1 }
          return counts.reject { |_, count| count == 1 }.keys
        end

        # Given a call stack and a binding, returns true if there seems to be a
        # loop in the call stack with the binding causing it, false otherwise.
        def loop_detected?(the_binding, stack)
          # Make a temporary stack with the binding pushed
          tmp_stack = stack.dup
          tmp_stack << the_binding
          loops = Methods.repeated(tmp_stack)

          # If we do find a loop with the current binding involved, we'll just
          # call the wrapped method.
          return loops.include?(the_binding)
        end
      end # class << self

    end # module Methods

  end # module Support

end # module Collapsium