From d75d110016d145327a8a3be4d469a169e958139c Mon Sep 17 00:00:00 2001 From: viralpraxis Date: Wed, 24 Sep 2025 02:56:36 +0400 Subject: [PATCH] Add new `Rails/JSONSymbolizeNames` cop I've seen this pattern a lot: ```ruby JSON.parse(large_json).deep_symbolize_keys ``` instead of travesing Ruby hash once more we can use the `symbolize_names` option: ```ruby JSON.parse(large_json, symbolize_names: true) ``` Caveats / FP scenarios: 1. `symbolize_names` does not work if `create_addition` option is provided. 2. User might use both `symbolize_names: false` and `deep_symbolize_keys`. 3. There's no autocorrection yet, but it should be easy to add. --- changelog/new_json_symbolize_names_cop.md | 1 + config/default.yml | 5 +++ lib/rubocop/cop/rails/json_symbolize_names.rb | 40 +++++++++++++++++++ lib/rubocop/cop/rails_cops.rb | 1 + .../cop/rails/json_symbolize_names_spec.rb | 40 +++++++++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 changelog/new_json_symbolize_names_cop.md create mode 100644 lib/rubocop/cop/rails/json_symbolize_names.rb create mode 100644 spec/rubocop/cop/rails/json_symbolize_names_spec.rb diff --git a/changelog/new_json_symbolize_names_cop.md b/changelog/new_json_symbolize_names_cop.md new file mode 100644 index 0000000000..2bc8899221 --- /dev/null +++ b/changelog/new_json_symbolize_names_cop.md @@ -0,0 +1 @@ +* [#1532](https://github.com/rubocop/rubocop-rails/pull/1532): Add new `Rails/JSONSymbolizeNames` cop. ([@viralpraxis][]) diff --git a/config/default.yml b/config/default.yml index 74bebd3142..fd9d7fbfea 100644 --- a/config/default.yml +++ b/config/default.yml @@ -682,6 +682,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..89b948d2a8 --- /dev/null +++ b/lib/rubocop/cop/rails/json_symbolize_names.rb @@ -0,0 +1,40 @@ +# 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) + # + class JSONSymbolizeNames < Base + 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 + + def on_send(node) + deep_symbolize_keys?(node) do + add_offense(node) + end + end + alias on_csend on_send + end + end + end +end diff --git a/lib/rubocop/cop/rails_cops.rb b/lib/rubocop/cop/rails_cops.rb index a8c7202dac..e9c5a19e84 100644 --- a/lib/rubocop/cop/rails_cops.rb +++ b/lib/rubocop/cop/rails_cops.rb @@ -75,6 +75,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..4e9516b354 --- /dev/null +++ b/spec/rubocop/cop/rails/json_symbolize_names_spec.rb @@ -0,0 +1,40 @@ +# 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 + 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 + 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}("null")&.deep_symbolize_keys + ^^^^^^^^{method_name}^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `symbolize_names` option. + RUBY + end + + it "does not register 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 for single `JSON.#{method_name}`" do + expect_no_offenses(<<~RUBY) + JSON.#{method_name}(json) + RUBY + end + end + end +end