Skip to content

Commit

Permalink
Add new Rails/RelativeDateGrammar cop
Browse files Browse the repository at this point in the history
This PR adds new `Rails/RelativeDateGrammar` cop.

It checks whether the word orders of a relative dates
are grammatically easy to understand.

```ruby
# bad
tomorrow = Time.current.since(1.day)

# good
tomorrow = 1.day.since(Time.current)
```
  • Loading branch information
aeroastro committed Sep 7, 2023
1 parent baf39e6 commit 8e0dec9
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 0 deletions.
1 change: 1 addition & 0 deletions 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][])
6 changes: 6 additions & 0 deletions config/default.yml
Expand Up @@ -861,6 +861,12 @@ Rails/RelativeDateConstant:
VersionAdded: '0.48'
VersionChanged: '2.13'

Rails/RelativeDateGrammar:
Description: 'Prefer ActiveSupport::Duration as a receiver for a relative date like `1.day.since(Time.current)`.'
Enabled: pending
Safe: false
VersionAdded: '<<next>>'

Rails/RenderInline:
Description: 'Prefer using a template over inline rendering.'
StyleGuide: 'https://rails.rubystyle.guide/#inline-rendering'
Expand Down
59 changes: 59 additions & 0 deletions lib/rubocop/cop/rails/relative_date_grammar.rb
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Rails
# Checks whether the word orders of relative dates are grammatically easy to understand.
#
# @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 = 'Prefer ActiveSupport::Duration#%<relation>s as a receiver ' \
'for relative date like `%<duration>s.%<relation>s(%<date>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
1 change: 1 addition & 0 deletions lib/rubocop/cop/rails_cops.rb
Expand Up @@ -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'
Expand Down
31 changes: 31 additions & 0 deletions 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)
^^^^^^^^^^^^^^^^^^^^^^^^ Prefer 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)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer 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

0 comments on commit 8e0dec9

Please sign in to comment.