Skip to content

Commit

Permalink
Add new Performance/Readlines cop
Browse files Browse the repository at this point in the history
  • Loading branch information
fatkodima committed Jun 6, 2020
1 parent 0b666fd commit 24732b7
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### New features

* [#127](https://github.com/rubocop-hq/rubocop-performance/pull/127): Add new `Performance/Readlines` cop. ([@fatkodima][])

* [#125](https://github.com/rubocop-hq/rubocop-performance/pull/125): Support `Range#member?` method for `Performance/RangeInclude` cop. ([@fatkodima][])

## 1.6.1 (2020-06-05)
Expand Down
5 changes: 5 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ Performance/RangeInclude:
VersionChanged: '1.7'
Safe: false

Performance/Readlines:
Description: 'Use `IO.each_line` (`IO#each_line`) instead of `IO.readlines` (`IO#readlines`).'
Enabled: true
VersionAdded: '1.7'

Performance/RedundantBlockCall:
Description: 'Use `yield` instead of `block.call`.'
Reference: 'https://github.com/JuanitoFatas/fast-ruby#proccall-and-block-arguments-vs-yieldcode'
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/cops.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* xref:cops_performance.adoc#performanceinefficienthashsearch[Performance/InefficientHashSearch]
* xref:cops_performance.adoc#performanceopenstruct[Performance/OpenStruct]
* xref:cops_performance.adoc#performancerangeinclude[Performance/RangeInclude]
* xref:cops_performance.adoc#performancereadlines[Performance/Readlines]
* xref:cops_performance.adoc#performanceredundantblockcall[Performance/RedundantBlockCall]
* xref:cops_performance.adoc#performanceredundantmatch[Performance/RedundantMatch]
* xref:cops_performance.adoc#performanceredundantmerge[Performance/RedundantMerge]
Expand Down
36 changes: 36 additions & 0 deletions docs/modules/ROOT/pages/cops_performance.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,42 @@ This cop is `Safe: false` by default because `Range#include?` (or `Range#member?

* https://github.com/JuanitoFatas/fast-ruby#cover-vs-include-code

== Performance/Readlines

|===
| Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged

| Enabled
| Yes
| Yes
| 1.7
| -
|===

This cop identifies places where inefficient `readlines` method
can be replaced by `each_line` to avoid fully loading file content into memory.

=== Examples

[source,ruby]
----
# bad
File.readlines('testfile').each { |l| puts l }
IO.readlines('testfile', chomp: true).each { |l| puts l }
conn.readlines(10).map { |l| l.size }
file.readlines.find { |l| l.start_with?('#') }
file.readlines.each { |l| puts l }
# good
File.open('testfile', 'r').each_line { |l| puts l }
IO.open('testfile').each_line(chomp: true) { |l| puts l }
conn.each_line(10).map { |l| l.size }
file.readlines.find { |l| l.start_with?('#') }
file.each_line { |l| puts l }
----

== Performance/RedundantBlockCall

|===
Expand Down
127 changes: 127 additions & 0 deletions lib/rubocop/cop/performance/readlines.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Performance
# This cop identifies places where inefficient `readlines` method
# can be replaced by `each_line` to avoid fully loading file content into memory.
#
# @example
#
# # bad
# File.readlines('testfile').each { |l| puts l }
# IO.readlines('testfile', chomp: true).each { |l| puts l }
#
# conn.readlines(10).map { |l| l.size }
# file.readlines.find { |l| l.start_with?('#') }
# file.readlines.each { |l| puts l }
#
# # good
# File.open('testfile', 'r').each_line { |l| puts l }
# IO.open('testfile').each_line(chomp: true) { |l| puts l }
#
# conn.each_line(10).map { |l| l.size }
# file.readlines.find { |l| l.start_with?('#') }
# file.each_line { |l| puts l }
#
class Readlines < Cop
include RangeHelp

MSG = 'Use `%<good>s` instead of `%<bad>s`.'
ENUMERABLE_METHODS = (Enumerable.instance_methods + [:each]).freeze

def_node_matcher :readlines_on_class?, <<~PATTERN
$(send $(send (const nil? {:IO :File}) :readlines ...) #enumerable_method?)
PATTERN

def_node_matcher :readlines_on_instance?, <<~PATTERN
$(send $(send ${nil? !const_type?} :readlines ...) #enumerable_method? ...)
PATTERN

def on_send(node)
readlines_on_class?(node) do |enumerable_call, readlines_call|
offense(node, enumerable_call, readlines_call)
end

readlines_on_instance?(node) do |enumerable_call, readlines_call, _|
offense(node, enumerable_call, readlines_call)
end
end

def autocorrect(node)
readlines_on_instance?(node) do |enumerable_call, readlines_call, receiver|
# We cannot safely correct `.readlines` method called on IO/File classes
# due to its signature and we are not sure with implicit receiver
# if it is called in the context of some instance or mentioned class.
return if receiver.nil?

lambda do |corrector|
range = correction_range(enumerable_call, readlines_call)

if readlines_call.arguments?
call_args = build_call_args(readlines_call.arguments)
replacement = "each_line(#{call_args})"
else
replacement = 'each_line'
end

corrector.replace(range, replacement)
end
end
end

private

def enumerable_method?(node)
ENUMERABLE_METHODS.include?(node.to_sym)
end

def offense(node, enumerable_call, readlines_call)
range = offense_range(enumerable_call, readlines_call)
good_method = build_good_method(enumerable_call)
bad_method = build_bad_method(enumerable_call)

add_offense(
node,
location: range,
message: format(MSG, good: good_method, bad: bad_method)
)
end

def offense_range(enumerable_call, readlines_call)
readlines_pos = readlines_call.loc.selector.begin_pos
enumerable_pos = enumerable_call.loc.selector.end_pos
range_between(readlines_pos, enumerable_pos)
end

def build_good_method(enumerable_call)
if enumerable_call.method?(:each)
'each_line'
else
"each_line.#{enumerable_call.method_name}"
end
end

def build_bad_method(enumerable_call)
"readlines.#{enumerable_call.method_name}"
end

def correction_range(enumerable_call, readlines_call)
begin_pos = readlines_call.loc.selector.begin_pos

end_pos = if enumerable_call.method?(:each)
enumerable_call.loc.expression.end_pos
else
enumerable_call.loc.dot.begin_pos
end

range_between(begin_pos, end_pos)
end

def build_call_args(call_args_node)
call_args_node.map(&:source).join(', ')
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/performance_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
require_relative 'performance/inefficient_hash_search'
require_relative 'performance/open_struct'
require_relative 'performance/range_include'
require_relative 'performance/readlines'
require_relative 'performance/redundant_block_call'
require_relative 'performance/redundant_match'
require_relative 'performance/redundant_merge'
Expand Down
62 changes: 62 additions & 0 deletions spec/rubocop/cop/performance/readlines_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Performance::Readlines do
subject(:cop) { described_class.new }

it 'registers an offense when using `File.readlines` followed by Enumerable method' do
expect_offense(<<~RUBY)
File.readlines('testfile').map { |l| l.size }
^^^^^^^^^^^^^^^^^^^^^^^^^ Use `each_line.map` instead of `readlines.map`.
RUBY
end

it 'registers an offense when using `IO.readlines` followed by Enumerable method' do
expect_offense(<<~RUBY)
IO.readlines('testfile').map { |l| l.size }
^^^^^^^^^^^^^^^^^^^^^^^^^ Use `each_line.map` instead of `readlines.map`.
RUBY
end

it 'registers an offense when using `IO.readlines` followed by `#each` method' do
# Note: `each_line` in message, not `each_line.each`
expect_offense(<<~RUBY)
IO.readlines('testfile').each { |l| puts l }
^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `each_line` instead of `readlines.each`.
RUBY
end

it 'does not register an offense when using `.readlines` and not followed by Enumerable method' do
expect_no_offenses(<<~RUBY)
File.readlines('testfile').not_enumerable_method
RUBY
end

it 'registers an offense and corrects when using `#readlines` on an instance followed by Enumerable method' do
expect_offense(<<~RUBY)
file.readlines(10).map { |l| l.size }
^^^^^^^^^^^^^^^^^ Use `each_line.map` instead of `readlines.map`.
RUBY

expect_correction(<<~RUBY)
file.each_line(10).map { |l| l.size }
RUBY
end

it 'registers an offense and corrects when using `#readlines` on an instance followed by `#each` method' do
# Note: `each_line` in message, not `each_line.each`
expect_offense(<<~RUBY)
file.readlines(10).each { |l| puts l }
^^^^^^^^^^^^^^^^^^ Use `each_line` instead of `readlines.each`.
RUBY

expect_correction(<<~RUBY)
file.each_line(10) { |l| puts l }
RUBY
end

it 'does not register an offense when using `#readlines` on an instance and not followed by Enumerable method' do
expect_no_offenses(<<~RUBY)
file.readlines.not_enumerable_method
RUBY
end
end

0 comments on commit 24732b7

Please sign in to comment.