123456789_123456789_123456789_123456789_123456789_

Class: RuboCop::Cop::Style::MutableConstant

Relationships & Source Files
Namespace Children
Modules:
Super Chains via Extension / Inclusion / Inheritance
Class Chain:
self, ::RuboCop::Cop::AutoCorrector, ::RuboCop::Cop::Base, ::RuboCop::ExcludeLimit, NodePattern::Macros, RuboCop::AST::Sexp
Instance Chain:
Inherits: RuboCop::Cop::Base
Defined in: lib/rubocop/cop/style/mutable_constant.rb

Overview

Checks whether some constant value isn’t a mutable literal (e.g. array or hash).

When the Recursive option is enabled, mutable literals nested inside arrays and hashes are also frozen, so an offense on the outermost unfrozen literal will autocorrect every nested mutable literal as well. When the outer literal already has .freeze appended, the cop descends into it and reports each outermost unfrozen literal underneath. The option is disabled by default to preserve existing behavior; opt in to get strict nested freezing.

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.

Examples:

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

Recursive: false (default)

# good - only the outer container needs to be frozen
CONST = [{ a: [], b: 'foo' }].freeze

Recursive: true

# bad - nested mutable literals must be frozen too
CONST = [{ a: [], b: 'foo' }].freeze

# good
CONST = [{ a: [].freeze, b: 'foo'.freeze }.freeze].freeze

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

# good - {Data.define} declares an immutable value type
CONST = Data.define(:foo, :bar)
# Magic comment - shareable_constant_value: literal

# bad
CONST = [1, 2, 3]

# good
# shareable_constant_value: literal
CONST = [1, 2, 3]

Cop Safety Information:

  • 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.

Constant Summary

::RuboCop::Cop::Base - Inherited

DEPARTMENT_SEVERITIES, EMPTY_OFFENSES, RESTRICT_ON_SEND

::RuboCop::Cop::FrozenStringLiteral - Included

FROZEN_STRING_LITERAL_ENABLED, FROZEN_STRING_LITERAL_TYPES_RUBY27

::RuboCop::Cop::ConfigurableEnforcedStyle - Included

SYMBOL_TO_STRING_CACHE

Class Attribute Summary

::RuboCop::Cop::AutoCorrector - Extended

::RuboCop::Cop::Base - Inherited

.gem_requirements, .lint?,
.support_autocorrect?

Returns if class supports autocorrect.

.support_multiple_source?

Override if your cop should be called repeatedly for multiple investigations Between calls to on_new_investigation and on_investigation_end, the result of processed_source will remain constant.

Class Method Summary

::RuboCop::Cop::Base - Inherited

.autocorrect_incompatible_with

List of cops that should not try to autocorrect at the same time as this cop.

.badge

Naming.

.callbacks_needed, .cop_name, .department,
.documentation_url

Returns a url to view this cops documentation online.

.exclude_from_registry

Call for abstract Cop classes.

.inherited,
.joining_forces

Override and return the Force class(es) you need to join.

.match?

Returns true if the cop name or the cop namespace matches any of the given names.

.new,
.requires_gem

Register a version requirement for the given gem name.

.restrict_on_send

Reserved for Commissioner.

::RuboCop::ExcludeLimit - Extended

exclude_limit

Sets up a configuration option to have an exclude limit tracked.

transform

Instance Attribute Summary

::RuboCop::Cop::ConfigurableEnforcedStyle - Included

::RuboCop::Cop::Base - Inherited

::RuboCop::Cop::AutocorrectLogic - Included

#autocorrect?, #autocorrect_enabled?, #autocorrect_requested?, #autocorrect_with_disable_uncorrectable?, #correctable?, #disable_uncorrectable?, #safe_autocorrect?,
#skipped_unsafe_correction_with_disable_uncorrectable?

Whether a correction exists but is skipped because it is unsafe in a safe autocorrect run, while --disable-uncorrectable asks for a todo comment on everything this run cannot correct.

#contextual_autocorrect_held_back?

When LSP is enabled or the --editor-mode option is on, it is considered as editing source code, and autocorrection with AutoCorrect: contextual will not be performed.

Instance Method Summary

::RuboCop::Cop::ConfigurableEnforcedStyle - Included

::RuboCop::Cop::FrozenStringLiteral - Included

ShareableConstantValue - Included

#magic_comment_in_scope

Identifies the most recent magic comment with valid shareable constant values that’s in scope for this node.

#processed_source_till_node, #recent_shareable_value?, #shareable_constant_value_enabled?

