lib/rubocop/cop/rails/lexically_scoped_action_filter.rb



# frozen_string_literal: true

module RuboCop
  module Cop
    module Rails
      # This cop checks that methods specified in the filter's `only` or
      # `except` options are defined within the same class or module.
      #
      # You can technically specify methods of superclass or methods added by
      # mixins on the filter, but these can confuse developers. If you specify
      # methods that are defined in other classes or modules, you should
      # define the filter in that class or module.
      #
      # If you rely on behaviour defined in the superclass actions, you must
      # remember to invoke `super` in the subclass actions.
      #
      # @example
      #   # bad
      #   class LoginController < ApplicationController
      #     before_action :require_login, only: %i[index settings logout]
      #
      #     def index
      #     end
      #   end
      #
      #   # good
      #   class LoginController < ApplicationController
      #     before_action :require_login, only: %i[index settings logout]
      #
      #     def index
      #     end
      #
      #     def settings
      #     end
      #
      #     def logout
      #     end
      #   end
      #
      # @example
      #   # bad
      #   module FooMixin
      #     extend ActiveSupport::Concern
      #
      #     included do
      #       before_action proc { authenticate }, only: :foo
      #     end
      #   end
      #
      #   # good
      #   module FooMixin
      #     extend ActiveSupport::Concern
      #
      #     included do
      #       before_action proc { authenticate }, only: :foo
      #     end
      #
      #     def foo
      #       # something
      #     end
      #   end
      #
      # @example
      #   class ContentController < ApplicationController
      #     def update
      #       @content.update(content_attributes)
      #     end
      #   end
      #
      #   class ArticlesController < ContentController
      #     before_action :load_article, only: [:update]
      #
      #     # the cop requires this method, but it relies on behaviour defined
      #     # in the superclass, so needs to invoke `super`
      #     def update
      #       super
      #     end
      #
      #     private
      #
      #     def load_article
      #       @content = Article.find(params[:article_id])
      #     end
      #   end
      class LexicallyScopedActionFilter < Base
        MSG = '%<action>s not explicitly defined on the %<type>s.'

        RESTRICT_ON_SEND = %i[
          after_action
          append_after_action
          append_around_action
          append_before_action
          around_action
          before_action
          prepend_after_action
          prepend_around_action
          prepend_before_action
          skip_after_action
          skip_around_action
          skip_before_action
          skip_action_callback
        ].freeze

        FILTERS = RESTRICT_ON_SEND.map { |method_name| ":#{method_name}" }

        def_node_matcher :only_or_except_filter_methods, <<~PATTERN
          (send
            nil?
            {#{FILTERS.join(' ')}}
            _
            (hash
              (pair
                (sym {:only :except})
                $_)))
        PATTERN

        def on_send(node)
          methods_node = only_or_except_filter_methods(node)
          return unless methods_node

          parent = node.each_ancestor(:class, :module).first
          return unless parent

          block = parent.each_child_node(:begin).first
          return unless block

          defined_action_methods = defined_action_methods(block)

          methods = array_values(methods_node).reject do |method|
            defined_action_methods.include?(method)
          end

          message = message(methods, parent)
          add_offense(node, message: message) unless methods.empty?
        end

        private

        def defined_action_methods(block)
          defined_methods = block.each_child_node(:def).map(&:method_name)

          defined_methods + aliased_action_methods(block, defined_methods)
        end

        def aliased_action_methods(node, defined_methods)
          alias_methods = node.each_child_node(:send).select { |send_node| send_node.method?(:alias_method) }

          hash_of_alias_methods = alias_methods.each_with_object({}) do |alias_method, result|
            result[alias_method.last_argument.value] = alias_method.first_argument.value
          end

          defined_methods.each_with_object([]) do |defined_method, aliased_method|
            if (new_method_name = hash_of_alias_methods[defined_method])
              aliased_method << new_method_name
            end
          end
        end

        # @param node [RuboCop::AST::Node]
        # @return [Array<Symbol>]
        def array_values(node) # rubocop:disable Metrics/MethodLength
          case node.type
          when :str
            [node.str_content.to_sym]
          when :sym
            [node.value]
          when :array
            node.values.map do |v|
              case v.type
              when :str
                v.str_content.to_sym
              when :sym
                v.value
              end
            end.compact
          else
            []
          end
        end

        # @param methods [Array<String>]
        # @param parent [RuboCop::AST::Node]
        # @return [String]
        def message(methods, parent)
          if methods.size == 1
            format(MSG,
                   action: "`#{methods[0]}` is",
                   type: parent.type)
          else
            format(MSG,
                   action: "`#{methods.join('`, `')}` are",
                   type: parent.type)
          end
        end
      end
    end
  end
end