lib/opal/nodes/call.rb



# frozen_string_literal: true

require 'set'
require 'pathname'
require 'opal/nodes/base'

module Opal
  module Nodes
    class CallNode < Base
      handle :send, :csend

      attr_reader :recvr, :meth, :arglist, :iter

      SPECIALS = {}

      # Operators that get optimized by compiler
      OPERATORS = { :+ => :plus, :- => :minus, :* => :times, :/ => :divide,
                    :< => :lt, :<= => :le, :> => :gt, :>= => :ge }.freeze

      def self.add_special(name, options = {}, &handler)
        SPECIALS[name] = options
        define_method("handle_#{name}", &handler)
      end

      def initialize(*)
        super
        @recvr, @meth, *args = *@sexp

        *rest, last_arg = *args

        if last_arg && %i[iter block_pass].include?(last_arg.type)
          @iter = last_arg
          args = rest
        else
          @iter = nil
        end

        @arglist = s(:arglist, *args)
      end

      def compile
        # handle some methods specially
        # some special methods need to skip compilation, so we pass the default as a block
        handle_special do
          compiler.record_method_call meth

          with_wrapper do
            if using_eval?
              # if trying to access an lvar in eval or irb mode
              compile_eval_var
            elsif using_irb?
              # if trying to access an lvar in irb mode
              compile_irb_var
            else
              default_compile
            end
          end
        end
      end

      private

      def iter_has_break?
        return false unless iter

        iter.meta[:has_break]
      end

      # Opal has a runtime helper 'Opal.send_method_name' that assigns
      # provided block to a '$$p' property of the method body
      # and invokes a method using 'apply'.
      #
      # We have to compile a method call using this 'Opal.send_method_name' when a method:
      # 1. takes a splat
      # 2. takes a block
      #
      # Arguments that contain splat must be handled in a different way.
      # @see #compile_arguments
      #
      # When a method takes a block we have to calculate all arguments
      # **before** assigning '$$p' property (that stores a passed block)
      # to a method body. This is some kind of protection from method calls
      # like 'a(a {}) { 1 }'.
      def invoke_using_send?
        iter || splat? || call_is_writer_that_needs_handling?
      end

      def invoke_using_refinement?
        !scope.scope.collect_refinements_temps.empty?
      end

      # Is it a conditional send, ie. `foo&.bar`?
      def csend?
        @sexp.type == :csend
      end

      def default_compile
        if auto_await?
          push '(await '
          scope.await_encountered = true
        end

        push_closure(Closure::SEND) if iter_has_break?
        if invoke_using_refinement?
          compile_using_refined_send
        elsif invoke_using_send?
          compile_using_send
        else
          compile_simple_call_chain
        end
        pop_closure if iter_has_break?

        if auto_await?
          push ')'
        end
      end

      # Compiles method call using `Opal.send`
      #
      # @example
      #   a.b(c, &block)
      #
      #   Opal.send(a, 'b', [c], block)
      #
      def compile_using_send
        helper :send

        push '$send('
        compile_receiver
        compile_method_name
        compile_arguments
        compile_block_pass
        push ')'
      end

      # Compiles method call using `Opal.refined_send`
      #
      # @example
      #   a.b(c, &block)
      #
      #   Opal.refined_send(a, 'b', [c], block, [[Opal.MyRefinements]])
      #
      def compile_using_refined_send
        helper :refined_send

        push '$refined_send('
        compile_refinements
        compile_receiver
        compile_method_name
        compile_arguments
        compile_block_pass
        push ')'
      end

      def compile_receiver
        push @conditional_recvr || recv(receiver_sexp)
      end

      def compile_method_name
        push ", '#{meth}'"
      end

      def compile_arguments(skip_comma = false)
        push ', ' unless skip_comma

        if @with_writer_temp
          push @with_writer_temp
        elsif splat?
          push expr(arglist)
        elsif arglist.children.empty?
          push '[]'
        else
          push '[', expr(arglist), ']'
        end
      end

      def compile_block_pass
        if iter
          push ', ', expr(iter)
        end
      end

      def compile_refinements
        refinements = scope.collect_refinements_temps.map { |i| s(:js_tmp, i) }
        push expr(s(:array, *refinements)), ', '
      end

      def compile_simple_call_chain
        compile_receiver
        push method_jsid, '(', expr(arglist), ')'
      end

      def splat?
        arglist.children.any? { |a| a.type == :splat }
      end

      def receiver_sexp
        recvr || s(:self)
      end

      def method_jsid
        mid_to_jsid meth.to_s
      end

      # Used to generate the code to use this sexp as an ivar var reference
      def compile_irb_var
        with_temp do |tmp|
          lvar = meth
          call = s(:send, s(:self), meth.intern, s(:arglist))
          ref = "(typeof #{lvar} !== 'undefined') ? #{lvar} : "
          push "((#{tmp} = Opal.irb_vars.#{lvar}) == null ? ", ref, expr(call), " : #{tmp})"
        end
      end

      def compile_eval_var
        push meth.to_s
      end

      # a variable reference in irb mode in top scope might be a var ref,
      # or it might be a method call
      def using_irb?
        @compiler.irb? && scope.top? && variable_like?
      end

      def using_eval?
        @compiler.eval? && scope.top? && @compiler.scope_variables.include?(meth)
      end

      def variable_like?
        arglist == s(:arglist) && recvr.nil? && iter.nil?
      end

      def sexp_with_arglist
        @sexp.updated(nil, [recvr, meth, arglist])
      end

      def auto_await?
        awaited_set = compiler.async_await

        awaited_set && awaited_set != true && awaited_set.match?(meth.to_s)
      end

      # Handle "special" method calls, e.g. require(). Subclasses can override
      # this method. If this method returns nil, then the method will continue
      # to be generated by CallNode.
      def handle_special(&compile_default)
        if SPECIALS.include? meth
          method = method("handle_#{meth}")
          method.arity == 1 ? method[compile_default] : method[]
        else
          yield # i.e. compile_default.call
        end
      end

      OPERATORS.each do |operator, name|
        add_special(operator.to_sym) do |compile_default|
          if invoke_using_refinement?
            compile_default.call
          elsif compiler.inline_operators?
            compiler.record_method_call operator
            helper :"rb_#{name}"
            lhs, rhs = expr(recvr), expr(arglist)

            push fragment("$rb_#{name}(")
            push lhs
            push fragment(', ')
            push rhs
            push fragment(')')
          else
            compile_default.call
          end
        end
      end

      add_special :require do |compile_default|
        str = DependencyResolver.new(compiler, arglist.children[0]).resolve
        compiler.requires << str unless str.nil?
        compile_default.call
      end

      add_special :require_relative do
        arg = arglist.children[0]
        file = compiler.file
        if arg.type == :str
          dir = File.dirname(file)
          compiler.requires << Pathname(dir).join(arg.children[0]).cleanpath.to_s
        end
        push fragment("#{scope.self}.$require(#{file.inspect}+ '/../' + ")
        push process(arglist)
        push fragment(')')
      end

      add_special :autoload do |compile_default|
        args = arglist.children
        if args.length == 2 && args[0].type == :sym
          str = DependencyResolver.new(compiler, args[1], :ignore).resolve
          if str.nil?
            compiler.warning "File for autoload of constant '#{args[0].children[0]}' could not be bundled!"
          else
            compiler.requires << str
            compiler.autoloads << str
          end
        end
        compile_default.call
      end

      add_special :require_tree do |compile_default|
        first_arg, *rest = *arglist.children
        if first_arg.type == :str
          relative_path = first_arg.children[0]
          compiler.required_trees << relative_path

          dir = File.dirname(compiler.file)
          full_path = Pathname(dir).join(relative_path).cleanpath.to_s
          full_path.force_encoding(relative_path.encoding)
          first_arg = first_arg.updated(nil, [full_path])
        end
        @arglist = arglist.updated(nil, [first_arg] + rest)
        compile_default.call
      end

      add_special :block_given? do
        push compiler.handle_block_given_call @sexp
      end

      add_special :__callee__ do
        if scope.def?
          push fragment scope.mid.to_s.inspect
        else
          push fragment 'nil'
        end
      end

      add_special :__method__ do
        if scope.def?
          push fragment scope.mid.to_s.inspect
        else
          push fragment 'nil'
        end
      end

      add_special :__dir__ do
        push File.dirname(Opal::Compiler.module_name(compiler.file)).inspect
      end

      # Refinements support
      add_special :using do |compile_default|
        if scope.accepts_using? && arglist.children.count == 1
          using_refinement(arglist.children.first)
        else
          compile_default.call
        end
      end

      def using_refinement(arg)
        prev, curr = *scope.refinements_temp
        if prev
          push "(#{curr} = #{prev}.slice(), #{curr}.push(", expr(arg), "), #{scope.self})"
        else
          push "(#{curr} = [", expr(arg), "], #{scope.self})"
        end
      end

      add_special :debugger do
        push fragment 'debugger'
      end

      add_special :__OPAL_COMPILER_CONFIG__ do
        push fragment "(new Map([['arity_check', #{compiler.arity_check?}]]))"
      end

      add_special :lambda do |compile_default|
        scope.defines_lambda do
          compile_default.call
        end
      end

      add_special :nesting do |compile_default|
        push_nesting = push_nesting?
        push "(Opal.Module.$$nesting = #{scope.nesting}, " if push_nesting
        compile_default.call
        push ')' if push_nesting
      end

      add_special :constants do |compile_default|
        push_nesting = push_nesting?
        push "(Opal.Module.$$nesting = #{scope.nesting}, " if push_nesting
        compile_default.call
        push ')' if push_nesting
      end

      # This can be refactored in terms of binding, but it would need 'corelib/binding'
      # to be required in existing code.
      add_special :eval do |compile_default|
        # Catch the return throw coming from eval
        thrower(:eval_return)

        next compile_default.call if arglist.children.length != 1 || ![s(:self), nil].include?(recvr)

        scope.nesting
        temp = scope.new_temp
        scope_variables = scope.scope_locals.map(&:to_s).inspect
        push "(#{temp} = ", expr(arglist)
        push ", typeof Opal.compile === 'function' ? eval(Opal.compile(#{temp}"
        push ', {scope_variables: ', scope_variables
        push ", arity_check: #{compiler.arity_check?}, file: '(eval)', eval: true})) : "
        push "#{scope.self}.$eval(#{temp}))"
      end

      add_special :local_variables do |compile_default|
        next compile_default.call unless [s(:self), nil].include?(recvr)

        scope_variables = scope.scope_locals.map(&:to_s).inspect
        push scope_variables
      end

      add_special :binding do |compile_default|
        next compile_default.call unless recvr.nil?

        scope.nesting
        push "Opal.Binding.$new("
        push "  function($code) {"
        push "    return eval($code);"
        push "  },"
        push "  ", scope.scope_locals.map(&:to_s).inspect, ","
        push "  ", scope.self, ","
        push "  ", source_location
        push ")"
      end

      add_special :__await__ do |compile_default|
        if compiler.async_await
          push fragment '(await ('
          push process(recvr)
          push fragment '))'
          scope.await_encountered = true
        else
          compile_default.call
        end
      end

      def push_nesting?
        recv = children.first

        children.size == 2 && (           # only receiver and method
          recv.nil? || (                  # and no receiver
            recv.type == :const &&        # or receiver
            recv.children.last == :Module # is Module
          )
        )
      end

      def with_wrapper(&block)
        if csend? && !@conditional_recvr
          handle_conditional_send do
            with_wrapper(&block)
          end
        elsif call_is_writer_that_needs_handling?
          handle_writer(&block)
        else
          yield
        end
      end

      def call_is_writer_that_needs_handling?
        (expr? || recv?) && (meth.to_s =~ /^\w+=$/ || meth == :[]=)
      end

      # Handle safe-operator calls: foo&.bar / foo&.bar ||= baz / ...
      def handle_conditional_send
        # temporary variable that stores method receiver
        receiver_temp = scope.new_temp
        push "#{receiver_temp} = ", expr(receiver_sexp)

        # execute the sexp only if the receiver isn't nil
        push ", (#{receiver_temp} === nil || #{receiver_temp} == null) ? nil : "
        @conditional_recvr = receiver_temp
        yield
        wrap '(', ')'
      end

      def handle_writer
        with_temp do |temp|
          push "(#{temp} = "
          compile_arguments(true)
          push ", "
          @with_writer_temp = temp
          yield
          @with_writer_temp = false
          push ", "
          push "#{temp}[#{temp}.length - 1])"
        end
      end

      class DependencyResolver
        def initialize(compiler, sexp, missing_dynamic_require = nil)
          @compiler = compiler
          @sexp = sexp
          @missing_dynamic_require = missing_dynamic_require || @compiler.dynamic_require_severity
        end

        def resolve
          handle_part @sexp
        end

        def handle_part(sexp, missing_dynamic_require = @missing_dynamic_require)
          if sexp
            case sexp.type
            when :str
              return sexp.children[0]
            when :dstr
              return sexp.children.map { |i| handle_part i }.join
            when :begin
              return handle_part sexp.children[0] if sexp.children.length == 1
            when :send
              recv, meth, *args = sexp.children

              parts = args.map { |s| handle_part(s, :ignore) }

              return nil if parts.include? nil

              if recv.is_a?(::Opal::AST::Node) && recv.type == :const && recv.children.last == :File
                if meth == :expand_path
                  return expand_path(*parts)
                elsif meth == :join
                  return expand_path parts.join('/')
                elsif meth == :dirname
                  return expand_path parts[0].split('/')[0...-1].join('/')
                end
              elsif meth == :__dir__
                return File.dirname(Opal::Compiler.module_name(@compiler.file))
              end
            end
          end

          case missing_dynamic_require
          when :error
            @compiler.error 'Cannot handle dynamic require', @sexp.line
          when :warning
            @compiler.warning 'Cannot handle dynamic require', @sexp.line
          end
        end

        def expand_path(path, base = '')
          "#{base}/#{path}".split('/').each_with_object([]) do |part, p|
            if part == ''
              # we had '//', so ignore
            elsif part == '..'
              p.pop
            else
              p << part
            end
          end.join '/'
        end
      end
    end
  end
end