lib/rubocop/cop/style/arguments_forwarding.rb
# frozen_string_literal: true
module RuboCop
module Cop
module Style
# In Ruby 2.7, arguments forwarding has been added.
#
# This cop identifies places where `do_something(*args, &block)`
# can be replaced by `do_something(...)`.
#
# In Ruby 3.1, anonymous block forwarding has been added.
#
# This cop identifies places where `do_something(&block)` can be replaced
# by `do_something(&)`; if desired, this functionality can be disabled
# by setting `UseAnonymousForwarding: false`.
#
# In Ruby 3.2, anonymous args/kwargs forwarding has been added.
#
# This cop also identifies places where `use_args(*args)`/`use_kwargs(**kwargs)` can be
# replaced by `use_args(*)`/`use_kwargs(**)`; if desired, this functionality can be disabled
# by setting `UseAnonymousForwarding: false`.
#
# And this cop has `RedundantRestArgumentNames`, `RedundantKeywordRestArgumentNames`,
# and `RedundantBlockArgumentNames` options. This configuration is a list of redundant names
# that are sufficient for anonymizing meaningless naming.
#
# Meaningless names that are commonly used can be anonymized by default:
# e.g., `*args`, `**options`, `&block`, and so on.
#
# Names not on this list are likely to be meaningful and are allowed by default.
#
# @example
# # bad
# def foo(*args, &block)
# bar(*args, &block)
# end
#
# # bad
# def foo(*args, **kwargs, &block)
# bar(*args, **kwargs, &block)
# end
#
# # good
# def foo(...)
# bar(...)
# end
#
# @example UseAnonymousForwarding: true (default, only relevant for Ruby >= 3.2)
# # bad
# def foo(*args, **kwargs, &block)
# args_only(*args)
# kwargs_only(**kwargs)
# block_only(&block)
# end
#
# # good
# def foo(*, **, &)
# args_only(*)
# kwargs_only(**)
# block_only(&)
# end
#
# @example UseAnonymousForwarding: false (only relevant for Ruby >= 3.2)
# # good
# def foo(*args, **kwargs, &block)
# args_only(*args)
# kwargs_only(**kwargs)
# block_only(&block)
# end
#
# @example AllowOnlyRestArgument: true (default, only relevant for Ruby < 3.2)
# # good
# def foo(*args)
# bar(*args)
# end
#
# def foo(**kwargs)
# bar(**kwargs)
# end
#
# @example AllowOnlyRestArgument: false (only relevant for Ruby < 3.2)
# # bad
# # The following code can replace the arguments with `...`,
# # but it will change the behavior. Because `...` forwards block also.
# def foo(*args)
# bar(*args)
# end
#
# def foo(**kwargs)
# bar(**kwargs)
# end
#
# @example RedundantRestArgumentNames: ['args', 'arguments'] (default)
# # bad
# def foo(*args)
# bar(*args)
# end
#
# # good
# def foo(*)
# bar(*)
# end
#
# @example RedundantKeywordRestArgumentNames: ['kwargs', 'options', 'opts'] (default)
# # bad
# def foo(**kwargs)
# bar(**kwargs)
# end
#
# # good
# def foo(**)
# bar(**)
# end
#
# @example RedundantBlockArgumentNames: ['blk', 'block', 'proc'] (default)
# # bad - But it is good with `EnforcedStyle: explicit` set for `Naming/BlockForwarding`.
# def foo(&block)
# bar(&block)
# end
#
# # good
# def foo(&)
# bar(&)
# end
class ArgumentsForwarding < Base
include RangeHelp
extend AutoCorrector
extend TargetRubyVersion
minimum_target_ruby_version 2.7
FORWARDING_LVAR_TYPES = %i[splat kwsplat block_pass].freeze
ADDITIONAL_ARG_TYPES = %i[lvar arg].freeze
FORWARDING_MSG = 'Use shorthand syntax `...` for arguments forwarding.'
ARGS_MSG = 'Use anonymous positional arguments forwarding (`*`).'
KWARGS_MSG = 'Use anonymous keyword arguments forwarding (`**`).'
BLOCK_MSG = 'Use anonymous block arguments forwarding (`&`).'
def self.autocorrect_incompatible_with
[Naming::BlockForwarding]
end
def on_def(node)
return unless node.body
restarg, kwrestarg, blockarg = extract_forwardable_args(node.arguments)
forwardable_args = redundant_forwardable_named_args(restarg, kwrestarg, blockarg)
send_nodes = node.each_descendant(:send).to_a
send_classifications = classify_send_nodes(
node, send_nodes, non_splat_or_block_pass_lvar_references(node.body), forwardable_args
)
return if send_classifications.empty?
if only_forwards_all?(send_classifications)
add_forward_all_offenses(node, send_classifications, forwardable_args)
elsif target_ruby_version >= 3.2
add_post_ruby_32_offenses(node, send_classifications, forwardable_args)
end
end
alias on_defs on_def
private
def extract_forwardable_args(args)
[args.find(&:restarg_type?), args.find(&:kwrestarg_type?), args.find(&:blockarg_type?)]
end
def redundant_forwardable_named_args(restarg, kwrestarg, blockarg)
restarg_node = redundant_named_arg(restarg, 'RedundantRestArgumentNames', '*')
kwrestarg_node = redundant_named_arg(kwrestarg, 'RedundantKeywordRestArgumentNames', '**')
blockarg_node = redundant_named_arg(blockarg, 'RedundantBlockArgumentNames', '&')
[restarg_node, kwrestarg_node, blockarg_node]
end
def only_forwards_all?(send_classifications)
send_classifications.all? { |_, c, _, _| c == :all }
end
# rubocop:disable Metrics/MethodLength
def add_forward_all_offenses(node, send_classifications, forwardable_args)
_rest_arg, _kwrest_arg, block_arg = *forwardable_args
registered_block_arg_offense = false
send_classifications.each do |send_node, _c, forward_rest, forward_kwrest, forward_block_arg| # rubocop:disable Layout/LineLength
if !forward_rest && !forward_kwrest
# Prevents `anonymous block parameter is also used within block (SyntaxError)` occurs
# in Ruby 3.3.0.
if outside_block?(forward_block_arg)
register_forward_block_arg_offense(!forward_rest, node.arguments, block_arg)
register_forward_block_arg_offense(!forward_rest, send_node, forward_block_arg)
end
registered_block_arg_offense = true
break
else
register_forward_all_offense(send_node, send_node, forward_rest)
end
end
return if registered_block_arg_offense
rest_arg, _kwrest_arg, _block_arg = *forwardable_args
register_forward_all_offense(node, node.arguments, rest_arg)
end
# rubocop:enable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def add_post_ruby_32_offenses(def_node, send_classifications, forwardable_args)
return unless use_anonymous_forwarding?
rest_arg, kwrest_arg, block_arg = *forwardable_args
send_classifications.each do |send_node, _c, forward_rest, forward_kwrest, forward_block_arg| # rubocop:disable Layout/LineLength
if outside_block?(forward_rest)
register_forward_args_offense(def_node.arguments, rest_arg)
register_forward_args_offense(send_node, forward_rest)
end
if outside_block?(forward_kwrest)
register_forward_kwargs_offense(!forward_rest, def_node.arguments, kwrest_arg)
register_forward_kwargs_offense(!forward_rest, send_node, forward_kwrest)
end
# Prevents `anonymous block parameter is also used within block (SyntaxError)` occurs
# in Ruby 3.3.0.
if outside_block?(forward_block_arg)
register_forward_block_arg_offense(!forward_rest, def_node.arguments, block_arg)
register_forward_block_arg_offense(!forward_rest, send_node, forward_block_arg)
end
end
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
def non_splat_or_block_pass_lvar_references(body)
body.each_descendant(:lvar, :lvasgn).filter_map do |lvar|
parent = lvar.parent
next if lvar.lvar_type? && FORWARDING_LVAR_TYPES.include?(parent.type)
lvar.children.first
end.uniq
end
def classify_send_nodes(def_node, send_nodes, referenced_lvars, forwardable_args)
send_nodes.filter_map do |send_node|
classification_and_forwards = classification_and_forwards(
def_node,
send_node,
referenced_lvars,
forwardable_args
)
next unless classification_and_forwards
[send_node, *classification_and_forwards]
end
end
def classification_and_forwards(def_node, send_node, referenced_lvars, forwardable_args)
classifier = SendNodeClassifier.new(
def_node, send_node, referenced_lvars, forwardable_args,
target_ruby_version: target_ruby_version,
allow_only_rest_arguments: allow_only_rest_arguments?
)
classification = classifier.classification
return unless classification
[
classification,
classifier.forwarded_rest_arg,
classifier.forwarded_kwrest_arg,
classifier.forwarded_block_arg
]
end
def redundant_named_arg(arg, config_name, keyword)
return nil unless arg
redundant_arg_names = cop_config.fetch(config_name, []).map do |redundant_arg_name|
"#{keyword}#{redundant_arg_name}"
end << keyword
redundant_arg_names.include?(arg.source) ? arg : nil
end
def outside_block?(node)
return false unless node
node.each_ancestor(:block, :numblock).none?
end
def register_forward_args_offense(def_arguments_or_send, rest_arg_or_splat)
add_offense(rest_arg_or_splat, message: ARGS_MSG) do |corrector|
add_parens_if_missing(def_arguments_or_send, corrector)
corrector.replace(rest_arg_or_splat, '*')
end
end
def register_forward_kwargs_offense(add_parens, def_arguments_or_send, kwrest_arg_or_splat)
add_offense(kwrest_arg_or_splat, message: KWARGS_MSG) do |corrector|
add_parens_if_missing(def_arguments_or_send, corrector) if add_parens
corrector.replace(kwrest_arg_or_splat, '**')
end
end
def register_forward_block_arg_offense(add_parens, def_arguments_or_send, block_arg)
return if target_ruby_version <= 3.0 ||
block_arg.nil? || block_arg.source == '&' || explicit_block_name?
add_offense(block_arg, message: BLOCK_MSG) do |corrector|
add_parens_if_missing(def_arguments_or_send, corrector) if add_parens
corrector.replace(block_arg, '&')
end
end
def register_forward_all_offense(def_or_send, send_or_arguments, rest_or_splat)
arg_range = arguments_range(def_or_send, rest_or_splat)
add_offense(arg_range, message: FORWARDING_MSG) do |corrector|
add_parens_if_missing(send_or_arguments, corrector)
corrector.replace(arg_range, '...')
end
end
def arguments_range(node, first_node)
arguments = node.arguments.reject { |arg| ADDITIONAL_ARG_TYPES.include?(arg.type) }
start_node = first_node || arguments.first
range_between(start_node.source_range.begin_pos, arguments.last.source_range.end_pos)
end
def allow_only_rest_arguments?
cop_config.fetch('AllowOnlyRestArgument', true)
end
def use_anonymous_forwarding?
cop_config.fetch('UseAnonymousForwarding', false)
end
def add_parens_if_missing(node, corrector)
return if parentheses?(node)
add_parentheses(node, corrector)
end
# Classifies send nodes for possible rest/kwrest/all (including block) forwarding.
class SendNodeClassifier
extend NodePattern::Macros
# @!method forwarded_rest_arg?(node, rest_name)
def_node_matcher :forwarded_rest_arg?, '(splat (lvar %1))'
# @!method extract_forwarded_kwrest_arg(node, kwrest_name)
def_node_matcher :extract_forwarded_kwrest_arg, '(hash <$(kwsplat (lvar %1)) ...>)'
# @!method forwarded_block_arg?(node, block_name)
def_node_matcher :forwarded_block_arg?, '(block_pass {(lvar %1) nil?})'
def initialize(def_node, send_node, referenced_lvars, forwardable_args, **config)
@def_node = def_node
@send_node = send_node
@referenced_lvars = referenced_lvars
@rest_arg, @kwrest_arg, @block_arg = *forwardable_args
@rest_arg_name, @kwrest_arg_name, @block_arg_name =
*forwardable_args.map { |a| a&.name }
@config = config
end
def forwarded_rest_arg
return nil if referenced_rest_arg?
arguments.find { |arg| forwarded_rest_arg?(arg, @rest_arg_name) }
end
def forwarded_kwrest_arg
return nil if referenced_kwrest_arg?
arguments.filter_map { |arg| extract_forwarded_kwrest_arg(arg, @kwrest_arg_name) }.first
end
def forwarded_block_arg
return nil if referenced_block_arg?
arguments.find { |arg| forwarded_block_arg?(arg, @block_arg_name) }
end
def classification
return nil unless forwarded_rest_arg || forwarded_kwrest_arg || forwarded_block_arg
if can_forward_all?
:all
else
:rest_or_kwrest
end
end
private
def can_forward_all?
return false if any_arg_referenced?
return false if ruby_32_missing_rest_or_kwest?
return false unless offensive_block_forwarding?
return false if additional_kwargs_or_forwarded_kwargs?
no_additional_args? || (target_ruby_version >= 3.0 && no_post_splat_args?)
end
def ruby_32_missing_rest_or_kwest?
target_ruby_version >= 3.2 && !forwarded_rest_and_kwrest_args
end
def offensive_block_forwarding?
@block_arg ? forwarded_block_arg : allow_offense_for_no_block?
end
def forwarded_rest_and_kwrest_args
forwarded_rest_arg && forwarded_kwrest_arg
end
def arguments
@send_node.arguments
end
def referenced_rest_arg?
@referenced_lvars.include?(@rest_arg_name)
end
def referenced_kwrest_arg?
@referenced_lvars.include?(@kwrest_arg_name)
end
def referenced_block_arg?
@referenced_lvars.include?(@block_arg_name)
end
def any_arg_referenced?
referenced_rest_arg? || referenced_kwrest_arg? || referenced_block_arg?
end
def target_ruby_version
@config.fetch(:target_ruby_version)
end
def no_post_splat_args?
return true unless (splat_index = arguments.index(forwarded_rest_arg))
arg_after_splat = arguments[splat_index + 1]
[nil, :hash, :block_pass].include?(arg_after_splat&.type)
end
def additional_kwargs_or_forwarded_kwargs?
additional_kwargs? || forward_additional_kwargs?
end
def additional_kwargs?
@def_node.arguments.any? { |a| a.kwarg_type? || a.kwoptarg_type? }
end
def forward_additional_kwargs?
return false unless forwarded_kwrest_arg
!forwarded_kwrest_arg.parent.children.one?
end
def allow_offense_for_no_block?
!@config.fetch(:allow_only_rest_arguments)
end
def no_additional_args?
forwardable_count = [@rest_arg, @kwrest_arg, @block_arg].compact.size
return false if missing_rest_arg_or_kwrest_arg?
@def_node.arguments.size == forwardable_count &&
@send_node.arguments.size == forwardable_count
end
def missing_rest_arg_or_kwrest_arg?
(@rest_arg_name && !forwarded_rest_arg) ||
(@kwrest_arg_name && !forwarded_kwrest_arg)
end
end
def explicit_block_name?
block_forwarding_config = config.for_cop('Naming/BlockForwarding')
return false unless block_forwarding_config['Enabled']
block_forwarding_config['EnforcedStyle'] == 'explicit'
end
end
end
end
end