lib/rubocop/cop/style/mutable_constant.rb
# 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