type_toolkit 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -1
  3. data/benchmark/.rubocop.yml +7 -0
  4. data/benchmark/abstract_methods_benchmark.rb +221 -0
  5. data/benchmark/interface_startup_performance.rb +128 -0
  6. data/benchmark/module_benchmark.rb +62 -0
  7. data/config/default.yml +5 -0
  8. data/docs/design/inherited_implementation_problem.md +119 -0
  9. data/docs/design/self_dot_methods.md +79 -0
  10. data/lib/rubocop/cop/type_toolkit/plugin.rb +2 -1
  11. data/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb +96 -0
  12. data/lib/rubocop-type_toolkit.rb +1 -0
  13. data/lib/type_toolkit/abstract_method_receiver.rb +39 -0
  14. data/lib/type_toolkit/dsl.rb +64 -0
  15. data/lib/type_toolkit/ext/method.rb +11 -0
  16. data/lib/type_toolkit/ext/module.rb +7 -0
  17. data/lib/type_toolkit/ext/nil_assertions.rb +3 -1
  18. data/lib/type_toolkit/has_abstract_methods.rb +108 -0
  19. data/lib/type_toolkit/interface.rb +38 -0
  20. data/lib/type_toolkit/method_def_recorder.rb +53 -0
  21. data/lib/type_toolkit/method_patch.rb +16 -0
  22. data/lib/type_toolkit/version.rb +1 -1
  23. data/lib/type_toolkit.rb +3 -0
  24. data/sorbet/config +3 -1
  25. data/sorbet/rbi/gems/benchmark-ips@2.14.0.rbi +981 -0
  26. data/sorbet/rbi/gems/{erb@6.0.1.rbi → erb@6.0.1.1.rbi} +2 -2
  27. data/sorbet/rbi/gems/{json@2.18.1.rbi → json@2.19.9.rbi} +115 -161
  28. data/sorbet/rbi/gems/minitest@5.27.0.rbi +707 -0
  29. data/sorbet/rbi/gems/rexml@3.4.4.rbi +0 -167
  30. data/sorbet/rbi/gems/rubocop-ast@1.49.0.rbi +6 -0
  31. data/sorbet/rbi/gems/rubocop@1.84.2.rbi +7 -0
  32. data/sorbet/rbi/gems/tapioca@0.17.10.rbi +1 -0
  33. data/sorbet/rbi/gems/{yard@0.9.38.rbi → yard@0.9.44.rbi} +1206 -241
  34. data/sorbet/rbi/shims/core.rbi +19 -0
  35. data/sorbet/rbi/shims/minitest.rbi +5 -2
  36. data/spec/interface_spec.rb +405 -0
  37. data/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb +229 -0
  38. data/spec/spec_helper.rb +14 -0
  39. metadata +23 -4
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "lint_roller"
5
+ require "type_toolkit/version"
5
6
 
6
7
  module RuboCop
7
8
  module Cop
@@ -12,7 +13,7 @@ module RuboCop
12
13
  name: "rubocop-type_toolkit",
13
14
  version: ::TypeToolkit::VERSION,
14
15
  homepage: "https://github.com/Shopify/type_toolkit",
15
- description: "Detects misuse of UnexpectedNilError.",
16
+ description: "RuboCop rules for Type Toolkit.",
16
17
  )
17
18
  end
18
19
 
