diff --git a/changelog/new_json_symbolize_names_cop.md b/changelog/new_json_symbolize_names_cop.md new file mode 100644 index 0000000000..c18c14e171 --- /dev/null +++ b/changelog/new_json_symbolize_names_cop.md @@ -0,0 +1 @@ +* [#1534](https://github.com/rubocop/rubocop-rails/pull/1534): Add new `Rails/JSONSymbolizeNames` cop. ([@viralpraxis][]) diff --git a/config/default.yml b/config/default.yml index 2e59f3661a..4fe8ff868b 100644 --- a/config/default.yml +++ b/config/default.yml @@ -695,6 +695,11 @@ Rails/InverseOf: Include: - '**/app/models/**/*.rb' +Rails/JSONSymbolizeNames: + Description: 'Use `JSON.parse(json, symbolize_names: true)` instead of `JSON.parse(json).deep_symbolize_keys`.' + Enabled: pending + VersionAdded: '<>' + Rails/LexicallyScopedActionFilter: Description: "Checks that methods specified in the filter's `only` or `except` options are explicitly defined in the class." StyleGuide: 'https://rails.rubystyle.guide#lexically-scoped-action-filter' diff --git a/lib/rubocop/cop/rails/json_symbolize_names.rb b/lib/rubocop/cop/rails/json_symbolize_names.rb new file mode 100644 index 0000000000..d98aa3368a --- /dev/null +++ b/lib/rubocop/cop/rails/json_symbolize_names.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Rails + # Use `JSON.parse(json, symbolize_names: true)` instead of `JSON.parse(json).deep_symbolize_keys`. + # Using `symbolize_names: true` is more efficient as it creates symbols during parsing + # rather than requiring a second pass through the data structure. + # + # @example + # # bad + # JSON.parse(json).deep_symbolize_keys + # + # # good + # JSON.parse(json, symbolize_names: true) + # + # # good + # JSON.parse(json, create_additions: true).deep_symbolize_keys + # + class JSONSymbolizeNames < Base + extend AutoCorrector + + MSG = 'Use `symbolize_names` option.' + RESTRICT_ON_SEND = %i[deep_symbolize_keys].to_set.freeze + JSON_PARSING_METHOD_NAMES = %i[load_file load_file! parse parse!].to_set.freeze + + # @!method deep_symbolize_keys?(node) + def_node_matcher :deep_symbolize_keys?, <<~PATTERN + (call + (send (const {nil? cbase} :JSON) JSON_PARSING_METHOD_NAMES ...) :deep_symbolize_keys) + PATTERN + + # @!method create_additions_true?(node) + def_node_matcher :create_additions_true?, <<~PATTERN + (pair (sym :create_additions) (true)) + PATTERN + + def on_send(node) + deep_symbolize_keys?(node) do + json_parse_node = node.receiver + + next if create_additions_enabled?(json_parse_node) + + handle_offense(node, json_parse_node) + end + end + alias on_csend on_send + + private + + def create_additions_enabled?(json_parse_node) + json_parse_node.arguments.any? do |arg| + next false unless arg.hash_type? + + arg.pairs.any? { |pair| create_additions_true?(pair) } + end + end + + def handle_offense(node, json_parse_node) + add_offense(node) do |corrector| + range_to_remove = json_parse_node.source_range.end.join(node.source_range.end) + corrector.remove(range_to_remove) + + if json_parse_node.arguments.any? + corrector.insert_after(json_parse_node.last_argument, ', symbolize_names: true') + else + corrector.insert_after(json_parse_node.source_range.end.adjust(begin_pos: -1), 'symbolize_names: true') + end + end + end + end + end + end +end diff --git a/lib/rubocop/cop/rails_cops.rb b/lib/rubocop/cop/rails_cops.rb index e3cea8a378..1f0d5c6a9d 100644 --- a/lib/rubocop/cop/rails_cops.rb +++ b/lib/rubocop/cop/rails_cops.rb @@ -77,6 +77,7 @@ require_relative 'rails/index_with' require_relative 'rails/inquiry' require_relative 'rails/inverse_of' +require_relative 'rails/json_symbolize_names' require_relative 'rails/lexically_scoped_action_filter' require_relative 'rails/link_to_blank' require_relative 'rails/mailer_name' diff --git a/spec/rubocop/cop/rails/json_symbolize_names_spec.rb b/spec/rubocop/cop/rails/json_symbolize_names_spec.rb new file mode 100644 index 0000000000..ac0a9774fe --- /dev/null +++ b/spec/rubocop/cop/rails/json_symbolize_names_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Cop::Rails::JSONSymbolizeNames, :config do + %i[load_file load_file! parse parse!].each do |method_name| + context "with `#{method_name}` method" do + it "registers an offense for `JSON.#{method_name}` followed by `deep_symbolize_keys`" do + expect_offense(<<~RUBY, method_name: method_name) + JSON.#{method_name}(json).deep_symbolize_keys + ^^^^^^{method_name}^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `symbolize_names` option. + RUBY + + expect_correction(<<~RUBY) + JSON.#{method_name}(json, symbolize_names: true) + RUBY + end + + it "registers an offense for `JSON.#{method_name}` followed by `deep_symbolize_keys` with string literal" do + expect_offense(<<~RUBY, method_name: method_name) + JSON.#{method_name}('{"foo": "bar"}').deep_symbolize_keys + ^^^^^^{method_name}^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `symbolize_names` option. + RUBY + + expect_correction(<<~RUBY) + JSON.#{method_name}('{"foo": "bar"}', symbolize_names: true) + RUBY + end + + it "registers an offense for `::JSON.#{method_name}` followed by `deep_symbolize_keys`" do + expect_offense(<<~RUBY, method_name: method_name) + ::JSON.#{method_name}(json).deep_symbolize_keys + ^^^^^^^^{method_name}^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `symbolize_names` option. + RUBY + + expect_correction(<<~RUBY) + ::JSON.#{method_name}(json, symbolize_names: true) + RUBY + end + + it "registers an offense for `::JSON.#{method_name}` followed by `deep_symbolize_keys` with safe navigation" do + expect_offense(<<~RUBY, method_name: method_name) + ::JSON.#{method_name}(json_null)&.deep_symbolize_keys + ^^^^^^^^{method_name}^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `symbolize_names` option. + RUBY + + expect_correction(<<~RUBY) + ::JSON.#{method_name}(json_null, symbolize_names: true) + RUBY + end + + it "registers an offense for `JSON.#{method_name}` followed by `deep_symbolize_keys` with non-literal option" do + expect_offense(<<~RUBY, method_name: method_name) + ::JSON.#{method_name}(json, options)&.deep_symbolize_keys + ^^^^^^^^{method_name}^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `symbolize_names` option. + RUBY + + expect_correction(<<~RUBY) + ::JSON.#{method_name}(json, options, symbolize_names: true) + RUBY + end + + it "does not register an offense for `JSON.#{method_name}` with `symbolize_names` option" do + expect_no_offenses(<<~RUBY) + JSON.#{method_name}(json, symbolize_names: true) + RUBY + end + + it "does not register an offense for single `JSON.#{method_name}`" do + expect_no_offenses(<<~RUBY) + JSON.#{method_name}(json) + RUBY + end + + context 'with `create_additions` option' do + it "registers an offense for `JSON.#{method_name}` with `create_additions` option set to `false`" do + expect_offense(<<~RUBY, method_name: method_name) + JSON.#{method_name}(json, create_additions: false).deep_symbolize_keys + ^^^^^^{method_name}^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `symbolize_names` option. + RUBY + end + + it "does not register for `JSON.#{method_name}` with `create_additions` option set to `true`" do + expect_no_offenses(<<~RUBY) + JSON.#{method_name}(json, create_additions: true).deep_symbolize_keys + RUBY + end + end + end + end +end