lib/rubocop/cop/sorbet/buggy_obsolete_strict_memoization.rb



# frozen_string_literal: true

require "rubocop"

module RuboCop
  module Cop
    module Sorbet
      # 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.
      #
      # @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 `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
      #
      # @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

        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 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 = ...
              (send                                                   # T.let(_ivar, T.nilable(_ivar_type))
                (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)
          buggy_legacy_memoization_pattern?(node) do |ivar, nil_node|
            add_offense(nil_node) do |corrector|
              corrector.replace(
                range_between(nil_node.source_range.begin_pos, nil_node.source_range.end_pos),
                ivar,
              )
            end
          end
        end

        def relevant_file?(file)
          super && sorbet_enabled?
        end
      end
    end
  end
end