@@ -0,0 +1,96 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module RuboCop
5
+ module Cop
6
+ module TypeToolkit
7
+ # Replaces Sorbet's `T.must(value)` assertion with Type Toolkit's `value.not_nil!` assertion.
8
+ class PreferNotNil < Base
9
+ extend AutoCorrector
10
+
11
+ MSG = "Use `.not_nil!` instead of `T.must()`."
12
+ RESTRICT_ON_SEND = [:must].freeze
13
+
14
+ COMMA_BYTE = ",".ord
15
+ private_constant :COMMA_BYTE
16
+
17
+ KEYWORD_EXPRESSION_TYPES = [:defined?, :super, :yield, :zsuper].freeze
18
+ private_constant :KEYWORD_EXPRESSION_TYPES
19
+
20
+ #: (RuboCop::AST::SendNode) -> void
21
+ def on_send(node)
22
+ return unless (argument = extract_t_must_argument(node))
23
+
24
+ if nested_t_must?(node)
25
+ add_offense(node, message: MSG)
26
+ else
27
+ replacement = replacement_for(argument)
28
+ correction = correction_for(node, argument, replacement)
29
+
30
+ add_offense(node, message: MSG) do |corrector|
31
+ corrector.replace(node, correction)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ #: (RuboCop::AST::SendNode) -> RuboCop::AST::Node?
39
+ def extract_t_must_argument(node)
40
+ receiver = node.receiver
41
+ return unless receiver.is_a?(RuboCop::AST::ConstNode)
42
+ return unless receiver.short_name == :T && node.method?(:must) && node.arguments.one?
43
+
44
+ namespace = receiver.namespace
45
+ return unless namespace.nil? || namespace.cbase_type?
46
+
47
+ argument = node.first_argument
48
+ return unless argument
49
+ return if argument.splat_type? || argument.kwsplat_type?
50
+
51
+ argument
52
+ end
53
+
54
+ #: (RuboCop::AST::Node) -> String
55
+ def replacement_for(argument)
56
+ source = argument.source
57
+ source = "(#{source})" if requires_parentheses?(argument)
58
+ "#{source}.not_nil!"
59
+ end
60
+
61
+ #: (RuboCop::AST::SendNode, RuboCop::AST::Node, String) -> String
62
+ def correction_for(node, argument, replacement)
63
+ return replacement unless node.multiline?
64
+
65
+ grouped_range = node.source_range.with(begin_pos: node.loc.begin.begin_pos, end_pos: node.loc.end.end_pos)
66
+ grouped_source = grouped_range.source
67
+ comma_offset = argument.source_range.end_pos - grouped_range.begin_pos
68
+ grouped_source.slice!(comma_offset) if grouped_source.getbyte(comma_offset) == COMMA_BYTE
69
+ "#{grouped_source}.not_nil!"
70
+ end
71
+
72
+ #: (RuboCop::AST::SendNode) -> bool
73
+ def nested_t_must?(node)
74
+ node.each_ancestor(:send).any? do |ancestor|
75
+ ancestor.is_a?(RuboCop::AST::SendNode) && extract_t_must_argument(ancestor)
76
+ end
77
+ end
78
+
79
+ #: (RuboCop::AST::Node) -> bool
80
+ def requires_parentheses?(argument)
81
+ return false if argument.begin_type?
82
+
83
+ if argument.is_a?(RuboCop::AST::SendNode)
84
+ return true if argument.operator_method?
85
+ return true if argument.arguments? && !argument.parenthesized_call?
86
+ end
87
+ return true if argument.range_type? || argument.operator_keyword?
88
+ return true if argument.if_type? || argument.assignment?
89
+ return true if argument.any_block_type?
90
+
91
+ KEYWORD_EXPRESSION_TYPES.include?(argument.type)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -3,3 +3,4 @@
3
3
  require "rubocop"
4
4
  require_relative "rubocop/cop/type_toolkit/plugin"
5
5
  require_relative "rubocop/cop/type_toolkit/dont_expect_unexpected_nil"
