Skip to content

Commit

Permalink
Introduce BuggyObsoleteStrictMemoization cop
Browse files Browse the repository at this point in the history
  • Loading branch information
amomchilov committed Aug 31, 2023
1 parent dc4828a commit 0ab5e8c
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 260 deletions.
17 changes: 17 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,23 @@ Sorbet/ObsoleteStrictMemoization:
Safe: true
SafeAutoCorrect: true

Sorbet/BuggyObsoleteStrictMemoization:
Description: >-
Checks for the a mistaken variant of the "obsolete memoization pattern" that used to be required
for older Sorbet versions in `#typed: strict` files. The mistaken variant would overwrite the ivar with `nil`
on every call, causing the memoized value to be discarded and recomputed on every call.
This cop will correct it to read from the ivar instead of `nil`, which will memoize it correctly.
The result of this correction will be the "obsolete memoization pattern", which can further be corrected by
the `Sorbet/ObsoleteStrictMemoization` cop.
See `Sorbet/ObsoleteStrictMemoization` for more details.
Enabled: true
VersionAdded: '0.7.3'
Safe: true
SafeAutoCorrect: false

Sorbet/OneAncestorPerLine:
Description: 'Enforces one ancestor per call to requires_ancestor'
Enabled: false
Expand Down
77 changes: 34 additions & 43 deletions lib/rubocop/cop/sorbet/buggy_obsolete_strict_memoization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,86 +5,77 @@
module RuboCop
module Cop
module Sorbet
# Checks for the obsolete pattern for initializing instance variables that was required for older Sorbet
# versions in `#typed: strict` files.
# Checks for the a mistaken variant of the "obsolete memoization pattern" that used to be required
# for older Sorbet versions in `#typed: strict` files. The mistaken variant would overwrite the ivar with `nil`
# on every call, causing the memoized value to be discarded and recomputed on every call.
#
# It's no longer required, as of Sorbet 0.5.10210
# See https://sorbet.org/docs/type-assertions#put-type-assertions-behind-memoization
# This cop will correct it to read from the ivar instead of `nil`, which will memoize it correctly.
#
# @example
# The result of this correction will be the "obsolete memoization pattern", which can further be corrected by
# the `Sorbet/ObsoleteStrictMemoization` cop.
#
# # bad
# sig { returns(Foo) }
# def foo
# @foo = T.let(@foo, T.nilable(Foo))
# @foo ||= Foo.new
# end
# See `Sorbet/ObsoleteStrictMemoization` for more details.
#
# @safety
# If the computation being memoized had side effects, calling it only once (instead of once on every call
# to the affected method) can be observed, and might be a breaking change.
#
# @example
# # bad
# sig { returns(Foo) }
# def foo
# # This would have been a mistake, causing the memoized value to be discarded and recomputed on every call.
# # This `nil` is likely a mistake, causing the memoized value to be discarded and recomputed on every call.
# @foo = T.let(nil, T.nilable(Foo))
# @foo ||= Foo.new
# @foo ||= some_computation
# end
#
# # good
# sig { returns(Foo) }
# def foo
# @foo ||= T.let(Foo.new, T.nilable(Foo))
# # This will now memoize the value as was likely intended, so `some_computation` is only ever called once.
# # ⚠️If `some_computation` has side effects, this might be a breaking change!
# @foo = T.let(@foo, T.nilable(Foo))
# @foo ||= some_computation
# end
#
class ObsoleteStrictMemoization < RuboCop::Cop::Base
# @see Sorbet/ObsoleteStrictMemoization
class BuggyObsoleteStrictMemoization < RuboCop::Cop::Base
include RuboCop::Cop::MatchRange
include RuboCop::Cop::Alignment
include RuboCop::Cop::LineLengthHelp
include RuboCop::Cop::RangeHelp
extend AutoCorrector

include TargetSorbetVersion
minimum_target_sorbet_static_version "0.5.10210"

MSG = "This two-stage workaround for memoization in `#typed: strict` files is no longer necessary. " \
"See https://sorbet.org/docs/type-assertions#put-type-assertions-behind-memoization."
MSG = "This might be a mistaken variant of the two-stage workaround that used to be needed for memoization in "\
"`#typed: strict` files. See https://sorbet.org/docs/type-assertions#put-type-assertions-behind-memoization."

# @!method legacy_memoization_pattern?(node)
def_node_matcher :legacy_memoization_pattern?, <<~PATTERN
# @!method buggy_legacy_memoization_pattern?(node)
def_node_matcher :buggy_legacy_memoization_pattern?, <<~PATTERN
(begin
... # Ignore any other lines that come first.
$(ivasgn $_ivar # First line: @_ivar = ...
(ivasgn $_ivar # First line: @_ivar = ...
(send # T.let(_ivar, T.nilable(_ivar_type))
$(const {nil? cbase} :T) :let
{(ivar _ivar) nil}
(send (const {nil? cbase} :T) :nilable $_ivar_type))) # T.nilable(_ivar_type)
$(or-asgn (ivasgn _ivar) $_initialization_expr)) # Second line: @_ivar ||= _initialization_expr
(const {nil? cbase} :T) :let
$nil
(send (const {nil? cbase} :T) :nilable _ivar_type))) # T.nilable(_ivar_type)
(or-asgn (ivasgn _ivar) _initialization_expr)) # Second line: @_ivar ||= _initialization_expr
PATTERN

def on_begin(node)
legacy_memoization_pattern?(node) do |first_asgn_node, ivar, t, ivar_type, second_or_asgn_node, init_expr| # rubocop:disable Metrics/ParameterLists
add_offense(first_asgn_node) do |corrector|
indent = offset(node)
correction = "#{ivar} ||= #{t.source}.let(#{init_expr.source}, #{t.source}.nilable(#{ivar_type.source}))"