::RuboCop::Cop::Base - Inherited

#add_global_offense

Adds an offense that has no particular location.

#add_offense

Adds an offense on the specified range (or node with an expression) Unless that offense is disabled for this range, a corrector will be yielded to provide the cop the opportunity to autocorrect the offense.

#begin_investigation

Called before any investigation.

#callbacks_needed

 — grouped under the Commissioner heading above.

#cop_config

Configuration Helpers.

#cop_name, #excluded_file?,
#external_dependency_checksum

This method should be overridden when a cop’s behavior depends on state that lives outside of these locations:

#inspect,
#message

Gets called if no message is specified when calling add_offense or add_global_offense Cops are discouraged to override this; instead pass your message directly.

#name

Alias for Base#cop_name.

#offenses,
#on_investigation_end

Called after all on_…​

#on_new_investigation

Called before all on_…​

#on_other_file

Called instead of all on_…​

#parse

There should be very limited reasons for a Cop to do it’s own parsing.

#parser_engine,
#ready

Called between investigations.

#relevant_file?,
#target_gem_version

Returns the version of a gem in the target, by default its locked version (i.e. from Gemfile.lock or gems.locked).

#target_rails_version, #target_ruby_version, #annotate, #apply_correction, #attempt_correction,
#callback_argument

Reserved for Cop::Cop.

#complete_investigation

Called to complete an investigation.

#correct, #covering_disabled_range, #current_corrector,
#current_offense_locations

Reserved for Commissioner:

#current_offenses, #currently_disabled_lines, #custom_severity, #default_severity, #disable_uncorrectable, #enabled_line?,
#enabled_lines?

A multi-line offense is suppressed by a directive on any line of its range, not only its first line, matching the intuition that the directive is attached to the offending code.

#file_name_matches_any?, #find_message, #find_severity, #matches_absolute_include_pattern?, #new_offense, #range_for_original, #range_from_node_or_range,
#reset_investigation

Actually private methods.

#suppression_reason

The -- reason on the directive that suppresses offenses on this range, or nil when the directive carries none.

#use_corrector

::RuboCop::Cop::AutocorrectLogic - Included

::RuboCop::Cop::IgnoredNode - Included

Constructor Details

This class inherits a constructor from RuboCop::Cop::Base

Class Method Details

.autocorrect_incompatible_with

With Recursive: true, .freeze is appended to a nested literal. When the literal is the last element and spans multiple lines, the trailing comma lands between the literal and the .freeze appended in the same pass.

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 152

def self.autocorrect_incompatible_with
  [Style::HashAsLastArrayItem, Style::TrailingCommaInArrayLiteral, Style::TrailingCommaInHashLiteral]
end

Instance Attribute Details

#recursive? ⇒ Boolean (readonly, private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 303

def recursive?
  cop_config.fetch('Recursive', false)
end

Instance Method Details

#autocorrect(corrector, node) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 221

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

  splat_value = splat_value(node)
  if splat_value
    correct_splat_expansion(corrector, expr, splat_value)
    corrector.insert_after(expr, '.freeze')
    return
  end

  return expand_omitted_hash_value(corrector, node.parent) if omitted_hash_value?(node)

  wrap_for_freeze(corrector, node, expr)
  corrector.insert_after(expr, '.freeze')

  freeze_nested_literals(corrector, node) if recursive?
end

#correct_splat_expansion(corrector, expr, splat_value) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 349

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

#expand_omitted_hash_value(corrector, pair) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 243

def expand_omitted_hash_value(corrector, pair)
  key = pair.key.value

  corrector.replace(pair, "#{key}: #{key}.freeze")
end

#explicitly_frozen_literal?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 297

def explicitly_frozen_literal?(node)
  return false unless node.send_type? && node.method?(:freeze)

  node.receiver && mutable_literal?(node.receiver)
end

#freezable_nested_literal?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 272

def freezable_nested_literal?(node)
  return false if frozen_string_literal?(node)
  return false if shareable_constant_value?(node)

  mutable_literal?(node)
end

#freeze_nested_literals(corrector, node) (private)

Recursively freezes every nested mutable literal inside an array or hash literal. Already-frozen subtrees are not re-frozen, but their children are still inspected for unfrozen literals deeper down.

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 262

def freeze_nested_literals(corrector, node)
  literal_children(node).each do |child|
    if explicitly_frozen_literal?(child)
      freeze_nested_literals(corrector, child.receiver)
    elsif freezable_nested_literal?(child)
      autocorrect(corrector, child)
    end
  end
