lib/rubocop/cop/naming/memoized_instance_variable_name.rb
# frozen_string_literal: true
module RuboCop
module Cop
module Naming
# Checks for memoized methods whose instance variable name
# does not match the method name. Applies to both regular methods
# (defined with `def`) and dynamic methods (defined with
# `define_method` or `define_singleton_method`).
#
# This cop can be configured with the EnforcedStyleForLeadingUnderscores
# directive. It can be configured to allow for memoized instance variables
# prefixed with an underscore. Prefixing ivars with an underscore is a
# convention that is used to implicitly indicate that an ivar should not
# be set or referenced outside of the memoization method.
#
# @safety
# This cop relies on the pattern `@instance_var ||= ...`,
# but this is sometimes used for other purposes than memoization
# so this cop is considered unsafe. Also, its autocorrection is unsafe
# because it may conflict with instance variable names already in use.
#
# @example EnforcedStyleForLeadingUnderscores: disallowed (default)
# # bad
# # Method foo is memoized using an instance variable that is
# # not `@foo`. This can cause confusion and bugs.
# def foo
# @something ||= calculate_expensive_thing
# end
#
# def foo
# return @something if defined?(@something)
# @something = calculate_expensive_thing
# end
#
# # good
# def _foo
# @foo ||= calculate_expensive_thing
# end
#
# # good
# def foo
# @foo ||= calculate_expensive_thing
# end
#
# # good
# def foo
# @foo ||= begin
# calculate_expensive_thing
# end
# end
#
# # good
# def foo
# helper_variable = something_we_need_to_calculate_foo
# @foo ||= calculate_expensive_thing(helper_variable)
# end
#
# # good
# define_method(:foo) do
# @foo ||= calculate_expensive_thing
# end
#
# # good
# define_method(:foo) do
# return @foo if defined?(@foo)
# @foo = calculate_expensive_thing
# end
#
# @example EnforcedStyleForLeadingUnderscores: required
# # bad
# def foo
# @something ||= calculate_expensive_thing
# end
#
# # bad
# def foo
# @foo ||= calculate_expensive_thing
# end
#
# def foo
# return @foo if defined?(@foo)
# @foo = calculate_expensive_thing
# end
#
# # good
# def foo
# @_foo ||= calculate_expensive_thing
# end
#
# # good
# def _foo
# @_foo ||= calculate_expensive_thing
# end
#
# def foo
# return @_foo if defined?(@_foo)
# @_foo = calculate_expensive_thing
# end
#
# # good
# define_method(:foo) do
# @_foo ||= calculate_expensive_thing
# end
#
# # good
# define_method(:foo) do
# return @_foo if defined?(@_foo)
# @_foo = calculate_expensive_thing
# end
#
# @example EnforcedStyleForLeadingUnderscores :optional
# # bad
# def foo
# @something ||= calculate_expensive_thing
# end
#
# # good
# def foo
# @foo ||= calculate_expensive_thing
# end
#
# # good
# def foo
# @_foo ||= calculate_expensive_thing
# end
#
# # good
# def _foo
# @_foo ||= calculate_expensive_thing
# end
#
# # good
# def foo
# return @_foo if defined?(@_foo)
# @_foo = calculate_expensive_thing
# end
#
# # good
# define_method(:foo) do
# @foo ||= calculate_expensive_thing
# end
#
# # good
# define_method(:foo) do
# @_foo ||= calculate_expensive_thing
# end
class MemoizedInstanceVariableName < Base
extend AutoCorrector
include ConfigurableEnforcedStyle
MSG = 'Memoized variable `%<var>s` does not match ' \
'method name `%<method>s`. Use `@%<suggested_var>s` instead.'
UNDERSCORE_REQUIRED = 'Memoized variable `%<var>s` does not start ' \
'with `_`. Use `@%<suggested_var>s` instead.'
DYNAMIC_DEFINE_METHODS = %i[define_method define_singleton_method].to_set.freeze
# @!method method_definition?(node)
def_node_matcher :method_definition?, <<~PATTERN
${
(block (send _ %DYNAMIC_DEFINE_METHODS ({sym str} $_)) ...)
(def $_ ...)
(defs _ $_ ...)
}
PATTERN
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def on_or_asgn(node)
lhs, _value = *node
return unless lhs.ivasgn_type?
method_node, method_name = find_definition(node)
return unless method_node
body = method_node.body
return unless body == node || body.children.last == node
return if matches?(method_name, lhs)
suggested_var = suggested_var(method_name)
msg = format(
message(lhs.children.first.to_s),
var: lhs.children.first.to_s,
suggested_var: suggested_var,
method: method_name
)
add_offense(lhs, message: msg) do |corrector|
corrector.replace(lhs.loc.name, "@#{suggested_var}")
end
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize
# @!method defined_memoized?(node, ivar)
def_node_matcher :defined_memoized?, <<~PATTERN
(begin
(if (defined $(ivar %1)) (return $(ivar %1)) nil?)
...
$(ivasgn %1 _))
PATTERN
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def on_defined?(node)
arg = node.first_argument
return false unless arg.ivar_type?
method_node, method_name = find_definition(node)
return false unless method_node
var_name = arg.children.first
defined_memoized?(method_node.body, var_name) do |defined_ivar, return_ivar, ivar_assign|
return false if matches?(method_name, ivar_assign)
suggested_var = suggested_var(method_name)
msg = format(
message(var_name.to_s),
var: var_name.to_s,
suggested_var: suggested_var,
method: method_name
)
add_offense(defined_ivar, message: msg) do |corrector|
corrector.replace(defined_ivar, "@#{suggested_var}")
end
add_offense(return_ivar, message: msg) do |corrector|
corrector.replace(return_ivar, "@#{suggested_var}")
end
add_offense(ivar_assign.loc.name, message: msg) do |corrector|
corrector.replace(ivar_assign.loc.name, "@#{suggested_var}")
end
end
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def style_parameter_name
'EnforcedStyleForLeadingUnderscores'
end
def find_definition(node)
# Methods can be defined in a `def` or `defs`,
# or dynamically via a `block` node.
node.each_ancestor(:def, :defs, :block).each do |ancestor|
method_node, method_name = method_definition?(ancestor)
return [method_node, method_name] if method_node
end
nil
end
def matches?(method_name, ivar_assign)
return true if ivar_assign.nil? || method_name == :initialize
method_name = method_name.to_s.delete('!?')
variable = ivar_assign.children.first
variable_name = variable.to_s.sub('@', '')
variable_name_candidates(method_name).include?(variable_name)
end
def message(variable)
variable_name = variable.to_s.sub('@', '')
return UNDERSCORE_REQUIRED if style == :required && !variable_name.start_with?('_')
MSG
end
def suggested_var(method_name)
suggestion = method_name.to_s.delete('!?')
style == :required ? "_#{suggestion}" : suggestion
end
def variable_name_candidates(method_name)
no_underscore = method_name.delete_prefix('_')
with_underscore = "_#{method_name}"
case style
when :required
[with_underscore,
method_name.start_with?('_') ? method_name : nil].compact
when :disallowed
[method_name, no_underscore]
when :optional
[method_name, with_underscore, no_underscore]
else
raise 'Unreachable'
end
end
end
end
end
end