6
+ require_relative "rubocop/cop/type_toolkit/prefer_not_nil"
@@ -0,0 +1,39 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module TypeToolkit
5
+ # Raised when a call is made to an abstract method that never had a real implementation.
6
+ class AbstractMethodNotImplementedError < Exception # rubocop:disable Lint/InheritException
7
+ def initialize(method_name:)
8
+ # Do not rely on this message content! Its content is subject to change.
9
+ super("Abstract method `##{method_name}` was never implemented.")
10
+ end
11
+ end
12
+
13
+ # This module is included on a class whose instances can be receivers of calls to abstract methods.
14
+ #
15
+ # Since abstract methods are removed at runtime (see `TypeToolkit::DSL#abstract`), attempting to call
16
+ # an unimplemented abstract method would usually raise a `NoMethodError`.
17
+ # This module uses `method_missing` to raise `AbstractMethodNotImplementedError` instead.
18
+ # @requires_ancestor: Kernel
19
+ module AbstractInstanceMethodReceiver
20
+ # This `#method_missing` is hit when calling a potentially abstract method on an instance
21
+ # E.g. TheClass.new.maybe_abstract_method
22
+ #
23
+ # (Symbol, ...) -> untyped
24
+ def method_missing(method_name, ...)
25
+ c = self.class #: as Class[top] & HasAbstractMethods
26
+
27
+ if c.abstract_method_declared?(method_name)
28
+ raise AbstractMethodNotImplementedError.new(method_name:)
29
+ end
30
+
31
+ super
32
+ end
33
+
34
+ #: (Symbol, ?bool) -> bool
35
+ def respond_to_missing?(method_name, include_private = false)
36
+ self.class.abstract_method_declared?(method_name) || super
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,64 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module TypeToolkit
5
+ # @requires_ancestor: MethodDefRecorder
6
+ module DSL
7
+ # Mark `method_name` as abstract.
8
+ #
9
+ # A real implementation of the method must be provided somewhere in the ancestor chain.
10
+ # Calls to an unimplemented abstract method will raise `AbstractMethodNotImplementedError`.
11
+ #
12
+ #: (Symbol) -> Symbol
13
+ def abstract(method_name)
14
+ #: self as (Module[top] & HasAbstractMethods & MethodDefRecorder)
15
+
16
+ recorded_method_name, is_singleton_method = __last_method_def
17
+
18
+ if recorded_method_name != method_name
19
+ prefix = is_singleton_method ? "." : "#"
20
+
21
+ # Do not rely on this message content! Its content is subject to change.
22
+ raise <<~MSG.chomp
23
+ `abstract` expected to see `#{prefix}#{method_name}`, but the last recorded method was called `#{recorded_method_name}`.
24
+ This can happen when `abstract` is combined with other metaprogramming.
25
+ If you think this is a bug, please open an issue: https://github.com/Shopify/type_toolkit/issues
26
+ MSG
27
+ end
28
+
29
+ # The `method_owner` is the class whose method table stores the abstract method.
30
+ #
31
+ # Example:
32
+ #
33
+ # class Foo
34
+ # # is_singleton_method = false, owner is the `Foo` class
35
+ # abstract def foo; end
36
+ #
37
+ # # is_singleton_method = true, owner is `Foo.singleton_class`
38
+ # abstract def self.foo; end
39
+ # end
40
+ method_owner = is_singleton_method ? singleton_class : self #: as Module[top] & HasAbstractMethods
41
+
42
+ # Register the fact that this method is meant to be abstract,
43
+ # used by APIs like `abstract_method_declared?` and `Method#abstract?`
44
+ method_owner.__register_abstract_method(method_name)
45
+
46
+ # We never want the empty "stub" method to be called, so we remove it. This has one of 3 effects:
47
+ #
48
+ # 1. If the abstract method is implemented by a subclass, then there's no effect.
49
+ # The subclass' implementation will always be invoked, so this removal does nothing.
50
+ #
51
+ # 2. If the abstract method was already implemented by a superclass,
52
+ # Then this removal ensures that calls to the method will resolve to
53
+ # the superclass' implementation, and never the empty stub.
54
+ #
55
+ # 3. If the abstract method was not implemented anywhere in the ancestor chain,
56
+ # then this removal ensures we hit `method_missing`, which will then raise
57
+ # the `AbstractMethodNotImplementedError`.
58
+ method_owner.remove_method(method_name)
59
+
60
+ # Return the method name, so `abstract` can be chained, e.g. `private abstract def foo; end`
61
+ method_name
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "type_toolkit/method_patch"
4
+
5
+ class Method
6
+ prepend TypeToolkit::MethodPatch
7
+ end
8
+
9
+ class UnboundMethod
10
+ prepend TypeToolkit::MethodPatch
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Module
4
+ def interface!
5
+ TypeToolkit.make_interface!(self)
6
+ end
7
+ end
@@ -27,7 +27,9 @@ module TypeToolkit
27
27
  #
28
28
  # `UnexpectedNilError` should never occur in well-formed code, so it should never be rescued.
29
29
  # This is why it inherits from `Exception` instead of `StandardError`,
30
- # so that bare rescues clauses (like `rescue => e`) don't rescue it.
30
+ # so that bare rescue clauses (like `rescue => e`) don't accidentally swallow it.
31
+ #
32
+ # Note: `rescue Exception` can still catch it, but that's intentionally harder to write accidentally.
31
33
  class UnexpectedNilError < Exception # rubocop:disable Lint/InheritException
32
34
  def initialize(message = "Called `not_nil!` on nil.")
33
35
  super