end

#frozen_regexp_or_range_literals?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 331

def frozen_regexp_or_range_literals?(node)
  target_ruby_version >= 3.0 && node.type?(:regexp, :range)
end

#immutable_literal?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 313

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

#literal_check(value) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 208

def literal_check(value)
  return unless mutable_or_unfrozen_range?(value)
  return if frozen_string_literal?(value)
  return if shareable_constant_value?(value)

  true
end

#literal_children(node) (private)

Returns the child literals of an array or hash node that may themselves need freezing. For hashes, both keys and values are included. Percent-literal arrays (e.g. %w(a b)) are skipped because .freeze cannot be appended to their contents. Splat elements are skipped because the splatted object is not stored in the array.

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 284

def literal_children(node)
  case node.type
  when :array
    return [] if node.percent_literal?

    node.children.reject(&:splat_type?)
  when :hash
    node.children.flat_map { |child| child.pair_type? ? child.children : [] }
  else
    []
  end
end

#mutable_literal?(value) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 307

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

  value.mutable_literal?
end

#mutable_nodes(value, &block) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 183

def mutable_nodes(value, &block)
  if recursive? && explicitly_frozen_literal?(value)
    literal_children(value.receiver).flat_map { |c| mutable_nodes(c, &block) }
  else
    node_offending = yield(value)

    if node_offending
      [value]
    else
      []
    end
  end
end

#mutable_or_unfrozen_range?(value) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 216

def mutable_or_unfrozen_range?(value)
  mutable_literal?(value) ||
    (target_ruby_version <= 2.7 && range_enclosed_in_parentheses?(value))
end

#omitted_hash_value?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 239

def omitted_hash_value?(node)
  node.parent&.pair_type? && node.parent.value_omission?
end

#on_assignment(value) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 169

def on_assignment(value)
  nodes = mutable_nodes(value) do |node|
    if style == :strict
      strict_check(node)
    else
      literal_check(node)
    end
  end

  nodes.each do |node|
    add_offense(node) { |corrector| autocorrect(corrector, node) }
  end
end

#on_casgn(node)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 156

def on_casgn(node)
  if node.expression.nil? # This is only the case for `CONST += ...` or similar
    parent = node.parent
    return unless parent.or_asgn_type? # We only care about `CONST ||= ...`

    on_assignment(parent.children.last)
  else
    on_assignment(node.expression)
  end
end

#operation_produces_immutable_object?(node) (private)

Some of these patterns may not actually return an immutable object, but we want to consider them immutable for this cop.

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 365

def_node_matcher :operation_produces_immutable_object?, <<~PATTERN
  {
    (const _ _)
    (send (const {nil? cbase} :Struct) :new ...)
    (block (send (const {nil? cbase} :Struct) :new ...) ...)
    (send (const {nil? cbase} :Data) :define ...)
    (block (send (const {nil? cbase} :Data) :define ...) ...)
    (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

#parenthesized_immutable_value?(value) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 323

def parenthesized_immutable_value?(value)
  return false if !value.begin_type? || !value.children.one?

  inner = value.children.first

  !inner.assignment? && !strict_check(inner)
end

#range_enclosed_in_parentheses?(node) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 384

def_node_matcher :range_enclosed_in_parentheses?, <<~PATTERN
  (begin (range _ _))
PATTERN

#requires_parentheses?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 335

def requires_parentheses?(node)
  if node.range_type? || node.operator_keyword? || unparenthesized_method_call?(node)
    return true
  end

  node.if_type? && node.ternary?
end

#shareable_constant_value?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 317

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

  recent_shareable_value? node
end

#splat_value(node) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 358

def_node_matcher :splat_value, <<~PATTERN
  (array (splat $_))
PATTERN

#strict_check(value) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 197

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

  true
end

#unparenthesized_method_call?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 343

def unparenthesized_method_call?(node)
  return false unless node.call_type?

  node.loc.dot.nil? || (node.arguments.any? && !node.parenthesized?)
end

#wrap_for_freeze(corrector, node, expr) (private)

[ GitHub ]

  
# File 'lib/rubocop/cop/style/mutable_constant.rb', line 249

def wrap_for_freeze(corrector, node, expr)
  if node.array_type? && !node.bracketed?
    corrector.wrap(expr, '[', ']')
  elsif node.hash_type? && !node.braces?
    corrector.wrap(expr, '{ ', ' }')
  elsif requires_parentheses?(node)
    corrector.wrap(expr, '(', ')')
  end
end