rubocop-hq/rubocop

View on GitHub
lib/rubocop/cop/style/mutable_constant.rb

Summary

Maintainability
A
25 mins
Test Coverage
A
100%
# frozen_string_literal: true

module RuboCop
  module Cop
    module Style
      # Checks whether some constant value isn't a
      # mutable literal (e.g. array or hash).
      #
      # Strict mode can be used to freeze all constants, rather than
      # just literals.
      # Strict mode is considered an experimental feature. It has not been
      # updated with an exhaustive list of all methods that will produce
      # frozen objects so there is a decent chance of getting some false
      # positives. Luckily, there is no harm in freezing an already
      # frozen object.
      #
      # From Ruby 3.0, this cop honours the magic comment
      # 'shareable_constant_value'. When this magic comment is set to any
      # acceptable value other than none, it will suppress the offenses
      # raised by this cop. It enforces frozen state.
      #
      # NOTE: Regexp and Range literals are frozen objects since Ruby 3.0.
      #
      # NOTE: From Ruby 3.0, interpolated strings are not frozen when
      # `# frozen-string-literal: true` is used, so this cop enforces explicit
      # freezing for such strings.
      #
      # NOTE: From Ruby 3.0, this cop allows explicit freezing of constants when
      # the `shareable_constant_value` directive is used.
      #
      # @safety
      #   This cop's autocorrection is unsafe since any mutations on objects that
      #   are made frozen will change from being accepted to raising `FrozenError`,
      #   and will need to be manually refactored.
      #
      # @example EnforcedStyle: literals (default)
      #   # bad
      #   CONST = [1, 2, 3]
      #
      #   # good
      #   CONST = [1, 2, 3].freeze
      #
      #   # good
      #   CONST = <<~TESTING.freeze
      #     This is a heredoc
      #   TESTING
      #
      #   # good
      #   CONST = Something.new
      #
      #
      # @example EnforcedStyle: strict
      #   # bad
      #   CONST = Something.new
      #
      #   # bad
      #   CONST = Struct.new do
      #     def foo
      #       puts 1
      #     end
      #   end
      #
      #   # good
      #   CONST = Something.new.freeze
      #
      #   # good
      #   CONST = Struct.new do
      #     def foo
      #       puts 1
      #     end
      #   end.freeze
      #
      # @example
      #   # Magic comment - shareable_constant_value: literal
      #
      #   # bad
      #   CONST = [1, 2, 3]
      #
      #   # good
      #   # shareable_constant_value: literal
      #   CONST = [1, 2, 3]
      #
      class MutableConstant < Base
        # Handles magic comment shareable_constant_value with O(n ^ 2) complexity
        # n - number of lines in the source
        # Iterates over all lines before a CONSTANT
        # until it reaches shareable_constant_value
        module ShareableConstantValue
          module_function

          def recent_shareable_value?(node)
            shareable_constant_comment = magic_comment_in_scope node
            return false if shareable_constant_comment.nil?

            shareable_constant_value = MagicComment.parse(shareable_constant_comment)
                                                   .shareable_constant_value
            shareable_constant_value_enabled? shareable_constant_value
          end

          # Identifies the most recent magic comment with valid shareable constant values
          # that's in scope for this node
          def magic_comment_in_scope(node)
            processed_source_till_node(node).reverse_each.find do |line|
              MagicComment.parse(line).valid_shareable_constant_value?
            end
          end

          private

          def processed_source_till_node(node)
            processed_source.lines[0..(node.last_line - 1)]
          end

          def shareable_constant_value_enabled?(value)
            %w[literal experimental_everything experimental_copy].include? value
          end
        end
        private_constant :ShareableConstantValue

        include ShareableConstantValue
        include FrozenStringLiteral
        include ConfigurableEnforcedStyle
        extend AutoCorrector

        MSG = 'Freeze mutable objects assigned to constants.'

        def on_casgn(node)
          _scope, _const_name, value = *node
          if value.nil? # This is only the case for `CONST += ...` or similarg66
            parent = node.parent
            return unless parent.or_asgn_type? # We only care about `CONST ||= ...`

            value = parent.children.last
          end

          on_assignment(value)
        end

        private

        def on_assignment(value)
          if style == :strict
            strict_check(value)
          else
            check(value)
          end
        end

        def strict_check(value)
          return if immutable_literal?(value)
          return if operation_produces_immutable_object?(value)
          return if frozen_string_literal?(value)
          return if shareable_constant_value?(value)

          add_offense(value) { |corrector| autocorrect(corrector, value) }
        end

        def check(value)
          range_enclosed_in_parentheses = range_enclosed_in_parentheses?(value)
          return unless mutable_literal?(value) ||
                        (target_ruby_version <= 2.7 && range_enclosed_in_parentheses)

          return if frozen_string_literal?(value)
          return if shareable_constant_value?(value)

          add_offense(value) { |corrector| autocorrect(corrector, value) }
        end

        def autocorrect(corrector, node)
          expr = node.source_range

          splat_value = splat_value(node)
          if splat_value
            correct_splat_expansion(corrector, expr, splat_value)
          elsif node.array_type? && !node.bracketed?
            corrector.wrap(expr, '[', ']')
          elsif requires_parentheses?(node)
            corrector.wrap(expr, '(', ')')
          end

          corrector.insert_after(expr, '.freeze')
        end

        def mutable_literal?(value)
          return false if frozen_regexp_or_range_literals?(value)

          value.mutable_literal?
        end

        def immutable_literal?(node)
          frozen_regexp_or_range_literals?(node) || node.immutable_literal?
        end

        def shareable_constant_value?(node)
          return false if target_ruby_version < 3.0

          recent_shareable_value? node
        end

        def frozen_regexp_or_range_literals?(node)
          target_ruby_version >= 3.0 && (node.regexp_type? || node.range_type?)
        end

        def requires_parentheses?(node)
          node.range_type? || (node.send_type? && node.loc.dot.nil?)
        end

        def correct_splat_expansion(corrector, expr, splat_value)
          if range_enclosed_in_parentheses?(splat_value)
            corrector.replace(expr, "#{splat_value.source}.to_a")
          else
            corrector.replace(expr, "(#{splat_value.source}).to_a")
          end
        end

        # @!method splat_value(node)
        def_node_matcher :splat_value, <<~PATTERN
          (array (splat $_))
        PATTERN

        # Some of these patterns may not actually return an immutable object,
        # but we want to consider them immutable for this cop.
        # @!method operation_produces_immutable_object?(node)
        def_node_matcher :operation_produces_immutable_object?, <<~PATTERN
          {
            (const _ _)
            (send (const {nil? cbase} :Struct) :new ...)
            (block (send (const {nil? cbase} :Struct) :new ...) ...)
            (send _ :freeze)
            (send {float int} {:+ :- :* :** :/ :% :<<} _)
            (send _ {:+ :- :* :** :/ :%} {float int})
            (send _ {:== :=== :!= :<= :>= :< :>} _)
            (send (const {nil? cbase} :ENV) :[] _)
            (or (send (const {nil? cbase} :ENV) :[] _) _)
            (send _ {:count :length :size} ...)
            (block (send _ {:count :length :size} ...) ...)
          }
        PATTERN

        # @!method range_enclosed_in_parentheses?(node)
        def_node_matcher :range_enclosed_in_parentheses?, <<~PATTERN
          (begin ({irange erange} _ _))
        PATTERN
      end
    end
  end
end