Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cop to forbid T::Struct uses and autocorrect them to bare classes #178

Merged
merged 1 commit into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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