lib/dry/types/constructor/function.rb



# frozen_string_literal: true

require 'concurrent/map'

module Dry
  module Types
    class Constructor < Nominal
      # Function is used internally by Constructor types
      #
      # @api private
      class Function
        # Wrapper for unsafe coercion functions
        #
        # @api private
        class Safe < Function
          def call(input, &block)
            @fn.(input, &block)
          rescue NoMethodError, TypeError, ArgumentError => e
            CoercionError.handle(e, &block)
          end
        end

        # Coercion via a method call on a known object
        #
        # @api private
        class MethodCall < Function
          @cache = ::Concurrent::Map.new

          # Choose or build the base class
          #
          # @return [Function]
          def self.call_class(method, public, safe)
            @cache.fetch_or_store([method, public, safe].hash) do
              if public
                ::Class.new(PublicCall) do
                  include PublicCall.call_interface(method, safe)
                end
              elsif safe
                PrivateCall
              else
                PrivateSafeCall
              end
            end
          end

          # Coercion with a publicly accessible method call
          #
          # @api private
          class PublicCall < MethodCall
            @interfaces = ::Concurrent::Map.new

            # Choose or build the interface
            #
            # @return [::Module]
            def self.call_interface(method, safe)
              @interfaces.fetch_or_store([method, safe].hash) do
                ::Module.new do
                  if safe
                    module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
                      def call(input, &block)
                        @target.#{method}(input, &block)
                      end
                    RUBY
                  else
                    module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
                      def call(input, &block)
                        @target.#{method}(input)
                      rescue NoMethodError, TypeError, ArgumentError => error
                        CoercionError.handle(error, &block)
                      end
                    RUBY
                  end
                end
              end
            end
          end

          # Coercion via a private method call
          #
          # @api private
          class PrivateCall < MethodCall
            def call(input, &block)
              @target.send(@name, input, &block)
            end
          end

          # Coercion via an unsafe private method call
          #
          # @api private
          class PrivateSafeCall < PrivateCall
            def call(input, &block)
              @target.send(@name, input)
            rescue NoMethodError, TypeError, ArgumentError => e
              CoercionError.handle(e, &block)
            end
          end

          # @api private
          #
          # @return [MethodCall]
          def self.[](fn, safe)
            public = fn.receiver.respond_to?(fn.name)
            MethodCall.call_class(fn.name, public, safe).new(fn)
          end

          attr_reader :target, :name

          def initialize(fn)
            super
            @target = fn.receiver
            @name = fn.name
          end

          def to_ast
            [:method, target, name]
          end
        end

        # Choose or build specialized invokation code for a callable
        #
        # @param [#call] fn
        # @return [Function]
        def self.[](fn)
          raise ArgumentError, 'Missing constructor block' if fn.nil?

          if fn.is_a?(Function)
            fn
          elsif fn.is_a?(::Method)
            MethodCall[fn, yields_block?(fn)]
          elsif yields_block?(fn)
            new(fn)
          else
            Safe.new(fn)
          end
        end

        # @return [Boolean]
        def self.yields_block?(fn)
          *, (last_arg,) =
            if fn.respond_to?(:parameters)
              fn.parameters
            else
              fn.method(:call).parameters
            end

          last_arg.equal?(:block)
        end

        include Dry::Equalizer(:fn, immutable: true)

        attr_reader :fn

        def initialize(fn)
          @fn = fn
        end

        # @return [Object]
        def call(input, &block)
          @fn.(input, &block)
        end
        alias_method :[], :call

        # @return [Array]
        def to_ast
          if fn.is_a?(::Proc)
            [:id, Dry::Types::FnContainer.register(fn)]
          else
            [:callable, fn]
          end
        end

        if RUBY_VERSION >= '2.6'
          # @return [Function]
          def >>(other)
            proc = other.is_a?(::Proc) ? other : other.fn
            Function[@fn >> proc]
          end

          # @return [Function]
          def <<(other)
            proc = other.is_a?(::Proc) ? other : other.fn
            Function[@fn << proc]
          end
        else
          # @return [Function]
          def >>(other)
            proc = other.is_a?(::Proc) ? other : other.fn
            Function[-> x { proc[@fn[x]] }]
          end

          # @return [Function]
          def <<(other)
            proc = other.is_a?(::Proc) ? other : other.fn
            Function[-> x { @fn[proc[x]] }]
          end
        end
      end
    end
  end
end