lib/rubocop/cop/rails/root_pathname_methods.rb
# frozen_string_literal: true
module RuboCop
module Cop
module Rails
# Use `Rails.root` IO methods instead of passing it to `File`.
#
# `Rails.root` is an instance of `Pathname`
# so we can apply many IO methods directly.
#
# This cop works best when used together with
# `Style/FileRead`, `Style/FileWrite` and `Rails/RootJoinChain`.
#
# @safety
# This cop is unsafe for autocorrection because ``Dir``'s `children`, `each_child`, `entries`, and `glob`
# methods return string element, but these methods of `Pathname` return `Pathname` element.
#
# @example
# # bad
# File.open(Rails.root.join('db', 'schema.rb'))
# File.open(Rails.root.join('db', 'schema.rb'), 'w')
# File.read(Rails.root.join('db', 'schema.rb'))
# File.binread(Rails.root.join('db', 'schema.rb'))
# File.write(Rails.root.join('db', 'schema.rb'), content)
# File.binwrite(Rails.root.join('db', 'schema.rb'), content)
# Dir.glob(Rails.root.join('db', 'schema.rb'))
# Dir[Rails.root.join('db', 'schema.rb')]
#
# # good
# Rails.root.join('db', 'schema.rb').open
# Rails.root.join('db', 'schema.rb').open('w')
# Rails.root.join('db', 'schema.rb').read
# Rails.root.join('db', 'schema.rb').binread
# Rails.root.join('db', 'schema.rb').write(content)
# Rails.root.join('db', 'schema.rb').binwrite(content)
# Rails.root.glob("db/schema.rb")
#
class RootPathnameMethods < Base # rubocop:disable Metrics/ClassLength
extend AutoCorrector
include RangeHelp
MSG = '`%<rails_root>s` is a `Pathname`, so you can use `%<replacement>s`.'
DIR_GLOB_METHODS = %i[[] glob].to_set.freeze
DIR_NON_GLOB_METHODS = %i[
children
delete
each_child
empty?
entries
exist?
mkdir
open
rmdir
unlink
].to_set.freeze
DIR_METHODS = (DIR_GLOB_METHODS + DIR_NON_GLOB_METHODS).freeze
FILE_METHODS = %i[
atime
basename
binread
binwrite
birthtime
blockdev?
chardev?
chmod
chown
ctime
delete
directory?
dirname
empty?
executable?
executable_real?
exist?
expand_path
extname
file?
fnmatch
fnmatch?
ftype
grpowned?
join
lchmod
lchown
lstat
mtime
open
owned?
pipe?
read
readable?
readable_real?
readlines
readlink
realdirpath
realpath
rename
setgid?
setuid?
size
size?
socket?
split
stat
sticky?
symlink?
sysopen
truncate
unlink
utime
world_readable?
world_writable?
writable?
writable_real?
write
zero?
].to_set.freeze
FILE_TEST_METHODS = %i[
blockdev?
chardev?
directory?
empty?
executable?
executable_real?
exist?
file?
grpowned?
owned?
pipe?
readable?
readable_real?
setgid?
setuid?
size
size?
socket?
sticky?
symlink?
world_readable?
world_writable?
writable?
writable_real?
zero?
].to_set.freeze
FILE_UTILS_METHODS = %i[chmod chown mkdir mkpath rmdir rmtree].to_set.freeze
RESTRICT_ON_SEND = (DIR_METHODS + FILE_METHODS + FILE_TEST_METHODS + FILE_UTILS_METHODS).to_set.freeze
# @!method pathname_method_for_ruby_2_5_or_higher(node)
def_node_matcher :pathname_method_for_ruby_2_5_or_higher, <<~PATTERN
{
(send (const {nil? cbase} :Dir) $DIR_METHODS $_ $...)
(send (const {nil? cbase} {:IO :File}) $FILE_METHODS $_ $...)
(send (const {nil? cbase} :FileTest) $FILE_TEST_METHODS $_ $...)
(send (const {nil? cbase} :FileUtils) $FILE_UTILS_METHODS $_ $...)
}
PATTERN
# @!method pathname_method_for_ruby_2_4_or_lower(node)
def_node_matcher :pathname_method_for_ruby_2_4_or_lower, <<~PATTERN
{
(send (const {nil? cbase} :Dir) $DIR_NON_GLOB_METHODS $_ $...)
(send (const {nil? cbase} {:IO :File}) $FILE_METHODS $_ $...)
(send (const {nil? cbase} :FileTest) $FILE_TEST_METHODS $_ $...)
(send (const {nil? cbase} :FileUtils) $FILE_UTILS_METHODS $_ $...)
}
PATTERN
def_node_matcher :dir_glob?, <<~PATTERN
(send
(const {cbase nil?} :Dir) DIR_GLOB_METHODS ...)
PATTERN
def_node_matcher :rails_root_pathname?, <<~PATTERN
{
$#rails_root?
(send $#rails_root? :join ...)
}
PATTERN
# @!method rails_root?(node)
def_node_matcher :rails_root?, <<~PATTERN
(send (const {nil? cbase} :Rails) {:root :public_path})
PATTERN
def on_send(node)
evidence(node) do |method, path, args, rails_root|
replacement = if dir_glob?(node)
build_path_glob_replacement(path)
else
build_path_replacement(path, method, args)
end
message = format(MSG, rails_root: rails_root.source, replacement: replacement)
add_offense(node, message: message) do |corrector|
corrector.replace(node, replacement)
end
end
end
private
def evidence(node)
return if node.method?(:open) && node.parent&.send_type?
return unless (method, path, args = pathname_method(node)) && (rails_root = rails_root_pathname?(path))
yield(method, path, args, rails_root)
end
def pathname_method(node)
if target_ruby_version >= 2.5
pathname_method_for_ruby_2_5_or_higher(node)
else
pathname_method_for_ruby_2_4_or_lower(node)
end
end
def build_path_glob_replacement(path)
receiver = range_between(path.source_range.begin_pos, path.children.first.loc.selector.end_pos).source
argument = path.arguments.one? ? path.first_argument.source : join_arguments(path.arguments)
"#{receiver}.glob(#{argument})"
end
def build_path_replacement(path, method, args)
path_replacement = path.source
if path.arguments? && !path.parenthesized_call?
path_replacement[' '] = '('
path_replacement << ')'
end
replacement = "#{path_replacement}.#{method}"
if args.any?
formatted_args = args.map { |arg| arg.array_type? ? "*#{arg.source}" : arg.source }
replacement += "(#{formatted_args.join(', ')})"
end
replacement
end
def include_interpolation?(arguments)
arguments.any? do |argument|
argument.children.any? { |child| child.respond_to?(:begin_type?) && child.begin_type? }
end
end
def join_arguments(arguments)
use_interpolation = false
joined_arguments = arguments.map do |arg|
if arg.respond_to?(:value)
arg.value
else
use_interpolation = true
"\#{#{arg.source}}"
end
end.join('/')
quote = enforce_double_quotes? || include_interpolation?(arguments) || use_interpolation ? '"' : "'"
"#{quote}#{joined_arguments}#{quote}"
end
def enforce_double_quotes?
string_literals_config['EnforcedStyle'] == 'double_quotes'
end
def string_literals_config
config.for_cop('Style/StringLiterals')
end
end
end
end
end