Skip to content

Commit

Permalink
Merge pull request #178 from Shopify/at-tstruct-autocorrect
Browse files Browse the repository at this point in the history
Add cop to forbid T::Struct uses and autocorrect them to bare classes
  • Loading branch information
Morriar committed Sep 25, 2023
2 parents d2bd9e2 + f200f04 commit d245285
Show file tree
Hide file tree
Showing 6 changed files with 649 additions and 0 deletions.
7 changes: 7 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ Sorbet/ForbidSuperclassConstLiteral:
Exclude:
- db/migrate/*.rb

Sorbet/ForbidTStruct:
Description: 'Forbid usage of T::Struct.'
Enabled: false
VersionAdded: <<next>>
VersionChanged: <<next>>
Safe: false

Sorbet/ForbidTUnsafe:
Description: 'Forbid usage of T.unsafe.'
Enabled: false
Expand Down
223 changes: 223 additions & 0 deletions lib/rubocop/cop/sorbet/forbid_t_struct.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# frozen_string_literal: true

require "rubocop"

module RuboCop
module Cop
module Sorbet
# Disallow using `T::Struct` and `T::Props`.
#
# @example
#
# # bad
# class MyStruct < T::Struct
# const :foo, String
# prop :bar, Integer, default: 0
#
# def some_method; end
# end
#
# # good
# class MyStruct
# extend T::Sig
#
# sig { returns(String) }
# attr_reader :foo
#
# sig { returns(Integer) }
# attr_accessor :bar
#
# sig { params(foo: String, bar: Integer) }
# def initialize(foo:, bar: 0)
# @foo = foo
# @bar = bar
# end
#
# def some_method; end
# end
class ForbidTStruct < RuboCop::Cop::Base
include Alignment
include RangeHelp
include CommentsHelp
extend AutoCorrector

RESTRICT_ON_SEND = [:include, :prepend, :extend].freeze

MSG_STRUCT = "Using `T::Struct` or its variants is deprecated."
MSG_PROPS = "Using `T::Props` or its variants is deprecated."

# This class walks down the class body of a T::Struct and collects all the properties that will need to be
# translated into `attr_reader` and `attr_accessor` methods.
class TStructWalker
include AST::Traversal
extend AST::NodePattern::Macros

attr_reader :props, :has_extend_t_sig

def initialize
@props = []
@has_extend_t_sig = false
end

# @!method extend_t_sig?(node)
def_node_matcher :extend_t_sig?, <<~PATTERN
(send _ :extend (const (const {nil? | cbase} :T) :Sig))
PATTERN

# @!method t_struct_prop?(node)
def_node_matcher(:t_struct_prop?, <<~PATTERN)
(send nil? {:const :prop} ...)
PATTERN

def on_send(node)
if extend_t_sig?(node)
# So we know we won't need to generate again a `extend T::Sig` line in the new class body
@has_extend_t_sig = true
return
end

return unless t_struct_prop?(node)

kind = node.method?(:const) ? :attr_reader : :attr_accessor
name = node.arguments[0].source.delete_prefix(":")
type = node.arguments[1].source
default = nil
factory = nil

node.arguments[2..-1].each do |arg|
next unless arg.hash_type?

arg.each_pair do |key, value|
case key.source
when "default"
default = value.source
when "factory"
factory = value.source
end
end
end

@props << Property.new(node, kind, name, type, default: default, factory: factory)
end
end

class Property
attr_reader :node, :kind, :name, :type, :default, :factory

def initialize(node, kind, name, type, default:, factory:)
@node = node
@kind = kind
@name = name
@type = type
@default = default
@factory = factory

# A T::Struct should have both a default and a factory, if we find one let's raise an error
raise if @default && @factory
end

def attr_sig
"sig { returns(#{type}) }"
end

def attr_accessor
"#{kind} :#{name}"
end

def initialize_sig_param
"#{name}: #{type}"
end

def initialize_param
rb = String.new
rb << "#{name}:"
rb << " #{default}" if default
rb << " #{factory}" if factory
rb
end

def initialize_assign
rb = String.new
rb << "@#{name} = #{name}"
rb << ".call" if factory
rb
end
end

# @!method t_struct?(node)
def_node_matcher(:t_struct?, <<~PATTERN)
(const (const {nil? cbase} :T) {:Struct :ImmutableStruct :InexactStruct})
PATTERN

# @!method t_props?(node)
def_node_matcher(:t_props?, "(send nil? {:include :prepend :extend} `(const (const {nil? cbase} :T) :Props))")

def on_class(node)
return unless t_struct?(node.parent_class)

add_offense(node, message: MSG_STRUCT) do |corrector|
walker = TStructWalker.new
walker.walk(node.body)

range = range_between(node.identifier.source_range.end_pos, node.parent_class.source_range.end_pos)
corrector.remove(range)
next if node.single_line?

unless walker.has_extend_t_sig
indent = offset(node)
corrector.insert_after(node.identifier, "\n#{indent} extend T::Sig\n")
end

first_prop = walker.props.first
walker.props.each do |prop|
node = prop.node
indent = offset(node)
line_range = range_by_whole_lines(prop.node.source_range)
new_line = prop != first_prop && !previous_line_blank?(node)
trailing_comments = processed_source.each_comment_in_lines(line_range.line..line_range.line)

corrector.replace(
line_range,
"#{new_line ? "\n" : ""}" \
"#{trailing_comments.map { |comment| "#{indent}#{comment.text}\n" }.join}" \
"#{indent}#{prop.attr_sig}\n#{indent}#{prop.attr_accessor}",
)
end

last_prop = walker.props.last
if last_prop
indent = offset(last_prop.node)
line_range = range_by_whole_lines(last_prop.node.source_range, include_final_newline: true)
corrector.insert_after(line_range, initialize_method(indent, walker.props))
end
end
end

def on_send(node)
return unless t_props?(node)

add_offense(node, message: MSG_PROPS)
end

private

def initialize_method(indent, props)
# We sort optional keyword arguments after required ones
props = props.sort_by { |prop| prop.default || prop.factory ? 1 : 0 }

string = +"\n"
string << "#{indent}sig { params(#{props.map(&:initialize_sig_param).join(", ")}).void }\n"
string << "#{indent}def initialize(#{props.map(&:initialize_param).join(", ")})\n"
props.each do |prop|
string << "#{indent} #{prop.initialize_assign}\n"
end
string << "#{indent}end\n"
end

def previous_line_blank?(node)
processed_source.buffer.source_line(node.source_range.line - 1).blank?
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/sorbet_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
require_relative "sorbet/implicit_conversion_method"
require_relative "sorbet/one_ancestor_per_line"
require_relative "sorbet/callback_conditionals_binding"
require_relative "sorbet/forbid_t_struct"
require_relative "sorbet/forbid_t_unsafe"
require_relative "sorbet/forbid_t_untyped"
require_relative "sorbet/redundant_extend_t_sig"
Expand Down
1 change: 1 addition & 0 deletions manual/cops.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ In the following section you find all available cops:
* [Sorbet/ForbidIncludeConstLiteral](cops_sorbet.md#sorbetforbidincludeconstliteral)
* [Sorbet/ForbidRBIOutsideOfAllowedPaths](cops_sorbet.md#sorbetforbidrbioutsideofallowedpaths)
* [Sorbet/ForbidSuperclassConstLiteral](cops_sorbet.md#sorbetforbidsuperclassconstliteral)
* [Sorbet/ForbidTStruct](cops_sorbet.md#sorbetforbidtstruct)
* [Sorbet/ForbidTUnsafe](cops_sorbet.md#sorbetforbidtunsafe)
* [Sorbet/ForbidTUntyped](cops_sorbet.md#sorbetforbidtuntyped)
* [Sorbet/ForbidUntypedStructProps](cops_sorbet.md#sorbetforbiduntypedstructprops)
Expand Down
39 changes: 39 additions & 0 deletions manual/cops_sorbet.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,45 @@ Name | Default value | Configurable values
--- | --- | ---
Exclude | `db/migrate/*.rb` | Array

## Sorbet/ForbidTStruct

Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged
--- | --- | --- | --- | ---
Disabled | No | Yes | <<next>> | <<next>>

Disallow using `T::Struct` and `T::Props`.

### Examples

```ruby
# bad
class MyStruct < T::Struct
const :foo, String
prop :bar, Integer, default: 0

def some_method; end
end

# good
class MyStruct
extend T::Sig

sig { returns(String) }
attr_reader :foo

sig { returns(Integer) }
attr_accessor :bar

sig { params(foo: String, bar: Integer) }
def initialize(foo:, bar: 0)
@foo = foo
@bar = bar
end

def some_method; end
end
```

## Sorbet/ForbidTUnsafe

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

0 comments on commit d245285

Please sign in to comment.