Skip to content

Commit 94cdc63

Browse files
sambostockpirj
authored andcommitted
Add Timecop cop
This cop makes `Timecop` illegal, in favour of `ActiveSupport::Testing::TimeHelpers`. Specifically, - `Timecop.freeze` should be replaced with `freeze_time` (autocorrected) - `Timecop.freeze(...)` should be replaced with `travel` or `travel_to` - `Timecop.return` should be replaced with `travel_back` or `unfreeze_time` (autocorrected) - `Timecop.scale` & `Timecop.travel` should be replaced with `travel` or `travel_to`. - Explicitly travelling again should be used instead of relying on time continuing to flow - `Timecop` should not appear anywhere
1 parent 41a3bd4 commit 94cdc63

File tree

3 files changed

+377
-0
lines changed

3 files changed

+377
-0
lines changed

changelog/new_add_timecop_cop.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#38](https://github.com/rubocop/rubocop-rails/pull/38): Add `Timecop` cop. ([@sambostock][])

lib/rubocop/cop/rails/timecop.rb

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module Rails
6+
# Disallows all usage of `Timecop`, in favour of
7+
# `ActiveSupport::Testing::TimeHelpers`.
8+
#
9+
# ## Migration
10+
# `Timecop.freeze` should be replaced with `freeze_time` when used
11+
# without arguments. Where a `duration` has been passed to `freeze`, it
12+
# should be replaced with `travel`. Likewise, where a `time` has been
13+
# passed to `freeze`, it should be replaced with `travel_to`.
14+
#
15+
# `Timecop.scale` should be replaced by explicitly calling `travel` or
16+
# `travel_to` with the expected `durations` or `times`, respectively,
17+
# rather than relying on allowing time to continue to flow.
18+
#
19+
# `Timecop.return` should be replaced with `travel_back`, when used
20+
# without a block. `travel_back` does not accept a block, so where
21+
# `return` is used with a block, it should be replaced by explicitly
22+
# calling `freeze_time` with a block, and passing the `time` to
23+
# temporarily return to.
24+
#
25+
# `Timecop.travel` should be replaced by `travel` or `travel_to` when
26+
# passed a `duration` or `time`, respectively. As with `Timecop.scale`,
27+
# rather than relying on time continuing to flow, it should be travelled
28+
# to explicitly.
29+
#
30+
# All other usages of `Timecop` are similarly disallowed.
31+
#
32+
# ## RSpec Caveats
33+
#
34+
# Note that if using RSpec, `TimeHelpers` are not included by default,
35+
# and must be manually included by updating `rails_helper` accordingly:
36+
#
37+
# ```ruby
38+
# RSpec.configure do |config|
39+
# config.include ActiveSupport::Testing::TimeHelpers
40+
# end
41+
# ```
42+
#
43+
# Moreover, because `TimeHelpers` relies on Minitest teardown hooks,
44+
# `rails_helper` must be required (instead of `spec_helper`), or a
45+
# similar adapter layer must be in effect.
46+
#
47+
# @example
48+
# # bad
49+
# Timecop
50+
#
51+
# # bad
52+
# Timecop.freeze
53+
# Timecop.freeze(duration)
54+
# Timecop.freeze(time)
55+
#
56+
# # good
57+
# freeze_time
58+
# travel(duration)
59+
# travel_to(time)
60+
#
61+
# # bad
62+
# Timecop.freeze { assert true }
63+
# Timecop.freeze(duration) { assert true }
64+
# Timecop.freeze(time) { assert true }
65+
#
66+
# # good
67+
# freeze_time { assert true }
68+
# travel(duration) { assert true }
69+
# travel_to(time) { assert true }
70+
#
71+
# # bad
72+
# Timecop.travel(duration)
73+
# Timecop.travel(time)
74+
#
75+
# # good
76+
# travel(duration)
77+
# travel_to(time)
78+
#
79+
# # bad
80+
# Timecop.return
81+
# Timecop.return { assert true }
82+
#
83+
# # good
84+
# travel_back
85+
# travel_to(time) { assert true }
86+
#
87+
# # bad
88+
# Timecop.scale(factor)
89+
# Timecop.scale(factor) { assert true }
90+
#
91+
# # good
92+
# travel(duration)
93+
# travel_to(time)
94+
# travel(duration) { assert true }
95+
# travel_to(time) { assert true }
96+
class Timecop < Base
97+
extend AutoCorrector
98+
99+
FREEZE_MESSAGE = 'Use `%<replacement>s` instead of `Timecop.freeze`'
100+
FREEZE_WITH_ARGUMENTS_MESSAGE = 'Use `travel` or `travel_to` instead of `Timecop.freeze`'
101+
RETURN_MESSAGE = 'Use `%<replacement>s` instead of `Timecop.return`'
102+
FLOW_ADDENDUM = 'If you need time to keep flowing, simulate it by travelling again.'
103+
TRAVEL_MESSAGE = "Use `travel` or `travel_to` instead of `Timecop.travel`. #{FLOW_ADDENDUM}"
104+
SCALE_MESSAGE = "Use `travel` or `travel_to` instead of `Timecop.scale`. #{FLOW_ADDENDUM}"
105+
MSG = 'Use `ActiveSupport::Testing::TimeHelpers` instead of `Timecop`'
106+
107+
def_node_matcher :timecop_const?, <<~PATTERN
108+
(const {nil? cbase} :Timecop)
109+
PATTERN
110+
111+
def_node_matcher :timecop_send, <<~PATTERN
112+
(send
113+
#timecop_const? ${:freeze :return :scale :travel}
114+
$...
115+
)
116+
PATTERN
117+
118+
def on_const(node)
119+
return unless timecop_const?(node)
120+
121+
timecop_send(node.parent) do |message, arguments|
122+
return on_timecop_send(node.parent, message, arguments)
123+
end
124+
125+
add_offense(node)
126+
end
127+
128+
private
129+
130+
def on_timecop_send(node, message, arguments)
131+
case message
132+
when :freeze then on_timecop_freeze(node, arguments)
133+
when :return then on_timecop_return(node, arguments)
134+
when :scale then on_timecop_scale(node, arguments)
135+
when :travel then on_timecop_travel(node, arguments)
136+
else add_offense(node)
137+
end
138+
end
139+
140+
def on_timecop_freeze(node, arguments)
141+
if arguments.empty?
142+
add_offense(node, message: format(FREEZE_MESSAGE, replacement: preferred_freeze_replacement)) do |corrector|
143+
autocorrect_freeze(corrector, node, arguments)
144+
end
145+
else
146+
add_offense(node, message: FREEZE_WITH_ARGUMENTS_MESSAGE)
147+
end
148+
end
149+
150+
def on_timecop_return(node, arguments)
151+
add_offense(node, message: format(RETURN_MESSAGE, replacement: preferred_return_replacement)) do |corrector|
152+
autocorrect_return(corrector, node, arguments)
153+
end
154+
end
155+
156+
def on_timecop_scale(node, _arguments)
157+
add_offense(node, message: SCALE_MESSAGE)
158+
end
159+
160+
def on_timecop_travel(node, _arguments)
161+
add_offense(node, message: TRAVEL_MESSAGE)
162+
end
163+
164+
def autocorrect_freeze(corrector, node, arguments)
165+
return unless arguments.empty?
166+
167+
corrector.replace(receiver_and_message_range(node), preferred_freeze_replacement)
168+
end
169+
170+
def autocorrect_return(corrector, node, _arguments)
171+
return if given_block?(node)
172+
173+
corrector.replace(receiver_and_message_range(node), preferred_return_replacement)
174+
end
175+
176+
def given_block?(node)
177+
node.send_type? && node.parent && node.parent.block_type? && node.parent.send_node == node
178+
end
179+
180+
def receiver_and_message_range(node)
181+
node.source_range.with(end_pos: node.location.selector.end_pos)
182+
end
183+
184+
def preferred_freeze_replacement
185+
return 'travel_to(Time.now)' if target_rails_version < 5.2
186+
187+
'freeze_time'
188+
end
189+
190+
def preferred_return_replacement
191+
return 'travel_back' if target_rails_version < 6.0
192+
193+
'unfreeze_time'
194+
end
195+
end
196+
end
197+
end
198+
end
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe RuboCop::Cop::Rails::Timecop, :config do
4+
shared_examples 'adds an offense to constant, and does not correct' do |usage:|
5+
constant = usage.include?('::Timecop') ? '::Timecop' : 'Timecop'
6+
7+
it 'adds an offense, and does not correct' do
8+
expect_offense(<<~RUBY, constant: constant)
9+
#{usage}
10+
^{constant} Use `ActiveSupport::Testing::TimeHelpers` instead of `Timecop`
11+
RUBY
12+
13+
expect_no_corrections
14+
end
15+
end
16+
17+
describe 'Timecop' do
18+
include_examples 'adds an offense to constant, and does not correct', usage: 'Timecop'
19+
20+
describe '.*' do
21+
include_examples 'adds an offense to constant, and does not correct', usage: 'Timecop.foo'
22+
end
23+
24+
shared_examples 'adds an offense to send, and does not correct' do |usage:, include_time_flow_addendum: false|
25+
usage_without_arguments = usage.sub(/\(.*\)$/, '')
26+
addendum =
27+
include_time_flow_addendum ? '. If you need time to keep flowing, simulate it by travelling again.' : ''
28+
29+
context 'given no block' do
30+
it 'adds an offense, and does not correct' do
31+
expect_offense(<<~RUBY, usage: usage)
32+
#{usage}
33+
^{usage} Use `travel` or `travel_to` instead of `#{usage_without_arguments}`#{addendum}
34+
RUBY
35+
36+
expect_no_corrections
37+
end
38+
end
39+
40+
context 'given a block' do
41+
it 'adds an offense, and does not correct' do
42+
expect_offense(<<~RUBY, usage: usage)
43+
#{usage} { assert true }
44+
^{usage} Use `travel` or `travel_to` instead of `#{usage_without_arguments}`#{addendum}
45+
RUBY
46+
47+
expect_no_corrections
48+
end
49+
end
50+
end
51+
52+
describe '.freeze' do
53+
context 'without arguments' do
54+
shared_examples 'adds an offense and corrects to' do |replacement:|
55+
context 'given no block' do
56+
it "adds an offense, and corrects to `#{replacement}`" do
57+
expect_offense(<<~RUBY)
58+
Timecop.freeze
59+
^^^^^^^^^^^^^^ Use `#{replacement}` instead of `Timecop.freeze`
60+
RUBY
61+
62+
expect_correction(<<~RUBY)
63+
#{replacement}
64+
RUBY
65+
end
66+
end
67+
68+
context 'given a block' do
69+
it "adds an offense, and corrects to `#{replacement}`" do
70+
expect_offense(<<~RUBY)
71+
Timecop.freeze { assert true }
72+
^^^^^^^^^^^^^^ Use `#{replacement}` instead of `Timecop.freeze`
73+
RUBY
74+
75+
expect_correction(<<~RUBY)
76+
#{replacement} { assert true }
77+
RUBY
78+
end
79+
end
80+
end
81+
82+
context 'prior to Rails 5.2', :rails51 do
83+
include_examples 'adds an offense and corrects to', replacement: 'travel_to(Time.now)'
84+
end
85+
86+
context 'since Rails 5.2', :rails52 do
87+
include_examples 'adds an offense and corrects to', replacement: 'freeze_time'
88+
end
89+
end
90+
91+
context 'with arguments' do
92+
include_examples 'adds an offense to send, and does not correct', usage: 'Timecop.freeze(*time_args)'
93+
end
94+
end
95+
96+
describe '.return' do
97+
shared_examples 'prefers' do |replacement|
98+
context 'given no block' do
99+
it "adds an offense, and corrects to `#{replacement}`" do
100+
expect_offense(<<~RUBY)
101+
Timecop.return
102+
^^^^^^^^^^^^^^ Use `#{replacement}` instead of `Timecop.return`
103+
RUBY
104+
105+
expect_correction(<<~RUBY)
106+
#{replacement}
107+
RUBY
108+
end
109+
110+
context 'inside a block' do
111+
it "adds an offense, and corrects to `#{replacement}`" do
112+
expect_offense(<<~RUBY)
113+
foo { Timecop.return }
114+
^^^^^^^^^^^^^^ Use `#{replacement}` instead of `Timecop.return`
115+
RUBY
116+
117+
expect_correction(<<~RUBY)
118+
foo { #{replacement} }
119+
RUBY
120+
end
121+
end
122+
end
123+
124+
context 'given a block' do
125+
it 'adds an offense, and does not correct' do
126+
expect_offense(<<~RUBY)
127+
Timecop.return { assert true }
128+
^^^^^^^^^^^^^^ Use `#{replacement}` instead of `Timecop.return`
129+
RUBY
130+
131+
expect_no_corrections
132+
end
133+
134+
context 'inside a block' do
135+
it 'adds an offense, and does not correct' do
136+
expect_offense(<<~RUBY)
137+
foo { Timecop.return { assert true } }
138+
^^^^^^^^^^^^^^ Use `#{replacement}` instead of `Timecop.return`
139+
RUBY
140+
141+
expect_no_corrections
142+
end
143+
end
144+
end
145+
end
146+
147+
context 'prior to Rails < 6.0', :rails52 do
148+
include_examples 'prefers', 'travel_back'
149+
end
150+
151+
context 'since Rails 6.0', :rails60 do
152+
include_examples 'prefers', 'unfreeze_time'
153+
end
154+
end
155+
156+
describe '.scale' do
157+
include_examples 'adds an offense to send, and does not correct', usage: 'Timecop.scale(factor)',
158+
include_time_flow_addendum: true
159+
end
160+
161+
describe '.travel' do
162+
include_examples 'adds an offense to send, and does not correct', usage: 'Timecop.travel(*time_args)',
163+
include_time_flow_addendum: true
164+
end
165+
end
166+
167+
describe '::Timecop' do
168+
include_examples 'adds an offense to constant, and does not correct', usage: '::Timecop'
169+
end
170+
171+
describe 'Foo::Timecop' do
172+
it 'adds no offenses' do
173+
expect_no_offenses(<<~RUBY)
174+
Foo::Timecop
175+
RUBY
176+
end
177+
end
178+
end

0 commit comments

Comments
 (0)