@@ -0,0 +1,108 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module TypeToolkit
5
+ # Given a class Foo, abstract methods can be defined at two levels:
6
+ # 1. Instance methods available on the instance of Foo
7
+ # 2. "Class methods" available on the Foo class itself
8
+ #
9
+ # In case 1, the storage belongs one level up, in the class Foo.
10
+ # In case 2, the storage belongs two levels up, in the singleton class of Foo (`Foo::<Foo>`)
11
+ module HasAbstractMethods
12
+ # Private API, do not use directly. Only meant to be called from the `abstract` macro.
13
+ #: (Symbol) -> void
14
+ def __register_abstract_method(method_name) # :nodoc:
15
+ (
16
+ @__abstract_methods ||= Set.new #: Set[Symbol]?
17
+ ) << method_name
18
+ end
19
+
20
+ # Returns all methods that were marked abstract (even those which are implemented).
21
+ #: (?bool) -> Array[Symbol]
22
+ def declared_abstract_instance_methods(include_super = true)
23
+ #: self as HasAbstractMethods & Module[top]
24
+
25
+ result = @__abstract_methods
26
+
27
+ return result.to_a unless include_super
28
+
29
+ if defined?(super) && (super_abstract_methods = super)
30
+ if result
31
+ result.merge(super_abstract_methods)
32
+ else
33
+ result = super_abstract_methods
34
+ end
35
+ end
36
+
37
+ abstract_methods_in_interfaces = included_modules.flat_map do |m|
38
+ m.is_a?(HasAbstractMethods) ? m.declared_abstract_instance_methods : []
39
+ end
40
+
41
+ if abstract_methods_in_interfaces.any?
42
+ if result&.any?
43
+ result.merge(abstract_methods_in_interfaces)
44
+ else
45
+ result = abstract_methods_in_interfaces
46
+ end
47
+ end
48
+
49
+ result.to_a
50
+ end
51
+
52
+ # Returns all methods that are abstract and have not been implemented.
53
+ #: (?bool) -> Array[Symbol]
54
+ def abstract_instance_methods(include_super = true)
55
+ #: self as HasAbstractMethods & Module[top]
56
+
57
+ declared_abstract_instance_methods(include_super).reject do |m|
58
+ method_defined?(m) || private_method_defined?(m)
59
+ end
60
+ end
61
+
62
+ # Returns true if the given method name was ever marked abstract, even if it has a concrete implementation.
63
+ #
64
+ # Similar to `public_method_defined?` and friends, this method is called on a class to check if the method
65
+ # is defined for _instances_ of that class. For example:
66
+ #
67
+ # if Foo.abstract_method_declared?(:instance_method)
68
+ # Foo.new.instance_method # Might raise AbstractMethodNotImplementedError
69
+ # end
70
+ #
71
+ # if Foo.singleton_class.abstract_method_declared?(:class_method)
72
+ # Foo.class_method # Might raise AbstractMethodNotImplementedError
73
+ # end
74
+ #
75
+ #: (Symbol) -> bool
76
+ def abstract_method_declared?(method_name)
77
+ #: self as Module[top]
78
+
79
+ @__abstract_methods&.include?(method_name) ||
80
+ included_modules.any? { |m| m.is_a?(HasAbstractMethods) && m.abstract_method_declared?(method_name) } ||
81
+ (defined?(super) && super)
82
+ end
83
+
84
+ # Returns true if the given method is abstract, and has not been implemented.
85
+ # Calling it *will* raise an `AbstractMethodNotImplementedError`.
86
+ #
87
+ # Similar to `public_method_defined?` and friends, this method is called on a class to check if the method
88
+ # is defined for _instances_ of that class. For example:
89
+ #
90
+ # if Foo.abstract_method?(:instance_method)
91
+ # Foo.new.instance_method # Will raise AbstractMethodNotImplementedError
92
+ # end
93
+ #
94
+ # if Foo.singleton_class.abstract_method?(:class_method)
95
+ # Foo.class_method # Will raise AbstractMethodNotImplementedError
96
+ # end
97
+ #
98
+ #: (Symbol) -> bool
99
+ def abstract_method?(method_name)
100
+ #: self as (HasAbstractMethods & Module[top])
101
+
102
+ # If the method is defined, it has a concrete implementation, so it's not abstract.
103
+ return false if method_defined?(method_name) || private_method_defined?(method_name)
104
+
105
+ abstract_method_declared?(method_name)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,38 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "dsl"
5
+ require_relative "method_def_recorder"
6
+ require_relative "has_abstract_methods"
7
+ require_relative "abstract_method_receiver"
8
+
9
+ module TypeToolkit
10
+ class << self
11
+ #: (Module[top]) -> void
12
+ def make_interface!(mod)
13
+ if Class === mod
14
+ raise TypeError, "Classes can't be interfaces. Did you mean to make it `abstract` instead?"
15
+ end
16
+
17
+ mod.extend(TypeToolkit::Interface)
18
+ mod.extend(TypeToolkit::DSL)
19
+ mod.extend(TypeToolkit::MethodDefRecorder)
20
+ mod.extend(TypeToolkit::HasAbstractMethods)
21
+ end
22
+ end
23
+
24
+ # This module is extended onto any module that represents an interface.
25
+ # All of its members should be public and abstract.
26
+ module Interface
27
+ #: (Module[top]) -> void
28
+ def included(target_module)
29
+ # Including/extending a module is idempotent, so we don't have to worry these were already included/extended.
30
+
31
+ # Potentially abstract methods will be called on instances of `self`, so we need the `method_missing` hooks.
32
+ target_module.include(TypeToolkit::AbstractInstanceMethodReceiver)
33
+
34
+ # The `method_missing` hooks need to be able to look up the abstract methods (from the interface).
35
+ target_module.extend(TypeToolkit::HasAbstractMethods)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,53 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module TypeToolkit
5
+ # This module tracks new methods being defined, and whether they were
6
+ # instance methods (`def foo; end`) or singleton methods (`def self.foo; end`).
7
+ module MethodDefRecorder
8
+ #: [Symbol, bool]?
9
+ attr_reader :__last_method_def
10
+
11
+ class << self
12
+ #: (Module[top]) -> void
13
+ def extended(target_module)
14
+ # Also extend the singleton class so methods are available there
15
+ target_module.singleton_class.extend(ClassMethods)
16
+ end
17
+ end
18
+
19
+ # These actually go on the YourClass.singleton_class.singleton_class
20
+ # @requires_ancestor: MethodDefRecorder
21
+ module ClassMethods
22
+ #: () -> [Symbol, bool]?
23
+ def __last_method_def
24
+ #: self as Class[MethodDefRecorder]
25
+ cls = attached_object #: as MethodDefRecorder
26
+ cls.__last_method_def
27
+ end
28
+ end
29
+
30
+ # Need `@without_runtime` because of https://github.com/Shopify/tapioca/issues/2513
31
+ # @without_runtime
32
+ #: (Symbol) -> void
33
+ def method_added(m)
34
+ is_singleton_method = false
35
+ @__last_method_def = [m, is_singleton_method] #: [Symbol, bool]?
36
+
37
+ super
38
+ end
39
+
40
+ # Need `@without_runtime` because of https://github.com/Shopify/tapioca/issues/2513
41
+ # @without_runtime
42
+ # @override
43
+ #: (Symbol) -> void
44
+ def singleton_method_added(m)
45
+ return super if m == :singleton_method_added
46
+
47
+ is_singleton_method = true
48
+ @__last_method_def = [m, is_singleton_method] #: [Symbol, bool]?
49
+
50
+ super
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module TypeToolkit
5
+ # @requires_ancestor: MethodOrUnboundMethod
6
+ module MethodPatch
7
+ # Returns true if this method is an abstract method that hasn't been implemented.
8
+ # Calling it will raise an `AbstractMethodNotImplementedError`.
9
+ #: -> bool
10
+ def abstract?
11
+ return false unless TypeToolkit::HasAbstractMethods === (owner = self.owner)
12
+
13
+ owner.abstract_method?(name)
14
+ end
15
+ end
16
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module TypeToolkit
5
- VERSION = "0.0.4"
5
+ VERSION = "0.0.6"
6
6
  end
data/lib/type_toolkit.rb CHANGED
@@ -2,6 +2,9 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "type_toolkit/version"
5
+ require_relative "type_toolkit/interface"
6
+ require_relative "type_toolkit/ext/method"
7
+ require_relative "type_toolkit/ext/module"
5
8
  require_relative "type_toolkit/ext/nil_assertions"
6
9
 
7
10
  module TypeToolkit
data/sorbet/config CHANGED
@@ -3,5 +3,7 @@
3
3
  --ignore=tmp/
4
4
  --ignore=vendor/
5
5
  --enable-experimental-rbs-comments
6
+ --enable-experimental-requires-ancestor
6
7
  --suppress-payload-superclass-redefinition-for=RDoc::Markup::Heading
7
- --disable-watchman
8
+ --suppress-error-code=5022
9
+ --parser=prism