lib/tapioca/runtime/dynamic_mixin_compiler.rb



# typed: strict
# frozen_string_literal: true

module Tapioca
  module Runtime
    class DynamicMixinCompiler
      extend T::Sig
      include Runtime::Reflection

      #: Array[Module]
      attr_reader :dynamic_extends, :dynamic_includes

      #: Array[Symbol]
      attr_reader :class_attribute_readers, :class_attribute_writers, :class_attribute_predicates

      #: Array[Symbol]
      attr_reader :instance_attribute_readers, :instance_attribute_writers, :instance_attribute_predicates

      #: (Module constant) -> void
      def initialize(constant)
        @constant = constant
        mixins_from_modules = {}.compare_by_identity
        class_attribute_readers = [] #: Array[Symbol]
        class_attribute_writers = [] #: Array[Symbol]
        class_attribute_predicates = [] #: Array[Symbol]

        instance_attribute_readers = [] #: Array[Symbol]
        instance_attribute_writers = [] #: Array[Symbol]
        instance_attribute_predicates = [] #: Array[Symbol]

        Class.new do
          # Override the `self.include` method
          define_singleton_method(:include) do |mod|
            # Take a snapshot of the list of singleton class ancestors
            # before the actual include
            before = singleton_class.ancestors
            # Call the actual `include` method with the supplied module
            ::Tapioca::Runtime::Trackers::Mixin.with_disabled_registration do
              super(mod).tap do
                # Take a snapshot of the list of singleton class ancestors
                # after the actual include
                after = singleton_class.ancestors
                # The difference is the modules that are added to the list
                # of ancestors of the singleton class. Those are all the
                # modules that were `extend`ed due to the `include` call.
                #
                # We record those modules on our lookup table keyed by
                # the included module with the values being all the modules
                # that that module pulls into the singleton class.
                #
                # We need to reverse the order, since the extend order should
                # be the inverse of the ancestor order. That is, earlier
                # extended modules would be later in the ancestor chain.
                mixins_from_modules[mod] = (after - before).reverse!
              end
            end
          rescue Exception # rubocop:disable Lint/RescueException
            # this is a best effort, bail if we can't perform this
          end

          define_singleton_method(:class_attribute) do |*attrs, **kwargs|
            class_attribute_readers.concat(attrs)
            class_attribute_writers.concat(attrs)

            instance_predicate = kwargs.fetch(:instance_predicate, true)
            instance_accessor = kwargs.fetch(:instance_accessor, true)
            instance_reader = kwargs.fetch(:instance_reader, instance_accessor)
            instance_writer = kwargs.fetch(:instance_writer, instance_accessor)

            if instance_reader
              instance_attribute_readers.concat(attrs)
            end

            if instance_writer
              instance_attribute_writers.concat(attrs)
            end

            if instance_predicate
              class_attribute_predicates.concat(attrs)

              if instance_reader
                instance_attribute_predicates.concat(attrs)
              end
            end

            super(*attrs, **kwargs) if defined?(super)
          end

          # rubocop:disable Style/MissingRespondToMissing
          T::Sig::WithoutRuntime.sig { params(symbol: Symbol, args: T.untyped).returns(T.untyped) }
          def method_missing(symbol, *args)
            # We need this here so that we can handle any random instance
            # method calls on the fake including class that may be done by
            # the included module during the `self.included` hook.
          end

          class << self
            extend T::Sig

            T::Sig::WithoutRuntime.sig { params(symbol: Symbol, args: T.untyped).returns(T.untyped) }
            def method_missing(symbol, *args)
              # Similarly, we need this here so that we can handle any
              # random class method calls on the fake including class
              # that may be done by the included module during the
              # `self.included` hook.
            end
          end
          # rubocop:enable Style/MissingRespondToMissing
        end.include(constant)

        # The value that corresponds to the original included constant
        # is the list of all dynamically extended modules because of that
        # constant. We grab that value by deleting the key for the original
        # constant.
        @dynamic_extends = mixins_from_modules.delete(constant) || [] #: Array[Module]

        # Since we deleted the original constant from the list of keys, all
        # the keys that remain are the ones that are dynamically included modules
        # during the include of the original constant.
        @dynamic_includes = mixins_from_modules.keys #: Array[Module]

        @class_attribute_readers = class_attribute_readers #: Array[Symbol]
        @class_attribute_writers = class_attribute_writers #: Array[Symbol]
        @class_attribute_predicates = class_attribute_predicates #: Array[Symbol]

        @instance_attribute_readers = instance_attribute_readers #: Array[Symbol]
        @instance_attribute_writers = instance_attribute_writers #: Array[Symbol]
        @instance_attribute_predicates = instance_attribute_predicates #: Array[Symbol]
      end

      #: -> bool
      def empty_attributes?
        @class_attribute_readers.empty? && @class_attribute_writers.empty?
      end

      #: (RBI::Tree tree) -> void
      def compile_class_attributes(tree)
        return if empty_attributes?

        # Create a synthetic module to hold the generated class methods
        tree << RBI::Module.new("GeneratedClassMethods") do |mod|
          class_attribute_readers.each do |attribute|
            mod << RBI::Method.new(attribute.to_s)
          end

          class_attribute_writers.each do |attribute|
            mod << RBI::Method.new("#{attribute}=") do |method|
              method << RBI::ReqParam.new("value")
            end
          end

          class_attribute_predicates.each do |attribute|
            mod << RBI::Method.new("#{attribute}?")
          end
        end

        # Create a synthetic module to hold the generated instance methods
        tree << RBI::Module.new("GeneratedInstanceMethods") do |mod|
          instance_attribute_readers.each do |attribute|
            mod << RBI::Method.new(attribute.to_s)
          end

          instance_attribute_writers.each do |attribute|
            mod << RBI::Method.new("#{attribute}=") do |method|
              method << RBI::ReqParam.new("value")
            end
          end

          instance_attribute_predicates.each do |attribute|
            mod << RBI::Method.new("#{attribute}?")
          end
        end

        # Add a mixes_in_class_methods and include for the generated modules
        tree << RBI::MixesInClassMethods.new("GeneratedClassMethods")
        tree << RBI::Include.new("GeneratedInstanceMethods")
      end

      #: (RBI::Tree tree) -> [Array[Module], Array[Module]]
      def compile_mixes_in_class_methods(tree)
        includes = dynamic_includes.filter_map do |mod|
          qname = qualified_name_of(mod)

          next if qname.nil? || qname.empty?
          next if filtered_mixin?(qname)

          tree << RBI::Include.new(qname)

          mod
        end

        # If we can generate multiple mixes_in_class_methods, then we want to use all dynamic extends that are not the
        # constant itself
        mixed_in_class_methods = dynamic_extends.select do |mod|
          mod != @constant && !module_included_by_another_dynamic_extend?(mod, dynamic_extends)
        end

        return [[], []] if mixed_in_class_methods.empty?

        mixed_in_class_methods.each do |mod|
          qualified_name = qualified_name_of(mod)

          next if qualified_name.nil? || qualified_name.empty?
          next if filtered_mixin?(qualified_name)

          tree << RBI::MixesInClassMethods.new(qualified_name)
        end

        [mixed_in_class_methods, includes]
      rescue
        [[], []] # silence errors
      end

      #: (Module mod, Array[Module] dynamic_extends) -> bool
      def module_included_by_another_dynamic_extend?(mod, dynamic_extends)
        dynamic_extends.any? do |dynamic_extend|
          mod != dynamic_extend && ancestors_of(dynamic_extend).include?(mod)
        end
      end

      #: (String qualified_mixin_name) -> bool
      def filtered_mixin?(qualified_mixin_name)
        # filter T:: namespace mixins that aren't T::Props
        # T::Props and subconstants have semantic value
        qualified_mixin_name.start_with?("::T::") && !qualified_mixin_name.start_with?("::T::Props")
      end
    end
  end
end