Skip to content

Commit 873ad73

Browse files
committed
Add new Rails/RelativeDateGrammar cop
This PR adds new `Rails/RelativeDateGrammar` cop. It checks whether the word orders of relative dates are grammatically easy to understand. This check includes detecting undefined methods on Date(Time) objects. ```ruby # bad tomorrow = Time.current.since(1.day) # good tomorrow = 1.day.since(Time.current) ```
1 parent baf39e6 commit 873ad73

File tree

5 files changed

+99
-0
lines changed

5 files changed

+99
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#1106](https://github.com/rubocop/rubocop-rails/pull/1106): Add new `Rails/RelativeDateGrammar` cop. ([@aeroastro][])

config/default.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,12 @@ Rails/RelativeDateConstant:
861861
VersionAdded: '0.48'
862862
VersionChanged: '2.13'
863863

864+
Rails/RelativeDateGrammar:
865+
Description: 'Use ActiveSupport::Duration as a receiver for a relative date like `1.day.since(Time.current)`.'
866+
Enabled: pending
867+
Safe: false
868+
VersionAdded: '<<next>>'
869+
864870
Rails/RenderInline:
865871
Description: 'Prefer using a template over inline rendering.'
866872
StyleGuide: 'https://rails.rubystyle.guide/#inline-rendering'
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module Rails
6+
# Checks whether the word orders of relative dates are grammatically easy to understand.
7+
# This check includes detecting undefined methods on Date(Time) objects.
8+
#
9+
# @safety
10+
# This cop is unsafe because it avoids strict checking of receivers' types,
11+
# ActiveSupport::Duration and Date(Time) respectively.
12+
#
13+
# @example
14+
# # bad
15+
# tomorrow = Time.current.since(1.day)
16+
#
17+
# # good
18+
# tomorrow = 1.day.since(Time.current)
19+
class RelativeDateGrammar < Base
20+
extend AutoCorrector
21+
22+
MSG = 'Use ActiveSupport::Duration#%<relation>s as a receiver ' \
23+
'for relative date like `%<duration>s.%<relation>s(%<date>s)`.'
24+
25+
RELATIVE_DATE_METHODS = %i[since from_now after ago until before].to_set.freeze
26+
DURATION_METHODS = %i[second seconds minute minutes hour hours
27+
day days week weeks month months year years].to_set.freeze
28+
29+
RESTRICT_ON_SEND = RELATIVE_DATE_METHODS.to_a.freeze
30+
31+
def_node_matcher :inverted_relative_date?, <<~PATTERN
32+
(send
33+
$!nil?
34+
$RELATIVE_DATE_METHODS
35+
$(send
36+
!nil?
37+
$DURATION_METHODS
38+
)
39+
)
40+
PATTERN
41+
42+
def on_send(node)
43+
inverted_relative_date?(node) do |date, relation, duration|
44+
message = format(MSG, date: date.source, relation: relation.to_s, duration: duration.source)
45+
add_offense(node, message: message) do |corrector|
46+
autocorrect(corrector, node, date, relation, duration)
47+
end
48+
end
49+
end
50+
51+
private
52+
53+
def autocorrect(corrector, node, date, relation, duration)
54+
new_code = ["#{duration.source}.#{relation}(#{date.source})"]
55+
corrector.replace(node, new_code)
56+
end
57+
end
58+
end
59+
end
60+
end

lib/rubocop/cop/rails_cops.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
require_relative 'rails/reflection_class_name'
9898
require_relative 'rails/refute_methods'
9999
require_relative 'rails/relative_date_constant'
100+
require_relative 'rails/relative_date_grammar'
100101
require_relative 'rails/render_inline'
101102
require_relative 'rails/render_plain_text'
102103
require_relative 'rails/request_referer'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe RuboCop::Cop::Rails::RelativeDateGrammar, :config do
4+
it 'accepts ActiveSupport::Duration as a receiver (ActiveSupport::Duration#since)' do
5+
expect_no_offenses(<<~RUBY)
6+
yesterday = 1.day.since(Time.current)
7+
RUBY
8+
end
9+
10+
it 'registers an offense for Date(Time) as a receiver (ActiveSupport::TimeWithZone#ago)' do
11+
expect_offense(<<~RUBY)
12+
last_week = Time.current.ago(1.week)
13+
^^^^^^^^^^^^^^^^^^^^^^^^ Use ActiveSupport::Duration#ago as a receiver for relative date like `1.week.ago(Time.current)`.
14+
RUBY
15+
16+
expect_correction(<<~RUBY)
17+
last_week = 1.week.ago(Time.current)
18+
RUBY
19+
end
20+
21+
it 'registers an offense when a receiver is presumably Date(Time)' do
22+
expect_offense(<<~RUBY)
23+
expiration_time = purchase.created_at.since(ticket.expires_in.seconds)
24+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use ActiveSupport::Duration#since as a receiver for relative date like `ticket.expires_in.seconds.since(purchase.created_at)`.
25+
RUBY
26+
27+
expect_correction(<<~RUBY)
28+
expiration_time = ticket.expires_in.seconds.since(purchase.created_at)
29+
RUBY
30+
end
31+
end

0 commit comments

Comments
 (0)