src/derails/abstract_controller/base.rb
# frozen_string_literal: true

# :markup: markdown

require "abstract_controller/error"
require "active_support/descendants_tracker"
require "active_support/core_ext/module/anonymous"
require "active_support/core_ext/module/attr_internal"

module AbstractController
  # Raised when a non-existing controller action is triggered.
  class ActionNotFound < StandardError
    attr_reader :controller, :action # :nodoc:

    def initialize(message = nil, controller = nil, action = nil) # :nodoc:
      @controller = controller
      @action = action
      super(message)
    end

    if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker)
      include DidYouMean::Correctable # :nodoc:

      def corrections # :nodoc:
        @corrections ||= DidYouMean::SpellChecker.new(dictionary: controller.class.action_methods).correct(action)
      end
    end
  end

  # # Abstract Controller Base
  #
  # AbstractController::Base is a low-level API. Nobody should be using it
  # directly, and subclasses (like ActionController::Base) are expected to provide
  # their own `render` method, since rendering means different things depending on
  # the context.
  class Base
    ##
    # Returns the body of the HTTP response sent by the controller.
    attr_internal :response_body

    ##
    # Returns the name of the action this controller is processing.
    attr_internal :action_name

    ##
    # Returns the formats that can be processed by the controller.
    attr_internal :formats

    class_attribute :config, instance_predicate: false, default: ActiveSupport::OrderedOptions.new
    extend ActiveSupport::DescendantsTracker

    class << self
      attr_reader :abstract
      alias_method :abstract?, :abstract

      # Define a controller as abstract. See internal_methods for more details.
      def abstract!
        @abstract = true
      end

      def inherited(klass) # :nodoc:
        # Define the abstract ivar on subclasses so that we don't get uninitialized ivar
        # warnings
        unless klass.instance_variable_defined?(:@abstract)
          klass.instance_variable_set(:@abstract, false)
        end
        klass.config = ActiveSupport::InheritableOptions.new(config)
        super
      end

      # A list of all internal methods for a controller. This finds the first abstract
      # superclass of a controller, and gets a list of all public instance methods on
      # that abstract class. Public instance methods of a controller would normally be
      # considered action methods, so methods declared on abstract classes are being
      # removed. (ActionController::Metal and ActionController::Base are defined as
      # abstract)
      def internal_methods
        controller = self
        methods = []

        until controller.abstract?
          methods += controller.public_instance_methods(false)
          controller = controller.superclass
        end

        controller.public_instance_methods(true) - methods
      end

      # A `Set` of method names that should be considered actions. This includes all
      # public instance methods on a controller, less any internal methods (see
      # internal_methods), adding back in any methods that are internal, but still
      # exist on the class itself.
      def action_methods
        @action_methods ||= begin
          # All public instance methods of this class, including ancestors except for
          # public instance methods of Base and its ancestors.
          methods = public_instance_methods(true) - internal_methods
          # Be sure to include shadowed public instance methods of this class.
          methods.concat(public_instance_methods(false))
          methods.map!(&:to_s)
          methods.to_set
        end
      end

      # action_methods are cached and there is sometimes a need to refresh them.
      # ::clear_action_methods! allows you to do that, so next time you run
      # action_methods, they will be recalculated.
      def clear_action_methods!
        @action_methods = nil
      end

      # Returns the full controller name, underscored, without the ending Controller.
      #
      #     class MyApp::MyPostsController < AbstractController::Base
      #
      #     end
      #
      #     MyApp::MyPostsController.controller_path # => "my_app/my_posts"
      #
      def controller_path
        @controller_path ||= name.delete_suffix("Controller").underscore unless anonymous?
      end

      def configure # :nodoc:
        yield config
      end

      # Refresh the cached action_methods when a new action_method is added.
      def method_added(name)
        super
        clear_action_methods!
      end

      def eager_load! # :nodoc:
        action_methods
        nil
      end
    end

    abstract!

    # Calls the action going through the entire Action Dispatch stack.
    #
    # The actual method that is called is determined by calling #method_for_action.
    # If no method can handle the action, then an AbstractController::ActionNotFound
    # error is raised.
    def process(action, ...)
      @_action_name = action.to_s

      unless action_name = _find_action_name(@_action_name)
        raise ActionNotFound.new("The action '#{action}' could not be found for #{self.class.name}", self, action)
      end

      @_response_body = nil

      process_action(action_name, ...)
    end

    # Delegates to the class's ::controller_path.
    def controller_path
      self.class.controller_path
    end

    # Delegates to the class's ::action_methods.
    def action_methods
      self.class.action_methods
    end

    # Returns true if a method for the action is available and can be dispatched,
    # false otherwise.
    #
    # Notice that `action_methods.include?("foo")` may return false and
    # `available_action?("foo")` returns true because this method considers actions
    # that are also available through other means, for example, implicit render
    # ones.
    #
    # #### Parameters
    # *   `action_name` - The name of an action to be tested
    #
    def available_action?(action_name)
      _find_action_name(action_name)
    end

    # Tests if a response body is set. Used to determine if the `process_action`
    # callback needs to be terminated in AbstractController::Callbacks.
    def performed?
      response_body
    end

    # Returns true if the given controller is capable of rendering a path. A
    # subclass of `AbstractController::Base` may return false. An Email controller
    # for example does not support paths, only full URLs.
    def self.supports_path?
      true
    end

    def config # :nodoc:
      @_config ||= self.class.config.inheritable_copy
    end

    def inspect # :nodoc:
      "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
    end

    private
      # Returns true if the name can be considered an action because it has a method
      # defined in the controller.
      #
      # #### Parameters
      # *   `name` - The name of an action to be tested
      #
      def action_method?(name)
        self.class.action_methods.include?(name)
      end

      # Call the action. Override this in a subclass to modify the behavior around
      # processing an action. This, and not #process, is the intended way to override
      # action dispatching.
      #
      # Notice that the first argument is the method to be dispatched which is **not**
      # necessarily the same as the action name.
      def process_action(...)
        send_action(...)
      end

      # Actually call the method associated with the action. Override this method if
      # you wish to change how action methods are called, not to add additional
      # behavior around it. For example, you would override #send_action if you want
      # to inject arguments into the method.
      alias send_action send

      # If the action name was not found, but a method called "action_missing" was
      # found, #method_for_action will return "_handle_action_missing". This method
      # calls #action_missing with the current action name.
      def _handle_action_missing(*args)
        action_missing(@_action_name, *args)
      end

      # Takes an action name and returns the name of the method that will handle the
      # action.
      #
      # It checks if the action name is valid and returns false otherwise.
      #
      # See method_for_action for more information.
      #
      # #### Parameters
      # *   `action_name` - An action name to find a method name for
      #
      #
      # #### Returns
      # *   `string` - The name of the method that handles the action
      # *   false           - No valid method name could be found.
      #
      # Raise `AbstractController::ActionNotFound`.
      def _find_action_name(action_name)
        _valid_action_name?(action_name) && method_for_action(action_name)
      end

      # Takes an action name and returns the name of the method that will handle the
      # action. In normal cases, this method returns the same name as it receives. By
      # default, if #method_for_action receives a name that is not an action, it will
      # look for an #action_missing method and return "_handle_action_missing" if one
      # is found.
      #
      # Subclasses may override this method to add additional conditions that should
      # be considered an action. For instance, an HTTP controller with a template
      # matching the action name is considered to exist.
      #
      # If you override this method to handle additional cases, you may also provide a
      # method (like `_handle_method_missing`) to handle the case.
      #
      # If none of these conditions are true, and `method_for_action` returns `nil`,
      # an `AbstractController::ActionNotFound` exception will be raised.
      #
      # #### Parameters
      # *   `action_name` - An action name to find a method name for
      #
      #
      # #### Returns
      # *   `string` - The name of the method that handles the action
      # *   `nil`    - No method name could be found.
      #
      def method_for_action(action_name)
        if action_method?(action_name)
          action_name
        elsif respond_to?(:action_missing, true)
          "_handle_action_missing"
        end
      end

      # Checks if the action name is valid and returns false otherwise.
      def _valid_action_name?(action_name)
        !action_name.to_s.include? File::SEPARATOR
      end
  end
end
