From 93c36b640512dfb22f577b8d13c52060f635aec3 Mon Sep 17 00:00:00 2001 From: Dave Corson-Knowles Date: Sun, 15 Sep 2024 22:40:08 -0700 Subject: [PATCH] Add UseZipToWrapArrayContents 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. --- changelog/new_merge_pull_request_459_from.md | 1 + config/default.yml | 5 + .../use_zip_to_wrap_array_contents.rb | 73 +++++ lib/rubocop/cop/performance_cops.rb | 1 + .../use_zip_to_wrap_array_contents_spec.rb | 264 ++++++++++++++++++ 5 files changed, 344 insertions(+) create mode 100644 changelog/new_merge_pull_request_459_from.md create mode 100644 lib/rubocop/cop/performance/use_zip_to_wrap_array_contents.rb create mode 100644 spec/rubocop/cop/performance/use_zip_to_wrap_array_contents_spec.rb diff --git a/changelog/new_merge_pull_request_459_from.md b/changelog/new_merge_pull_request_459_from.md new file mode 100644 index 0000000000..a3c277bdcf --- /dev/null +++ b/changelog/new_merge_pull_request_459_from.md @@ -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][]) diff --git a/config/default.yml b/config/default.yml index 95dc830898..776bc6978c 100644 --- a/config/default.yml +++ b/config/default.yml @@ -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: <> diff --git a/lib/rubocop/cop/performance/use_zip_to_wrap_array_contents.rb b/lib/rubocop/cop/performance/use_zip_to_wrap_array_contents.rb new file mode 100644 index 0000000000..36f079c26b --- /dev/null +++ b/lib/rubocop/cop/performance/use_zip_to_wrap_array_contents.rb @@ -0,0 +1,73 @@ +# 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 + include RangeHelp + extend AutoCorrector + + MSG = 'Use `.zip` instead of `.map { |id| [id] }`.' + RESTRICT_ON_SEND = %i[map].freeze + + # 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, node) if map_with_array?(parent) || map_with_array_numblock?(parent) + end + + private + + def register_offense(parent, node) + add_offense(offense_range(parent)) do |corrector| + autocorrect(parent, node, corrector) + end + end + + def autocorrect(parent, node, corrector) + receiver = node.receiver.source + corrector.replace(parent, "#{receiver}.zip") + end + + def offense_range(node) + range_between(node.children.first.loc.selector.begin_pos, node.loc.end.end_pos) + end + end + end + end +end diff --git a/lib/rubocop/cop/performance_cops.rb b/lib/rubocop/cop/performance_cops.rb index 2a18f26120..e093870751 100644 --- a/lib/rubocop/cop/performance_cops.rb +++ b/lib/rubocop/cop/performance_cops.rb @@ -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' diff --git a/spec/rubocop/cop/performance/use_zip_to_wrap_array_contents_spec.rb b/spec/rubocop/cop/performance/use_zip_to_wrap_array_contents_spec.rb new file mode 100644 index 0000000000..169db0514e --- /dev/null +++ b/spec/rubocop/cop/performance/use_zip_to_wrap_array_contents_spec.rb @@ -0,0 +1,264 @@ +# 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 + + context 'when calling filter_map with a numblock' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + [1, 2, 3].filter_map { [_1] } + RUBY + end + end + + context 'when calling map, adding, and wrapping, with a numblock' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + [1, 2, 3].map { [_1 + 1] } + RUBY + end + end + + context 'when calling double wrapping with a numblock' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + [1, 2, 3].map { [[_1]] } + RUBY + end + end + + context 'when calling map with an unused iterator' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + [1, 2, 3].map { |e| [1] } + RUBY + end + end + + context 'when calling map with a static block that always returns the same value' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + [1, 2, 3].map { [id] } + RUBY + end + end +end