# We know good places to put line breaks, if required.
if line_length(indent + correction) > max_line_length || correction.include?("\n")
correction = <<~RUBY.chomp
#{ivar} ||= #{t.source}.let(
#{indent} #{init_expr.source.gsub("\n", "\n#{indent}")},
#{indent} #{t.source}.nilable(#{ivar_type.source.gsub("\n", "\n#{indent}")}),
#{indent})
RUBY
end

buggy_legacy_memoization_pattern?(node) do |ivar, nil_node|
add_offense(nil_node) do |corrector|
corrector.replace(
range_between(first_asgn_node.source_range.begin_pos, second_or_asgn_node.source_range.end_pos),
correction,
range_between(nil_node.source_range.begin_pos, nil_node.source_range.end_pos),
ivar,
)
end
end
end

def relevant_file?(file)
super && enabled_for_sorbet_static_version?
super && sorbet_enabled?
end
end
end
Expand Down
10 changes: 7 additions & 3 deletions lib/rubocop/cop/sorbet/mixin/target_sorbet_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,25 @@ def included(target)
end

module ClassMethods
# The version of the Sorbet static type checker required by this cop
# Sets the version of the Sorbet static type checker required by this cop
def minimum_target_sorbet_static_version(version)
@minimum_target_sorbet_static_version = Gem::Version.new(version)
end

def support_target_sorbet_static_version?(version)
def supports_target_sorbet_static_version?(version)
@minimum_target_sorbet_static_version <= Gem::Version.new(version)
end
end

def sorbet_enabled?
!target_sorbet_static_version_from_bundler_lock_file.nil?
end

def enabled_for_sorbet_static_version?
sorbet_static_version = target_sorbet_static_version_from_bundler_lock_file
return false unless sorbet_static_version

self.class.support_target_sorbet_static_version?(sorbet_static_version)
self.class.supports_target_sorbet_static_version?(sorbet_static_version)
end

def target_sorbet_static_version_from_bundler_lock_file
Expand Down
2 changes: 1 addition & 1 deletion lib/rubocop/cop/sorbet/obsolete_strict_memoization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class ObsoleteStrictMemoization < RuboCop::Cop::Base
$(ivasgn $_ivar # First line: @_ivar = ...
(send # T.let(_ivar, T.nilable(_ivar_type))
$(const {nil? cbase} :T) :let
{(ivar _ivar) nil}
(ivar _ivar)
(send (const {nil? cbase} :T) :nilable $_ivar_type))) # T.nilable(_ivar_type)
$(or-asgn (ivasgn _ivar) $_initialization_expr)) # Second line: @_ivar ||= _initialization_expr
PATTERN
Expand Down
1 change: 1 addition & 0 deletions lib/rubocop/cop/sorbet_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
require_relative "sorbet/redundant_extend_t_sig"
require_relative "sorbet/type_alias_name"
require_relative "sorbet/obsolete_strict_memoization"
require_relative "sorbet/buggy_obsolete_strict_memoization"

require_relative "sorbet/rbi/forbid_extend_t_sig_helpers_in_shims"
require_relative "sorbet/rbi/forbid_rbi_outside_of_allowed_paths"
Expand Down
1 change: 1 addition & 0 deletions manual/cops.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ In the following section you find all available cops:

* [Sorbet/AllowIncompatibleOverride](cops_sorbet.md#sorbetallowincompatibleoverride)
* [Sorbet/BindingConstantWithoutTypeAlias](cops_sorbet.md#sorbetbindingconstantwithouttypealias)
* [Sorbet/BuggyObsoleteStrictMemoization](cops_sorbet.md#sorbetbuggyobsoletestrictmemoization)
* [Sorbet/CallbackConditionalsBinding](cops_sorbet.md#sorbetcallbackconditionalsbinding)
* [Sorbet/CheckedTrueInSignature](cops_sorbet.md#sorbetcheckedtrueinsignature)
* [Sorbet/ConstantsFromStrings](cops_sorbet.md#sorbetconstantsfromstrings)
Expand Down
38 changes: 38 additions & 0 deletions manual/cops_sorbet.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,44 @@ FooOrBar = T.any(Foo, Bar)
FooOrBar = T.type_alias { T.any(Foo, Bar) }
```

## Sorbet/BuggyObsoleteStrictMemoization

Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged
--- | --- | --- | --- | ---
Enabled | Yes | Yes (Unsafe) | 0.7.3 | -

Checks for the a mistaken variant of the "obsolete memoization pattern" that used to be required
for older Sorbet versions in `#typed: strict` files. The mistaken variant would overwrite the ivar with `nil`
on every call, causing the memoized value to be discarded and recomputed on every call.

This cop will correct it to read from the ivar instead of `nil`, which will memoize it correctly.

The result of this correction will be the "obsolete memoization pattern", which can further be corrected by
the `Sorbet/ObsoleteStrictMemoization` cop.

See `Sorbet/ObsoleteStrictMemoization` for more details.

### Examples

```ruby
# bad
sig { returns(Foo) }
def foo
# This `nil` is likely a mistake, causing the memoized value to be discarded and recomputed on every call.
@foo = T.let(nil, T.nilable(Foo))
@foo ||= some_computation
end

# good
sig { returns(Foo) }
def foo
# This will now memoize the value as was likely intended, so `some_computation` is only ever called once.
# ⚠️If `some_computation` has side effects, this might be a breaking change!
@foo = T.let(@foo, T.nilable(Foo))
@foo ||= some_computation
end
```

## Sorbet/CallbackConditionalsBinding

Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged
Expand Down

0 comments on commit 0ab5e8c

Please sign in to comment.