lib/rubocop/cop/rails/file_path.rb



# frozen_string_literal: true

module RuboCop
  module Cop
    module Rails
      # Identifies usages of file path joining process to use `Rails.root.join` clause.
      # It is used to add uniformity when joining paths.
      #
      # @example EnforcedStyle: slashes (default)
      #   # bad
      #   Rails.root.join('app', 'models', 'goober')
      #
      #   # good
      #   Rails.root.join('app/models/goober')
      #
      #   # bad
      #   File.join(Rails.root, 'app/models/goober')
      #   "#{Rails.root}/app/models/goober"
      #
      #   # good
      #   Rails.root.join('app/models/goober').to_s
      #
      # @example EnforcedStyle: arguments
      #   # bad
      #   Rails.root.join('app/models/goober')
      #
      #   # good
      #   Rails.root.join('app', 'models', 'goober')
      #
      #   # bad
      #   File.join(Rails.root, 'app/models/goober')
      #   "#{Rails.root}/app/models/goober"
      #
      #   # good
      #   Rails.root.join('app', 'models', 'goober').to_s
      #
      class FilePath < Base
        extend AutoCorrector

        include ConfigurableEnforcedStyle
        include RangeHelp

        MSG_SLASHES = 'Prefer `Rails.root.join(\'path/to\')%<to_s>s`.'
        MSG_ARGUMENTS = 'Prefer `Rails.root.join(\'path\', \'to\')%<to_s>s`.'
        RESTRICT_ON_SEND = %i[join].freeze

        def_node_matcher :file_join_nodes?, <<~PATTERN
          (send (const {nil? cbase} :File) :join ...)
        PATTERN

        def_node_search :rails_root_nodes?, <<~PATTERN
          (send (const {nil? cbase} :Rails) :root)
        PATTERN

        def_node_matcher :rails_root_join_nodes?, <<~PATTERN
          (send #rails_root_nodes? :join ...)
        PATTERN

        def on_dstr(node)
          return unless rails_root_nodes?(node)
          return if dstr_separated_by_colon?(node)

          check_for_slash_after_rails_root_in_dstr(node)
          check_for_extension_after_rails_root_join_in_dstr(node)
        end

        def on_send(node)
          check_for_file_join_with_rails_root(node)
          check_for_rails_root_join_with_slash_separated_path(node)
          check_for_rails_root_join_with_string_arguments(node)
        end

        private

        def check_for_slash_after_rails_root_in_dstr(node)
          rails_root_index = find_rails_root_index(node)
          slash_node = node.children[rails_root_index + 1]
          return unless slash_node&.str_type? && slash_node.source.start_with?(File::SEPARATOR)

          register_offense(node, require_to_s: false) do |corrector|
            autocorrect_slash_after_rails_root_in_dstr(corrector, node, rails_root_index)
          end
        end

        def check_for_extension_after_rails_root_join_in_dstr(node)
          rails_root_index = find_rails_root_index(node)
          extension_node = node.children[rails_root_index + 1]
          return unless extension_node?(extension_node)

          register_offense(node, require_to_s: false) do |corrector|
            autocorrect_extension_after_rails_root_join_in_dstr(corrector, node, rails_root_index, extension_node)
          end
        end

        def check_for_file_join_with_rails_root(node)
          return unless file_join_nodes?(node)
          return unless node.arguments.any? { |e| rails_root_nodes?(e) }

          register_offense(node, require_to_s: true) do |corrector|
            autocorrect_file_join(corrector, node)
          end
        end

        def check_for_rails_root_join_with_string_arguments(node)
          return unless style == :slashes
          return unless rails_root_nodes?(node)
          return unless rails_root_join_nodes?(node)
          return unless node.arguments.size > 1
          return unless node.arguments.all?(&:str_type?)

          register_offense(node, require_to_s: false) do |corrector|
            autocorrect_rails_root_join_with_string_arguments(corrector, node)
          end
        end

        def check_for_rails_root_join_with_slash_separated_path(node)
          return unless style == :arguments
          return unless rails_root_nodes?(node)
          return unless rails_root_join_nodes?(node)
          return unless node.arguments.any? { |arg| string_with_slash?(arg) }

          register_offense(node, require_to_s: false) do |corrector|
            autocorrect_rails_root_join_with_slash_separated_path(corrector, node)
          end
        end

        def string_with_slash?(node)
          node.str_type? && node.source.include?(File::SEPARATOR)
        end

        def register_offense(node, require_to_s:, &block)
          line_range = node.loc.column...node.loc.last_column
          source_range = source_range(processed_source.buffer, node.first_line, line_range)

          message = build_message(require_to_s)

          add_offense(source_range, message: message, &block)
        end

        def build_message(require_to_s)
          message_template = style == :arguments ? MSG_ARGUMENTS : MSG_SLASHES
          to_s = require_to_s ? '.to_s' : ''

          format(message_template, to_s: to_s)
        end

        def dstr_separated_by_colon?(node)
          node.children[1..].any? do |child|
            child.str_type? && child.source.start_with?(':')
          end
        end

        def autocorrect_slash_after_rails_root_in_dstr(corrector, node, rails_root_index)
          rails_root_node = node.children[rails_root_index].children.first
          argument_source = extract_rails_root_join_argument_source(node, rails_root_index)
          if rails_root_node.method?(:join)
            append_argument(corrector, rails_root_node, argument_source)
          else
            replace_with_rails_root_join(corrector, rails_root_node, argument_source)
          end
          node.children[rails_root_index + 1..].each { |child| corrector.remove(child) }
        end

        def autocorrect_extension_after_rails_root_join_in_dstr(corrector, node, rails_root_index, extension_node)
          rails_root_node = node.children[rails_root_index].children.first
          return unless rails_root_node.last_argument.str_type?

          corrector.insert_before(rails_root_node.last_argument.location.end, extension_node.source)
          corrector.remove(extension_node)
        end

        def autocorrect_file_join(corrector, node)
          corrector.replace(node.receiver, 'Rails.root')
          corrector.remove(
            range_with_surrounding_space(
              range_with_surrounding_comma(
                node.first_argument.source_range,
                :right
              ),
              side: :right
            )
          )
          node.arguments.filter(&:str_type?).each do |argument|
            corrector.replace(argument, argument.value.delete_prefix('/').inspect)
          end
          corrector.insert_after(node, '.to_s')
        end

        def autocorrect_rails_root_join_with_string_arguments(corrector, node)
          corrector.replace(node.first_argument, %("#{node.arguments.map(&:value).join('/')}"))
          node.arguments[1..].each do |argument|
            corrector.remove(
              range_with_surrounding_comma(
                range_with_surrounding_space(
                  argument.source_range,
                  side: :left
                ),
                :left
              )
            )
          end
        end

        def autocorrect_rails_root_join_with_slash_separated_path(corrector, node)
          node.arguments.each do |argument|
            next unless string_with_slash?(argument)

            index = argument.source.index(File::SEPARATOR)
            rest = inner_range_of(argument).adjust(begin_pos: index - 1)
            corrector.remove(rest)
            corrector.insert_after(argument, %(, "#{rest.source.delete_prefix(File::SEPARATOR)}"))
          end
        end

        def inner_range_of(node)
          node.location.end.with(begin_pos: node.location.begin.end_pos).adjust(end_pos: -1)
        end

        def find_rails_root_index(node)
          node.children.index { |child| rails_root_nodes?(child) }
        end

        def append_argument(corrector, node, argument_source)
          corrector.insert_after(node.last_argument, %(, "#{argument_source}"))
        end

        def replace_with_rails_root_join(corrector, node, argument_source)
          corrector.replace(node, %<Rails.root.join("#{argument_source}")>)
        end

        def extract_rails_root_join_argument_source(node, rails_root_index)
          node.children[rails_root_index + 1..].map(&:source).join.delete_prefix(File::SEPARATOR)
        end

        def extension_node?(node)
          node&.str_type? && node.source.match?(/\A\.[A-Za-z]+/)
        end
      end
    end
  end
end