diff --git a/changelog/new_add_rails_relative_date_grammar_cop b/changelog/new_add_rails_relative_date_grammar_cop new file mode 100644 index 0000000000..426131dec9 --- /dev/null +++ b/changelog/new_add_rails_relative_date_grammar_cop @@ -0,0 +1 @@ +* [#1106](https://github.com/rubocop/rubocop-rails/pull/1106): Add new `Rails/RelativeDateGrammar` cop. ([@aeroastro][]) diff --git a/config/default.yml b/config/default.yml index 66e69da650..0357636c12 100644 --- a/config/default.yml +++ b/config/default.yml @@ -861,6 +861,12 @@ Rails/RelativeDateConstant: VersionAdded: '0.48' VersionChanged: '2.13' +Rails/RelativeDateGrammar: + Description: 'Use ActiveSupport::Duration as a receiver for a relative date like `1.day.since(Time.current)`.' + Enabled: pending + Safe: false + VersionAdded: '<>' + Rails/RenderInline: Description: 'Prefer using a template over inline rendering.' StyleGuide: 'https://rails.rubystyle.guide/#inline-rendering' diff --git a/lib/rubocop/cop/rails/relative_date_grammar.rb b/lib/rubocop/cop/rails/relative_date_grammar.rb new file mode 100644 index 0000000000..ee4ece3942 --- /dev/null +++ b/lib/rubocop/cop/rails/relative_date_grammar.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Rails + # Checks whether the word orders of relative dates are grammatically easy to understand. + # This check includes detecting undefined methods on Date(Time) objects. + # + # @safety + # This cop is unsafe because it avoids strict checking of receivers' types, + # ActiveSupport::Duration and Date(Time) respectively. + # + # @example + # # bad + # tomorrow = Time.current.since(1.day) + # + # # good + # tomorrow = 1.day.since(Time.current) + class RelativeDateGrammar < Base + extend AutoCorrector + + MSG = 'Use ActiveSupport::Duration#%s as a receiver ' \ + 'for relative date like `%s.%s(%s)`.' + + RELATIVE_DATE_METHODS = %i[since from_now after ago until before].to_set.freeze + DURATION_METHODS = %i[second seconds minute minutes hour hours + day days week weeks month months year years].to_set.freeze + + RESTRICT_ON_SEND = RELATIVE_DATE_METHODS.to_a.freeze + + def_node_matcher :inverted_relative_date?, <<~PATTERN + (send + $!nil? + $RELATIVE_DATE_METHODS + $(send + !nil? + $DURATION_METHODS + ) + ) + PATTERN + + def on_send(node) + inverted_relative_date?(node) do |date, relation, duration| + message = format(MSG, date: date.source, relation: relation.to_s, duration: duration.source) + add_offense(node, message: message) do |corrector| + autocorrect(corrector, node, date, relation, duration) + end + end + end + + private + + def autocorrect(corrector, node, date, relation, duration) + new_code = ["#{duration.source}.#{relation}(#{date.source})"] + corrector.replace(node, new_code) + end + end + end + end +end diff --git a/lib/rubocop/cop/rails_cops.rb b/lib/rubocop/cop/rails_cops.rb index 3ffdb8f311..ea4de1e549 100644 --- a/lib/rubocop/cop/rails_cops.rb +++ b/lib/rubocop/cop/rails_cops.rb @@ -97,6 +97,7 @@ require_relative 'rails/reflection_class_name' require_relative 'rails/refute_methods' require_relative 'rails/relative_date_constant' +require_relative 'rails/relative_date_grammar' require_relative 'rails/render_inline' require_relative 'rails/render_plain_text' require_relative 'rails/request_referer' diff --git a/spec/rubocop/cop/rails/relative_date_grammar_spec.rb b/spec/rubocop/cop/rails/relative_date_grammar_spec.rb new file mode 100644 index 0000000000..8cc79cdf52 --- /dev/null +++ b/spec/rubocop/cop/rails/relative_date_grammar_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Cop::Rails::RelativeDateGrammar, :config do + it 'accepts ActiveSupport::Duration as a receiver (ActiveSupport::Duration#since)' do + expect_no_offenses(<<~RUBY) + yesterday = 1.day.since(Time.current) + RUBY + end + + it 'registers an offense for Date(Time) as a receiver (ActiveSupport::TimeWithZone#ago)' do + expect_offense(<<~RUBY) + last_week = Time.current.ago(1.week) + ^^^^^^^^^^^^^^^^^^^^^^^^ Use ActiveSupport::Duration#ago as a receiver for relative date like `1.week.ago(Time.current)`. + RUBY + + expect_correction(<<~RUBY) + last_week = 1.week.ago(Time.current) + RUBY + end + + it 'registers an offense when a receiver is presumably Date(Time)' do + expect_offense(<<~RUBY) + expiration_time = purchase.created_at.since(ticket.expires_in.seconds) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use ActiveSupport::Duration#since as a receiver for relative date like `ticket.expires_in.seconds.since(purchase.created_at)`. + RUBY + + expect_correction(<<~RUBY) + expiration_time = ticket.expires_in.seconds.since(purchase.created_at) + RUBY + end +end