Skip to content

Commit

Permalink
Add UseZipToWrapArrayContents
Browse files Browse the repository at this point in the history
Performance Cop for the more efficient way to generate an Array of Arrays.

Performs 40-90% faster than mapping to iteratively wrap array contents.
Performs 5 - 55% faster on ranges, depending on size.
  • Loading branch information
corsonknowles committed Sep 16, 2024
1 parent 4c4cacd commit a6f1f90
Show file tree
Hide file tree
Showing 5 changed files with 308 additions and 0 deletions.
1 change: 1 addition & 0 deletions changelog/new_merge_pull_request_459_from.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#462](https://github.com/rubocop/rubocop-performance/pull/462): Add new `Performance/UseZipToWrapArrayContents` cop that checks patterns like `.map { |id| [id] }` or `.map { [_1] }` and can replace them with `.zip`. ([@corsonknowles][])
5 changes: 5 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,8 @@ Performance/UriDefaultParser:
Description: 'Use `URI::DEFAULT_PARSER` instead of `URI::Parser.new`.'
Enabled: true
VersionAdded: '0.50'

Performance/UseZipToWrapArrayContents:
Description: 'Checks for `.map { |id| [id] }` and suggests replacing it with `.zip`.'
Enabled: pending
VersionAdded: <<next>>
77 changes: 77 additions & 0 deletions lib/rubocop/cop/performance/use_zip_to_wrap_array_contents.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Performance
# Checks for `.map { |id| [id] }` and suggests replacing it with `.zip`.
#
# @example
# # bad
# [1, 2, 3].map { |id| [id] }
#
# # good
# [1, 2, 3].zip
#
# @example
# # good (no offense)
# [1, 2, 3].map { |id| id }
#
# @example
# # good (no offense)
# [1, 2, 3].map { |id| [id, id] }
class UseZipToWrapArrayContents < Base
extend AutoCorrector

RESTRICT_ON_SEND = %i[map].freeze
MSG = 'Use `.zip` instead of `.map { |id| [id] }`.'

# Matches regular block form `.map { |e| [e] }`
# @!method map_with_array?(node)
def_node_matcher :map_with_array?, <<~PATTERN
(block
(send _ :map)
(args (arg _id))
(array (lvar _id)))
PATTERN

# Matches numblock form `.map { [_1] }`
# @!method map_with_array_numblock?(node)
def_node_matcher :map_with_array_numblock?, <<~PATTERN
(numblock
(send _ :map)
1
(array (lvar _))
)
PATTERN

def on_send(node)
parent = node.parent

register_offense(parent) if map_with_array?(parent) || map_with_array_numblock?(parent)
end

private

def register_offense(node)
map_node = node.children.first # The send node for `.map`
# Start position: beginning of the dot before `map`
start_pos = map_node.loc.dot.begin_pos
# End position: end of the block
end_pos = node.loc.end.end_pos
# Create a new source range covering `.map { |id| [id] }`
offense_range = Parser::Source::Range.new(node.source_range.source_buffer, start_pos, end_pos)

add_offense(offense_range) do |corrector|
autocorrect(node, corrector)
end
end

def autocorrect(node, corrector)
map_call = node.children.first
receiver = map_call.receiver.source
corrector.replace(node, "#{receiver}.zip")
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 @@ -53,3 +53,4 @@
require_relative 'performance/unfreeze_string'
require_relative 'performance/uri_default_parser'
require_relative 'performance/chain_array_allocation'
require_relative 'performance/use_zip_to_wrap_array_contents'
224 changes: 224 additions & 0 deletions spec/rubocop/cop/performance/use_zip_to_wrap_array_contents_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Performance::UseZipToWrapArrayContents, :config do
context 'when using map with array literal' do
it 'registers an offense and corrects to use zip with no arguments' do
expect_offense(<<~RUBY)
[1, 2, 3].map { |id| [id] }
^^^^^^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
[1, 2, 3].zip
RUBY
end
end

context 'when using map with a short iterator name' do
it 'registers an offense and corrects to use zip with no arguments' do
expect_offense(<<~RUBY)
[1, 2, 3].map { |e| [e] }
^^^^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
[1, 2, 3].zip
RUBY
end
end

context 'when using map on a range with another iterator name' do
it 'registers an offense and corrects the code' do
expect_offense(<<~RUBY)
(1..3).map { |x| [x] }
^^^^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
(1..3).zip
RUBY
end
end

context 'when using map in a do end block' do
it 'registers an offense' do
expect_offense(<<~RUBY)
(a..b).map do |m| [m] end
^^^^^^^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
(a..b).zip
RUBY
end
end

context 'when using map in a chain' do
it 'registers an offense' do
expect_offense(<<~RUBY)
[nil, tuple].flatten.map { |e| [e] }.call
^^^^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
[nil, tuple].flatten.zip.call
RUBY
end
end

context 'when the map block does not contain an array literal' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map { |id| id }
RUBY
end
end

context 'when using collect' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].collect { |id| [id] }
RUBY
end
end

context 'when using select with an array literal' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].select { |id| [id] }
RUBY
end
end

context 'when using map with an array literal containing multiple elements' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map { |id| [id, id] }
RUBY
end
end

context 'when using map with doubly wrapped arrays' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map { |id| [[id]] }
RUBY
end
end

context 'when using map with addition' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map { |id| id + 1 }
RUBY
end
end

context 'when using map with array addition' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map { |id| [id] + [id] }
RUBY
end
end

context 'when using map with indexing into an array' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map { |id| [id][id] }
RUBY
end
end

context 'when calling map with no arguments' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map
RUBY
end
end

context 'when calling map with an empty block' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map {}
RUBY
end
end

context 'when calling filter_map' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].filter_map {|id| [id]}
RUBY
end
end

context 'when calling flat_map' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].flat_map {|id| [id]}
RUBY
end
end

context 'when using Array.wrap' do
it 'does not register an offense' do
expect_no_offenses(<<~RUBY)
[1, 2, 3].map { |id| Array.wrap(id) }
RUBY
end
end

context 'when using map with a numerical argment reference' do
it 'registers an offense' do
expect_offense(<<~RUBY)
[1, 2, 3].map { [_1] }
^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
[1, 2, 3].zip
RUBY
end
end

context 'when using map with a numerical argment reference in a chain' do
it 'registers an offense' do
expect_offense(<<~RUBY)
[1, 2].sum.map { [_1] }.flatten
^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
[1, 2].sum.zip.flatten
RUBY
end
end

context 'when using map on a range with a numeric arguement reference' do
it 'registers an offense and corrects the code' do
expect_offense(<<~RUBY)
(1..3).map { [_1] }
^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
(1..3).zip
RUBY
end
end

context 'when using map in a do end block with a numeric argument reference' do
it 'registers an offense' do
expect_offense(<<~RUBY)
(a..b).map do [_1] end
^^^^^^^^^^^^^^^^ Use `.zip` instead of `.map { |id| [id] }`.
RUBY

expect_correction(<<~RUBY)
(a..b).zip
RUBY
end
end
end

0 comments on commit a6f1f90

Please sign in to comment.