diff --git a/Gemfile b/Gemfile index fc72a81..9dd169a 100644 --- a/Gemfile +++ b/Gemfile @@ -15,3 +15,8 @@ gem "minitest", "~> 5.16" gem "rubocop-shopify", require: false gem "rubocop-minitest", require: false gem "rubocop-rake", require: false + +group :benchmark do + gem "benchmark-ips" + gem "sorbet-runtime" +end diff --git a/Gemfile.lock b/Gemfile.lock index 6fc5193..fe9fb29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,6 +10,7 @@ GEM specs: ast (2.4.3) benchmark (0.5.0) + benchmark-ips (2.14.0) date (3.5.1) erb (6.0.1) erubi (1.13.1) @@ -123,6 +124,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + benchmark-ips irb minitest (~> 5.16) rake (~> 13.0) @@ -130,12 +132,14 @@ DEPENDENCIES rubocop-rake rubocop-shopify sorbet + sorbet-runtime tapioca (>= 0.17) type_toolkit! CHECKSUMS ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + benchmark-ips (2.14.0) sha256=b72bc8a65d525d5906f8cd94270dccf73452ee3257a32b89fbd6684d3e8a9b1d date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 diff --git a/README.md b/README.md index 3c563d4..39f312b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,49 @@ last_delivery = user.not_nil! .deliveries.last.not_nil! ``` +### Interfaces + +Interfaces are modules with abstract methods which a conforming class must implement. They help make duck-typing easier to use in Ruby, by validating that your conforming classes do actually provide the correct methods needed of them. + +The Type Toolkit provides runtime support for interfaces (marked with `interface!`) and abstract methods (marked with `abstract` before the `def` keyword). + +Example: + +```ruby +module Notifier + interface! + + #: (String) -> void + abstract def send_notification(message); end +end + +class SlackNotifier + include Notifier + + # @override + #: (String) -> void + def send_notification(message) + puts "Posting to Slack API: #{message.inspect}" + end +end + +SlackNotifier.new.send_notification("Hello, world!") # ✅ +# => Posting to Slack API: "Hello, world!" +``` + +Unimplemented abstract methods cannot be called, and the Type Toolkit runtime will raise an error if you try to do so: + +```ruby +class EmailNotifier + include Notifier + + # Oops, forgot to implement `#send_notification`! +end + +EmailNotifier.new.send_notification("Hello, world!") # ❌ TypeToolkit::AbstractMethodNotImplementedError +# => Abstract method #send_notification was never implemented. +``` + ## Guiding Principles ### Blazingly fast™ diff --git a/benchmark/.rubocop.yml b/benchmark/.rubocop.yml new file mode 100644 index 0000000..56cb155 --- /dev/null +++ b/benchmark/.rubocop.yml @@ -0,0 +1,7 @@ +inherit_from: ../.rubocop.yml + +Naming/ClassAndModuleCamelCase: + Enabled: false # Sometimes underscores are useful, m'kay? + +Style/ClassMethodsDefinitions: + Enabled: false # We need to be able to compare `class << self` and `def self.` diff --git a/benchmark/abstract_methods_benchmark.rb b/benchmark/abstract_methods_benchmark.rb new file mode 100644 index 0000000..8d4cecd --- /dev/null +++ b/benchmark/abstract_methods_benchmark.rb @@ -0,0 +1,221 @@ +# typed: ignore +# frozen_string_literal: true + +# Benchmark the performance overhead of calling: +# - A concrete implementation of an abstract method +# - An inherited concrete implementation of an abstract method +# - The error case of calling an unimplemented abstract method + +############################################# Results ############################################# +# +# ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin23] +# +# ## Interpreter +# +# | Call to... | Regular impl | Inherited impl | Missing impl | +# |-------------------|--------------------:|------------------------:|--------------------------:| +# | sorbet-runtime | (same-ish) 23.02 ns | (2.70x slower) 57.30 ns | (1.13x slower) 472.86 ns | +# | manual delegation | (same-ish) 22.18 ns | (2.07x slower) 44.90 ns | *415.36 ns* | +# | type_toolkit | (same-ish) 22.56 ns | *22.03 ns* | (2.11x slower) 890.38 ns | +# +# ## YJIT# +# | Call to... | Regular impl | Inherited impl | Missing impl | +# |-------------------|--------------------:|-------------------------:|--------------------------:| +# | sorbet-runtime | (same-ish) 1.63 ns | (21.41x slower) 34.91 ns | (1.10x slower) 447.59 ns | +# | manual delegation | (same-ish) 1.63 ns | (7.15x slower) 11.66 ns | *405.84 ns* | +# | type_toolkit | (same-ish) 1.67 ns | *1.63 ns* | (1.91x slower) 774.91 ns | +# +#################################################################################################### + +require "bundler" +Bundler.require(:default, :benchmark) + +require "type_toolkit" + +module TypeKitDemo + # Provides the concrete implementation of `m` + class Parent + def m1 = "Parent#m1" + end + + module I + interface! + + abstract def m1; end + abstract def m2; end + abstract def not_implemented; end + end + + # Inherits the concrete implementation of `m` from DemoParentClass. + class Child < Parent + include I + + def m2 = "Child#m2" + end +end + +module SorbetRuntimeDemo + # Provides the concrete implementation of `m` + class Parent + def m1 = "Parent#m1" + end + + module I + extend T::Sig + extend T::Helpers + + interface! + + sig { abstract.returns(String) } + def m1; end + + sig { abstract.returns(String) } + def m2; end + + sig { abstract.returns(String) } + def not_implemented; end + end + + # Inherits the concrete implementation of `m` from DemoParentClass. + class Child < Parent + include I + + def m2 = "Child#m2" + end +end + +module ManualDelegationDemo + class Parent + def m1 = "Parent#m1" + end + + module I + def m1 = defined?(super) ? super : raise + def m2 = defined?(super) ? super : raise + def not_implemented = defined?(super) ? super : raise + end + + # Inherits the concrete implementation of `m` from DemoParentClass. + class Child < Parent + include I + + def m2 = "Child#m2" + end +end + +type_toolkit_object = TypeKitDemo::Child.new +manual_delegation_object = ManualDelegationDemo::Child.new +sorbet_runtime_object = SorbetRuntimeDemo::Child.new + +[:interpreter, :yjit].each do |mode| + if mode == :yjit + puts <<~MSG + + + ================================================================================ + Enabling YJIT... + ================================================================================ + + + MSG + RubyVM::YJIT.enable + end + + warmup = 5 + time = 10 + + width = ["type_toolkit", "sorbet-runtime", "manual delegation"].max_by(&:length).length + + puts "Benchmark the performance of calling the concrete implementation directly..." + Benchmark.ips do |x| + x.config(warmup:, time:) + + x.report("type_toolkit".rjust(width)) do |times| + i = 0 + while (i += 1) < times + type_toolkit_object.m2 + end + end + + x.report("sorbet-runtime".rjust(width)) do |times| + i = 0 + while (i += 1) < times + sorbet_runtime_object.m2 + end + end + + x.report("manual delegation".rjust(width)) do |times| + i = 0 + while (i += 1) < times + manual_delegation_object.m2 + end + end + + x.compare! + end + + puts "\n\nBenchmark the performance of calling the inherited concrete implementation..." + Benchmark.ips do |x| + x.config(warmup:, time:) + + x.report("type_toolkit".rjust(width)) do |times| + i = 0 + while (i += 1) < times + type_toolkit_object.m1 + end + end + + x.report("sorbet-runtime".rjust(width)) do |times| + i = 0 + while (i += 1) < times + sorbet_runtime_object.m1 + end + end + + x.report("manual delegation".rjust(width)) do |times| + i = 0 + while (i += 1) < times + manual_delegation_object.m1 + end + end + + x.compare! + end + + puts "\n\nTest the performance of calling an unimplemented abstract method..." + Benchmark.ips do |x| + x.config(warmup:, time:) + + x.report("type_toolkit".rjust(width)) do |times| + i = 0 + while (i += 1) < times + begin + type_toolkit_object.not_implemented + rescue AbstractMethodNotImplementedError # rubocop:disable Lint/SuppressedException + end + end + end + + x.report("sorbet-runtime".rjust(width)) do |times| + i = 0 + while (i += 1) < times + begin + sorbet_runtime_object.not_implemented + rescue NotImplementedError # rubocop:disable Lint/SuppressedException + end + end + end + + x.report("manual delegation".rjust(width)) do |times| + i = 0 + while (i += 1) < times + begin + manual_delegation_object.not_implemented + rescue StandardError # rubocop:disable Lint/SuppressedException + end + end + end + + x.compare! + end +end diff --git a/benchmark/interface_startup_performance.rb b/benchmark/interface_startup_performance.rb new file mode 100644 index 0000000..54a6754 --- /dev/null +++ b/benchmark/interface_startup_performance.rb @@ -0,0 +1,128 @@ +# typed: ignore +# frozen_string_literal: true + +# Benchmark the startup performance of declaring modules/interfaces in 3 different styles: +# - TypeToolkit (abstract gem) +# - Sorbet runtime +# - Manual delegation (defined?(super) pattern) + +############################################# Results ############################################# +# +# ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin23] +# +# | | Interpreter | YJIT | +# |-------------------|-------------------------:|---------------------------:| +# | sorbet-runtime | (21.34x slower) 50.79 μs | (152.34x slower) 377.27 μs | +# | type_toolkit | (4.18x slower) 9.95 μs | (4.18x slower) 10.35 μs | +# | manual delegation | 2.38 μs | 2.48 μs | +# +#################################################################################################### + +require "bundler" +Bundler.require(:default, :benchmark) + +require "type_toolkit" + +warmup = 5 +time = 10 + +width = ["type_toolkit", "sorbet-runtime", "manual delegation"].max_by(&:length).length + +puts "Benchmark the time to declare an interface module with abstract methods..." + +[:interpreter, :yjit].each do |mode| + if mode == :yjit + puts <<~MSG + + + ================================================================================ + Enabling YJIT... + ================================================================================ + + + MSG + RubyVM::YJIT.enable + end + + Benchmark.ips do |x| + x.config(warmup:, time:) + + x.report("type_toolkit".rjust(width)) do |times| + i = 0 + while (i += 1) < times + interface = Module.new do + interface! + + abstract def m1; end + abstract def m2; end + abstract def m3; end + end + + Class.new do + include interface + + def m1 = "m1" + def m2 = "m2" + def m3 = "m3" + end + end + end + + x.report("sorbet-runtime".rjust(width)) do |times| + i = 0 + while (i += 1) < times + interface = Module.new do + extend T::Sig + extend T::Helpers + + interface! + + sig { abstract.returns(String) } + def m1; end + + sig { abstract.returns(String) } + def m2; end + + sig { abstract.returns(String) } + def m3; end + end + + Class.new do + extend T::Sig + + include interface + + sig { override.returns(String) } + def m1 = "m1" + + sig { override.returns(String) } + def m2 = "m2" + + sig { override.returns(String) } + def m3 = "m3" + end + end + end + + x.report("manual delegation".rjust(width)) do |times| + i = 0 + while (i += 1) < times + interface = Module.new do + def m1 = defined?(super) ? super : raise + def m2 = defined?(super) ? super : raise + def m3 = defined?(super) ? super : raise + end + + Class.new do + include interface + + def m1 = "m1" + def m2 = "m2" + def m3 = "m3" + end + end + end + + x.compare! + end +end diff --git a/benchmark/module_benchmark.rb b/benchmark/module_benchmark.rb new file mode 100644 index 0000000..c73926c --- /dev/null +++ b/benchmark/module_benchmark.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Benchmark if it's worth checking `Module.include?` before calling`Module.include` +# ... spoiler: meh, not really. + +############################################# Results ############################################# +# +# ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin23] +# +# | | Interpreter | YJIT | +# |-------------------:|------------------------:|------------------------:| +# | just include | (2.39x slower) 54.35 ns | (8.53x slower) 35.29 ns | +# | check then include | 22.72 ns | 4.14 ns | +# +#################################################################################################### + +require "bundler" +Bundler.require(:default, :benchmark) + +module M1; end +module M2; end +class C1; end +class C2; end + +[:interpreter, :yjit].each do |mode| + if mode == :yjit + puts <<~MSG + + + ================================================================================ + Enabling YJIT... + ================================================================================ + + + MSG + RubyVM::YJIT.enable + end + + warmup = 5 + time = 10 + + puts "Benchmark the performance of calling the concrete implementation directly..." + Benchmark.ips do |x| + x.config(warmup:, time:) + + x.report("check then include") do |times| + i = 0 + while (i += 1) < times + C1.include(M1) unless C1.include?(M1) + end + end + + x.report("just include") do |times| + i = 0 + while (i += 1) < times + C2.include(M2) + end + end + + x.compare! + end +end diff --git a/docs/design/inherited_implementation_problem.md b/docs/design/inherited_implementation_problem.md new file mode 100644 index 0000000..0162bcf --- /dev/null +++ b/docs/design/inherited_implementation_problem.md @@ -0,0 +1,119 @@ +# The inherited implementation problem + +Take this example: + +```ruby +class Parent + def m = "Parent#m" +end + +module I + interface! + + abstract def m = raise "Abstract method `#m` not implemented" +end + +class Child < Parent + include I +end + +Child.new.m +# => Vanilla Ruby: raises (from `I#m`) +# => With this gem: `Parent#m` +``` + +There are two challenges here: + + 1. We need to not let the `I#m` stub implementation "get in the way", so that we can find real implementation that get inherited from further ancestors (like `Parent#m`). + 2. *but* we still want something to raise an error if you attempt to call an unimplemented abstract method. + +If you lookup `m` on an instance of `Child`, you would usually hit the empty stub `I#m` instead of the inherited implementation `Parent#m`: + +```ruby +Child.ancestors + +# => [Child, Interface, Parent, Object, Kernel, BasicObject] +# ^ ^ ^ +# | | Provides the implementation for Child to inherit +# | Its stub abstract method "gets in the way" and needs to be side-stepped +# We want Child to inherit the implementation from Parent + + +Child.instance_method(:m).owner +# => Vanilla Ruby: `I` +# => With this gem: `Parent` +``` + +# Solution + +Solve the inheritance problem by just yoinking the abstract method stub out of the ancestor chain, by just using `remove_method()`. + +Now if you send `#m` to a child, there's no `I#m` implementation to hit, so it just jumps over to `Parent#m`. This is a direct method call with absolutely no runtime overhead. + +This introduces a new problem, that now calling unimplemented abstract methods just raises `NoMethodError`, as if the name never existed. For a better developer experience, we'd like to give a more helpful message. + +To do this, we implement `#method_missing` and check if the missing method name is one of the abstract methods (based on a list we append to every time you call `abstract`). If it is, we can raise our nicer error, otherwise we just delegate up the rest of the `#method_missing` chain (ultimately triggering a `NoMethodError`, like normal). This part isn't strictly necessary, but it's a nice-to-have. We can make it configurable. + +Syntax: + +```ruby +module I + interface! + + abstract def m; end +end +``` + +That's it. That simple! + +Pros: + - Good DX when calling an abstract method that you forgot to implement + - Really nice syntax + +Cons: + - Some implementation complexity (but all tucked into this gem, with less than 130 lines of implementation code) + - Calls to implemented abstract methods are faster, at the expense of unimplemented ones + - 1.9x slower than the hand-written alternative + - 1.1x slower than sorbet-runtime alternative + - but that's totally acceptable, because this should never happen in a completed program. The performance of calls to actually implemented methods is what matters, since that's what the real code will be doing at a huge volume. + +# Alternative 1: Hand-written delegation + +You duplicate this delegation logic in every abstract method: + +```ruby +module I + interface! + + # @abstract + def m = defined?(super) ? super : raise("Abstract method `#m` not implemented") +end +``` + +Pros: + - There's no magic, no runtime needed. + +Cons: + - Repetitive + - Can be forgotten + - There's more overhead on every method call (but only to abstract methods with an inherited implementation): + - Checking `defined?(super)` + - Making the `super` call + - It adds an extra frame to your backtrace which will be seen in debuggers and exception backtraces (unless you configure them to filter it out) + + +# Alternative 2: Sorbet runtime's solution + +Here's a Sorbet version of the example: [Sorbet.run](https://sorbet.run/#%23%20typed%3A%20true%0A%0Aclass%20Parent%0A%20%20extend%20T%3A%3ASig%0A%0A%20%20sig%20%7B%20returns%28String%29%20%7D%0A%20%20def%20m%20%3D%20%22Parent%23m%22%0Aend%0A%20%20%0Amodule%20I%0A%20%20extend%20T%3A%3ASig%0A%20%20extend%20T%3A%3AHelpers%0A%0A%20%20interface!%0A%0A%20%20sig%20%7B%20abstract.returns%28String%29%20%7D%0A%20%20def%20m%3B%20end%0Aend%0A%0Aclass%20Child%20%3C%20Parent%3B%20end). + +Sorbet runtime basically automates the hand-written solution above, by wrapping abstract methods: + +https://github.com/sorbet/sorbet/blob/703498a0dcddbe7ec4b87ec6cc5d7d55cfa9b270/gems/sorbet-runtime/lib/types/private/methods/call_validation.rb#L48-L68 + +Pros: + - "just works" + +Cons: + - All the downsides of doing this the hand-written way. + - To determine if a method is abstract or not, every `sig` needs to have its block evaluated, to see if it calls `abstract`. + - This used to be slower because it was defined via `defined_methods` with a block body. This produces a slower kind of method (`VM_METHOD_TYPE_BMETHOD`), than the equivalent code via `def` (`VM_METHOD_TYPE_ISEQ`). This was fixed in [this PR](https://github.com/sorbet/sorbet/pull/8238), which switch to using `module_eval` to define the method via `def`. diff --git a/docs/design/self_dot_methods.md b/docs/design/self_dot_methods.md new file mode 100644 index 0000000..005615d --- /dev/null +++ b/docs/design/self_dot_methods.md @@ -0,0 +1,79 @@ +# The `def self.` problem + +Ruby method definitions evaluate to the name of the method that was defined. This fact is used by the `public`/`protected`/`private` methods: + +```ruby +class + puts def demo; end + # Equivalent to `puts(:demo)` +end +``` + +However, there's no way to distinguish whether the method was an instance method, or a singleton method: + +```ruby +class C + puts def demo; end # prints ":demo" + puts def self.demo; end # *also* prints ":demo" +end +``` + +# Solution + +Track into whether the last method call was an instance method or a singleton method, by hooking into `method_added` and `singleton_method_added`. See the `MethodDefRecorder` for details. + +This way, both of these "just work" + +```rb +class C + # Correctly defines an abstract instance method + abstract def demo; end + + # Correctly defines an abstract "class method" + abstract def self.demo; end +end +``` + +# Alternative 1: Do nothing + +This is already a problem for access level modifiers, which don't do anything to handle it: + +```ruby +class C + private def self.demo; end + # => ❌ undefined method 'demo' for class 'C' (NameError) +end +``` + +We can just follow suit. However, there's a risk that if an instance method called `demo` _actually_ existed, we inadvertently make it abstract without intending. Again, the access level modifier methods already have this issue, but it's a sharp edge that we don't need to have. + +If we choose to do nothing, we could encourage users to enable the [`Style/ClassMethodsDefinitions`](https://docs.rubocop.org/rubocop/cops_style.html#styleclassmethodsdefinitions) on the `EnforcedStyle: self_class` mode, so that their code bases don't contain `def self.foo` methods at all. + +Pros: + - Simpler implementation (none!) + +Cons: + - Sharp edge + - More complex mental model for users of RBS + +# Alternative 2: separate macro + +E.g. + +```rb +class C + # Correctly defines an abstract instance method + abstract_instance_method def demo; end + + # Correctly defines an abstract "class method" + abstract_class_method def self.demo; end +end +``` + +Pros: + - Simple implementation + +Cons: + - Correct usage can't be enforced at runtime. + - Rubocop cop could enforce it, but that would need to be written + - Still a sharp edge, has the same cognitive complexity as alternative 1. diff --git a/lib/type_toolkit.rb b/lib/type_toolkit.rb index cd18206..1994c4a 100644 --- a/lib/type_toolkit.rb +++ b/lib/type_toolkit.rb @@ -2,6 +2,9 @@ # frozen_string_literal: true require_relative "type_toolkit/version" +require_relative "type_toolkit/interface" +require_relative "type_toolkit/ext/method" +require_relative "type_toolkit/ext/module" require_relative "type_toolkit/ext/nil_assertions" module TypeToolkit diff --git a/lib/type_toolkit/abstract_method_receiver.rb b/lib/type_toolkit/abstract_method_receiver.rb new file mode 100644 index 0000000..eab9238 --- /dev/null +++ b/lib/type_toolkit/abstract_method_receiver.rb @@ -0,0 +1,39 @@ +# typed: true +# frozen_string_literal: true + +module TypeToolkit + # Raised when a call is made to an abstract method that never had a real implementation. + class AbstractMethodNotImplementedError < Exception # rubocop:disable Lint/InheritException + def initialize(method_name:) + # Do not rely on this message content! Its content is subject to change. + super("Abstract method `##{method_name}` was never implemented.") + end + end + + # This module is included on a class whose instances can be receivers of calls to abstract methods. + # + # Since abstract methods are removed at runtime (see `TypeToolkit::DSL#abstract`), attempting to call + # an unimplemented abstract method would usually raise a `NoMethodError`. + # This module uses `method_missing` to raise `AbstractMethodNotImplementedError` instead. + # @requires_ancestor: Kernel + module AbstractInstanceMethodReceiver + # This `#method_missing` is hit when calling a potentially abstract method on an instance + # E.g. TheClass.new.maybe_abstract_method + # + # (Symbol, ...) -> untyped + def method_missing(method_name, ...) + c = self.class #: as Class[top] & HasAbstractMethods + + if c.abstract_method_declared?(method_name) + raise AbstractMethodNotImplementedError.new(method_name:) + end + + super + end + + #: (Symbol, ?bool) -> bool + def respond_to_missing?(method_name, include_private = false) + self.class.abstract_method_declared?(method_name) || super + end + end +end diff --git a/lib/type_toolkit/dsl.rb b/lib/type_toolkit/dsl.rb new file mode 100644 index 0000000..7dd6d98 --- /dev/null +++ b/lib/type_toolkit/dsl.rb @@ -0,0 +1,64 @@ +# typed: strict +# frozen_string_literal: true + +module TypeToolkit + # @requires_ancestor: MethodDefRecorder + module DSL + # Mark `method_name` as abstract. + # + # A real implementation of the method must be provided somewhere in the ancestor chain. + # Calls to an unimplemented abstract method will raise `AbstractMethodNotImplementedError`. + # + #: (Symbol) -> Symbol + def abstract(method_name) + #: self as (Module[top] & HasAbstractMethods & MethodDefRecorder) + + recorded_method_name, is_singleton_method = __last_method_def + + if recorded_method_name != method_name + prefix = is_singleton_method ? "." : "#" + + # Do not rely on this message content! Its content is subject to change. + raise <<~MSG.chomp + `abstract` expected to see `#{prefix}#{method_name}`, but the last recorded method was called `#{recorded_method_name}`. + This can happen when `abstract` is combined with other metaprogramming. + If you think this is a bug, please open an issue: https://github.com/Shopify/type_toolkit/issues + MSG + end + + # The `method_owner` is the class whose method table stores the abstract method. + # + # Example: + # + # class Foo + # # is_singleton_method = false, owner is the `Foo` class + # abstract def foo; end + # + # # is_singleton_method = true, owner is `Foo.singleton_class` + # abstract def self.foo; end + # end + method_owner = is_singleton_method ? singleton_class : self #: as Module[top] & HasAbstractMethods + + # Register the fact that this method is meant to be abstract, + # used by APIs like `abstract_method_declared?` and `Method#abstract?` + method_owner.__register_abstract_method(method_name) + + # We never want the empty "stub" method to be called, so we remove it. This has one of 3 effects: + # + # 1. If the abstract method is implemented by a subclass, then there's no effect. + # The subclass' implementation will always be invoked, so this removal does nothing. + # + # 2. If the abstract method was already implemented by a superclass, + # Then this removal ensures that calls to the method will resolve to + # the superclass' implementation, and never the empty stub. + # + # 3. If the abstract method was not implemented anywhere in the ancestor chain, + # then this removal ensures we hit `method_missing`, which will then raise + # the `AbstractMethodNotImplementedError`. + method_owner.remove_method(method_name) + + # Return the method name, so `abstract` can be chained, e.g. `private abstract def foo; end` + method_name + end + end +end diff --git a/lib/type_toolkit/ext/method.rb b/lib/type_toolkit/ext/method.rb new file mode 100644 index 0000000..30c9781 --- /dev/null +++ b/lib/type_toolkit/ext/method.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "type_toolkit/method_patch" + +class Method + prepend TypeToolkit::MethodPatch +end + +class UnboundMethod + prepend TypeToolkit::MethodPatch +end diff --git a/lib/type_toolkit/ext/module.rb b/lib/type_toolkit/ext/module.rb new file mode 100644 index 0000000..1621a96 --- /dev/null +++ b/lib/type_toolkit/ext/module.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Module + def interface! + TypeToolkit.make_interface!(self) + end +end diff --git a/lib/type_toolkit/has_abstract_methods.rb b/lib/type_toolkit/has_abstract_methods.rb new file mode 100644 index 0000000..b4874b0 --- /dev/null +++ b/lib/type_toolkit/has_abstract_methods.rb @@ -0,0 +1,108 @@ +# typed: strict +# frozen_string_literal: true + +module TypeToolkit + # Given a class Foo, abstract methods can be defined at two levels: + # 1. Instance methods available on the instance of Foo + # 2. "Class methods" available on the Foo class itself + # + # In case 1, the storage belongs one level up, in the class Foo. + # In case 2, the storage belongs two levels up, in the singleton class of Foo (`Foo::`) + module HasAbstractMethods + # Private API, do not use directly. Only meant to be called from the `abstract` macro. + #: (Symbol) -> void + def __register_abstract_method(method_name) # :nodoc: + ( + @__abstract_methods ||= Set.new #: Set[Symbol]? + ) << method_name + end + + # Returns all methods that were marked abstract (even those which are implemented). + #: (?bool) -> Array[Symbol] + def declared_abstract_instance_methods(include_super = true) + #: self as HasAbstractMethods & Module[top] + + result = @__abstract_methods + + return result.to_a unless include_super + + if defined?(super) && (super_abstract_methods = super) + if result + result.merge(super_abstract_methods) + else + result = super_abstract_methods + end + end + + abstract_methods_in_interfaces = included_modules.flat_map do |m| + m.is_a?(HasAbstractMethods) ? m.declared_abstract_instance_methods : [] + end + + if abstract_methods_in_interfaces.any? + if result&.any? + result.merge(abstract_methods_in_interfaces) + else + result = abstract_methods_in_interfaces + end + end + + result.to_a + end + + # Returns all methods that are abstract and have not been implemented. + #: (?bool) -> Array[Symbol] + def abstract_instance_methods(include_super = true) + #: self as HasAbstractMethods & Module[top] + + declared_abstract_instance_methods(include_super).reject do |m| + method_defined?(m) || private_method_defined?(m) + end + end + + # Returns true if the given method name was ever marked abstract, even if it has a concrete implementation. + # + # Similar to `public_method_defined?` and friends, this method is called on a class to check if the method + # is defined for _instances_ of that class. For example: + # + # if Foo.abstract_method_declared?(:instance_method) + # Foo.new.instance_method # Might raise AbstractMethodNotImplementedError + # end + # + # if Foo.singleton_class.abstract_method_declared?(:class_method) + # Foo.class_method # Might raise AbstractMethodNotImplementedError + # end + # + #: (Symbol) -> bool + def abstract_method_declared?(method_name) + #: self as Module[top] + + @__abstract_methods&.include?(method_name) || + included_modules.any? { |m| m.is_a?(HasAbstractMethods) && m.abstract_method_declared?(method_name) } || + (defined?(super) && super) + end + + # Returns true if the given method is abstract, and has not been implemented. + # Calling it *will* raise an `AbstractMethodNotImplementedError`. + # + # Similar to `public_method_defined?` and friends, this method is called on a class to check if the method + # is defined for _instances_ of that class. For example: + # + # if Foo.abstract_method?(:instance_method) + # Foo.new.instance_method # Will raise AbstractMethodNotImplementedError + # end + # + # if Foo.singleton_class.abstract_method?(:class_method) + # Foo.class_method # Will raise AbstractMethodNotImplementedError + # end + # + #: (Symbol) -> bool + def abstract_method?(method_name) + #: self as (HasAbstractMethods & Module[top]) + + # If the method is defined, it has a concrete implementation, so it's not abstract. + return false if method_defined?(method_name) || private_method_defined?(method_name) + + abstract_method_declared?(method_name) + end + end +end diff --git a/lib/type_toolkit/interface.rb b/lib/type_toolkit/interface.rb new file mode 100644 index 0000000..a548f8b --- /dev/null +++ b/lib/type_toolkit/interface.rb @@ -0,0 +1,38 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "dsl" +require_relative "method_def_recorder" +require_relative "has_abstract_methods" +require_relative "abstract_method_receiver" + +module TypeToolkit + class << self + #: (Module[top]) -> void + def make_interface!(mod) + if Class === mod + raise TypeError, "Classes can't be interfaces. Did you mean to make it `abstract` instead?" + end + + mod.extend(TypeToolkit::Interface) + mod.extend(TypeToolkit::DSL) + mod.extend(TypeToolkit::MethodDefRecorder) + mod.extend(TypeToolkit::HasAbstractMethods) + end + end + + # This module is extended onto any module that represents an interface. + # All of its members should be public and abstract. + module Interface + #: (Module[top]) -> void + def included(target_module) + # Including/extending a module is idempotent, so we don't have to worry these were already included/extended. + + # Potentially abstract methods will be called on instances of `self`, so we need the `method_missing` hooks. + target_module.include(TypeToolkit::AbstractInstanceMethodReceiver) + + # The `method_missing` hooks need to be able to look up the abstract methods (from the interface). + target_module.extend(TypeToolkit::HasAbstractMethods) + end + end +end diff --git a/lib/type_toolkit/method_def_recorder.rb b/lib/type_toolkit/method_def_recorder.rb new file mode 100644 index 0000000..27d04b6 --- /dev/null +++ b/lib/type_toolkit/method_def_recorder.rb @@ -0,0 +1,53 @@ +# typed: strict +# frozen_string_literal: true + +module TypeToolkit + # This module tracks new methods being defined, and whether they were + # instance methods (`def foo; end`) or singleton methods (`def self.foo; end`). + module MethodDefRecorder + #: [Symbol, bool]? + attr_reader :__last_method_def + + class << self + #: (Module[top]) -> void + def extended(target_module) + # Also extend the singleton class so methods are available there + target_module.singleton_class.extend(ClassMethods) + end + end + + # These actually go on the YourClass.singleton_class.singleton_class + # @requires_ancestor: MethodDefRecorder + module ClassMethods + #: () -> [Symbol, bool]? + def __last_method_def + #: self as Class[MethodDefRecorder] + cls = attached_object #: as MethodDefRecorder + cls.__last_method_def + end + end + + # Need `@without_runtime` because of https://github.com/Shopify/tapioca/issues/2513 + # @without_runtime + #: (Symbol) -> void + def method_added(m) + is_singleton_method = false + @__last_method_def = [m, is_singleton_method] #: [Symbol, bool]? + + super + end + + # Need `@without_runtime` because of https://github.com/Shopify/tapioca/issues/2513 + # @without_runtime + # @override + #: (Symbol) -> void + def singleton_method_added(m) + return super if m == :singleton_method_added + + is_singleton_method = true + @__last_method_def = [m, is_singleton_method] #: [Symbol, bool]? + + super + end + end +end diff --git a/lib/type_toolkit/method_patch.rb b/lib/type_toolkit/method_patch.rb new file mode 100644 index 0000000..e9fedb3 --- /dev/null +++ b/lib/type_toolkit/method_patch.rb @@ -0,0 +1,16 @@ +# typed: strict +# frozen_string_literal: true + +module TypeToolkit + # @requires_ancestor: MethodOrUnboundMethod + module MethodPatch + # Returns true if this method is an abstract method that hasn't been implemented. + # Calling it will raise an `AbstractMethodNotImplementedError`. + #: -> bool + def abstract? + return false unless TypeToolkit::HasAbstractMethods === (owner = self.owner) + + owner.abstract_method?(name) + end + end +end diff --git a/sorbet/config b/sorbet/config index 718871c..b62ed05 100644 --- a/sorbet/config +++ b/sorbet/config @@ -3,5 +3,6 @@ --ignore=tmp/ --ignore=vendor/ --enable-experimental-rbs-comments +--enable-experimental-requires-ancestor --suppress-payload-superclass-redefinition-for=RDoc::Markup::Heading ---disable-watchman +--suppress-error-code=5022 diff --git a/sorbet/rbi/gems/benchmark-ips@2.14.0.rbi b/sorbet/rbi/gems/benchmark-ips@2.14.0.rbi new file mode 100644 index 0000000..f0b0c66 --- /dev/null +++ b/sorbet/rbi/gems/benchmark-ips@2.14.0.rbi @@ -0,0 +1,981 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `benchmark-ips` gem. +# Please instead update this file by running `bin/tapioca gem benchmark-ips`. + + +# Performance benchmarking library +# +# source://benchmark-ips//lib/benchmark/timing.rb#1 +module Benchmark + extend ::Benchmark::Compare + extend ::Benchmark::IPS +end + +# Functionality of performaing comparison between reports. +# +# Usage: +# +# Add +x.compare!+ to perform comparison between reports. +# +# Example: +# > Benchmark.ips do |x| +# x.report('Reduce using tag') { [*1..10].reduce(:+) } +# x.report('Reduce using to_proc') { [*1..10].reduce(&:+) } +# x.compare! +# end +# +# Calculating ------------------------------------- +# Reduce using tag 19216 i/100ms +# Reduce using to_proc 17437 i/100ms +# ------------------------------------------------- +# Reduce using tag 278950.0 (±8.5%) i/s - 1402768 in 5.065112s +# Reduce using to_proc 247295.4 (±8.0%) i/s - 1238027 in 5.037299s +# +# Comparison: +# Reduce using tag: 278950.0 i/s +# Reduce using to_proc: 247295.4 i/s - 1.13x slower +# +# Besides regular Calculating report, this will also indicates which one is slower. +# +# +x.compare!+ also takes an +order: :baseline+ option. +# +# Example: +# > Benchmark.ips do |x| +# x.report('Reduce using block') { [*1..10].reduce { |sum, n| sum + n } } +# x.report('Reduce using tag') { [*1..10].reduce(:+) } +# x.report('Reduce using to_proc') { [*1..10].reduce(&:+) } +# x.compare!(order: :baseline) +# end +# +# Calculating ------------------------------------- +# Reduce using block 886.202k (± 2.2%) i/s - 4.521M in 5.103774s +# Reduce using tag 1.821M (± 1.6%) i/s - 9.111M in 5.004183s +# Reduce using to_proc 895.948k (± 1.6%) i/s - 4.528M in 5.055368s +# +# Comparison: +# Reduce using block: 886202.5 i/s +# Reduce using tag: 1821055.0 i/s - 2.05x (± 0.00) faster +# Reduce using to_proc: 895948.1 i/s - same-ish: difference falls within error +# +# The first report is considered the baseline against which other reports are compared. +# +# source://benchmark-ips//lib/benchmark/compare.rb#51 +module Benchmark::Compare + # Compare between reports, prints out facts of each report: + # runtime, comparative speed difference. + # + # @param entries [Array] Reports to compare. + # + # source://benchmark-ips//lib/benchmark/compare.rb#56 + def compare(*entries, order: T.unsafe(nil)); end +end + +# Benchmark in iterations per second, no more guessing! +# +# See Benchmark.ips for documentation on using this gem~ +# +# @see {https://github.com/evanphx/benchmark-ips} +# +# source://benchmark-ips//lib/benchmark/ips/stats/stats_metric.rb#2 +module Benchmark::IPS + # Measure code in block, each code's benchmarked result will display in + # iteration per second with standard deviation in given time. + # + # @param time [Integer] Specify how long should benchmark your code in seconds. + # @param warmup [Integer] Specify how long should Warmup time run in seconds. + # @return [Report] + # @yield [job] + # + # source://benchmark-ips//lib/benchmark/ips.rb#33 + def ips(*args); end + + # Quickly compare multiple methods on the same object. + # + # @example Compare String#upcase and String#downcase + # ips_quick(:upcase, :downcase, on: "hello") + # @example Compare two methods you just defined, with a custom warmup. + # def add; 1+1; end + # def sub; 2-1; end + # ips_quick(:add, :sub, warmup: 10) + # @option opts + # @option opts + # @param methods [Symbol...] A list of method names (as symbols) to compare. + # @param opts [Hash] Additional options for customizing the benchmark. + # @param receiver [Object] The object on which to call the methods. Defaults to Kernel. + # + # source://benchmark-ips//lib/benchmark/ips.rb#93 + def ips_quick(*methods, on: T.unsafe(nil), **opts); end + + class << self + # Set options for running the benchmarks. + # :format => [:human, :raw] + # :human format narrows precision and scales results for readability + # :raw format displays 6 places of precision and exact iteration counts + # + # source://benchmark-ips//lib/benchmark/ips.rb#109 + def options; end + end +end + +# CODENAME of current version. +# +# source://benchmark-ips//lib/benchmark/ips.rb#26 +Benchmark::IPS::CODENAME = T.let(T.unsafe(nil), String) + +# source://benchmark-ips//lib/benchmark/ips.rb#113 +module Benchmark::IPS::Helpers + private + + # source://benchmark-ips//lib/benchmark/ips.rb#126 + def humanize_duration(duration_ns); end + + # source://benchmark-ips//lib/benchmark/ips.rb#116 + def scale(value); end + + class << self + # source://benchmark-ips//lib/benchmark/ips.rb#137 + def humanize_duration(duration_ns); end + + # source://benchmark-ips//lib/benchmark/ips.rb#124 + def scale(value); end + end +end + +# source://benchmark-ips//lib/benchmark/ips.rb#114 +Benchmark::IPS::Helpers::SUFFIXES = T.let(T.unsafe(nil), Array) + +# Benchmark jobs. +# +# source://benchmark-ips//lib/benchmark/ips/job/entry.rb#4 +class Benchmark::IPS::Job + # Instantiate the Benchmark::IPS::Job. + # + # @return [Job] a new instance of Job + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#67 + def initialize(opts = T.unsafe(nil)); end + + # @return [Boolean] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#241 + def all_results_have_been_run?; end + + # source://benchmark-ips//lib/benchmark/ips/job.rb#245 + def clear_held_results; end + + # Determining whether to run comparison utility. + # + # @return [Boolean] true if needs to run compare. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#20 + def compare; end + + # Run comparison utility. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#124 + def compare!(order: T.unsafe(nil)); end + + # Return true if job needs to be compared. + # + # @return [Boolean] Need to compare? + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#119 + def compare?; end + + # Confidence. + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#52 + def confidence; end + + # Confidence. + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#52 + def confidence=(_arg0); end + + # Job configuration options, set +@warmup+ and +@time+. + # + # @option iterations + # @option opts + # @option opts + # @param iterations [Hash] a customizable set of options + # @param opts [Hash] a customizable set of options + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#95 + def config(opts); end + + # Create report by add entry to +@full_report+. + # + # @param cycles [Integer] Number of Cycles. + # @param iter [Integer] Iterations. + # @param label [String] Report item label. + # @param measured_us [Integer] Measured time in microsecond. + # @param samples [Array] Sampled iterations per second. + # @return [Report::Entry] Entry with data. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#392 + def create_report(label, measured_us, iter, samples, cycles); end + + # source://benchmark-ips//lib/benchmark/ips/job.rb#364 + def create_stats(samples); end + + # Calculate the cycles needed to run for approx 100ms, + # given the number of iterations to run the given time. + # + # @param iters [Integer] Iterations. + # @param time_msec [Float] Each iteration's time in ms. + # @return [Integer] Cycles per 100ms. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#193 + def cycles_per_100ms(time_msec, iters); end + + # Report object containing information about the run. + # + # @return [Report] the report object. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#28 + def full_report; end + + # Generate json from +@full_report+. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#381 + def generate_json; end + + # Determining whether to hold results between Ruby invocations + # + # @return [Boolean] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#24 + def hold; end + + # Hold after each iteration. + # + # @param held_path [String] File name to store hold file. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#137 + def hold!(held_path); end + + # Determining whether to hold results between Ruby invocations + # + # @return [Boolean] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#24 + def hold=(_arg0); end + + # Return true if results are held while multiple Ruby invocations + # + # @return [Boolean] Need to hold results between multiple Ruby invocations? + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#131 + def hold?; end + + # Registers the given label and block pair in the job list. + # + # @param blk [Proc] Code to be benchmarked. + # @param label [String] Label of benchmarked code. + # @param str [String] Code to be benchmarked. + # @raise [ArgumentError] Raises if str and blk are both present. + # @raise [ArgumentError] Raises if str and blk are both absent. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#175 + def item(label = T.unsafe(nil), str = T.unsafe(nil), &blk); end + + # Warmup and calculation iterations. + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#44 + def iterations; end + + # Warmup and calculation iterations. + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#44 + def iterations=(_arg0); end + + # Calculate the iterations per second given the number + # of cycles run and the time in microseconds that elapsed. + # + # @param cycles [Integer] Cycles. + # @param time_us [Integer] Time in microsecond. + # @return [Float] Iteration per second. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#211 + def iterations_per_sec(cycles, time_us); end + + # Generate json to given path, defaults to "data.json". + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#165 + def json!(path = T.unsafe(nil)); end + + # Return true if job needs to generate json. + # + # @return [Boolean] Need to generate json? + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#160 + def json?; end + + # Two-element arrays, consisting of label and block pairs. + # + # @return [Array] list of entries + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#16 + def list; end + + # source://benchmark-ips//lib/benchmark/ips/job.rb#215 + def load_held_results; end + + # Silence output + # + # @return [Boolean] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#56 + def quiet; end + + # source://benchmark-ips//lib/benchmark/ips/job.rb#105 + def quiet=(val); end + + # Registers the given label and block pair in the job list. + # + # @param blk [Proc] Code to be benchmarked. + # @param label [String] Label of benchmarked code. + # @param str [String] Code to be benchmarked. + # @raise [ArgumentError] Raises if str and blk are both present. + # @raise [ArgumentError] Raises if str and blk are both absent. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#186 + def report(label = T.unsafe(nil), str = T.unsafe(nil), &blk); end + + # source://benchmark-ips//lib/benchmark/ips/job.rb#249 + def run; end + + # Run calculation. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#312 + def run_benchmark; end + + # Run comparison of entries in +@full_report+. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#376 + def run_comparison; end + + # Return true if items are to be run one at a time. + # For the traditional hold, this is true + # + # @return [Boolean] Run just a single item? + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#154 + def run_single?; end + + # Run warmup. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#267 + def run_warmup; end + + # Save interim results. Similar to hold, but all reports are run + # The report label must change for each invocation. + # One way to achieve this is to include the version in the label. + # + # @param held_path [String] File name to store hold file. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#146 + def save!(held_path); end + + # source://benchmark-ips//lib/benchmark/ips/job.rb#226 + def save_held_results; end + + # Statistics model. + # + # @return [Object] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#48 + def stats; end + + # Statistics model. + # + # @return [Object] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#48 + def stats=(_arg0); end + + # Suite + # + # @return [Benchmark::IPS::MultiReport] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#62 + def suite; end + + # source://benchmark-ips//lib/benchmark/ips/job.rb#113 + def suite=(suite); end + + # Calculation time setter and getter (in seconds). + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#40 + def time; end + + # Calculation time setter and getter (in seconds). + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#40 + def time=(_arg0); end + + # Calculate the time difference of before and after in microseconds. + # + # @param after [Time] time. + # @param before [Time] time. + # @return [Float] Time difference of before and after. + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#202 + def time_us(before, after); end + + # Storing Iterations in time period. + # + # @return [Hash] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#32 + def timing; end + + # Warmup time setter and getter (in seconds). + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#36 + def warmup; end + + # Warmup time setter and getter (in seconds). + # + # @return [Integer] + # + # source://benchmark-ips//lib/benchmark/ips/job.rb#36 + def warmup=(_arg0); end +end + +# Entries in Benchmark Jobs. +# +# source://benchmark-ips//lib/benchmark/ips/job/entry.rb#6 +class Benchmark::IPS::Job::Entry + # Instantiate the Benchmark::IPS::Job::Entry. + # + # @param action [String, Proc] Code to be benchmarked. + # @param label [#to_s] Label of Benchmarked code. + # @raise [ArgumentError] Raises when action is not String or not responding to +call+. + # @return [Entry] a new instance of Entry + # + # source://benchmark-ips//lib/benchmark/ips/job/entry.rb#11 + def initialize(label, action); end + + # The benchmarking action. + # + # @return [String, Proc] Code to be called, could be String / Proc. + # + # source://benchmark-ips//lib/benchmark/ips/job/entry.rb#41 + def action; end + + # Call action by given times. + # + # @param times [Integer] Times to call +@action+. + # @return [Integer] Number of times the +@action+ has been called. + # + # source://benchmark-ips//lib/benchmark/ips/job/entry.rb#46 + def call_times(times); end + + # source://benchmark-ips//lib/benchmark/ips/job/entry.rb#50 + def compile_block; end + + # source://benchmark-ips//lib/benchmark/ips/job/entry.rb#66 + def compile_block_with_manual_loop; end + + # Compile code into +call_times+ method. + # + # @param str [String] Code to be compiled. + # @return [Symbol] :call_times. + # + # source://benchmark-ips//lib/benchmark/ips/job/entry.rb#79 + def compile_string(str); end + + # The label of benchmarking action. + # + # @return [#to_s] Label of action. + # + # source://benchmark-ips//lib/benchmark/ips/job/entry.rb#37 + def label; end +end + +# The percentage of the expected runtime to allow +# before reporting a weird runtime +# +# source://benchmark-ips//lib/benchmark/ips/job.rb#11 +Benchmark::IPS::Job::MAX_TIME_SKEW = T.let(T.unsafe(nil), Float) + +# Microseconds per 100 millisecond. +# +# source://benchmark-ips//lib/benchmark/ips/job.rb#6 +Benchmark::IPS::Job::MICROSECONDS_PER_100MS = T.let(T.unsafe(nil), Integer) + +# Microseconds per second. +# +# source://benchmark-ips//lib/benchmark/ips/job.rb#8 +Benchmark::IPS::Job::MICROSECONDS_PER_SECOND = T.let(T.unsafe(nil), Integer) + +# source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#4 +class Benchmark::IPS::Job::MultiReport + # @param out [Array] list of reports to send output + # @return [MultiReport] a new instance of MultiReport + # + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#32 + def initialize(out = T.unsafe(nil)); end + + # @param report [StreamReport] report to accept input? + # + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#21 + def <<(report); end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#57 + def add_report(item, caller); end + + # @return [Boolean] + # + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#8 + def empty?; end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#61 + def footer; end + + # Returns the value of attribute out. + # + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#6 + def out; end + + # Sets the attribute out + # + # @param value the value to set the attribute out to. + # + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#6 + def out=(_arg0); end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#16 + def quiet!; end + + # @return [Boolean] + # + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#12 + def quiet?; end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#53 + def running(label, warmup); end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#49 + def start_running; end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#37 + def start_warming; end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#41 + def warming(label, warmup); end + + # source://benchmark-ips//lib/benchmark/ips/job/multi_report.rb#45 + def warmup_stats(warmup_time_us, timing); end +end + +# source://benchmark-ips//lib/benchmark/ips/job.rb#12 +Benchmark::IPS::Job::POW_2_30 = T.let(T.unsafe(nil), Integer) + +# source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#4 +class Benchmark::IPS::Job::StreamReport + # @return [StreamReport] a new instance of StreamReport + # + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#5 + def initialize(stream = T.unsafe(nil)); end + + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#34 + def add_report(item, caller); end + + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#39 + def footer; end + + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#32 + def running(label, _warmup); end + + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#15 + def start_running; end + + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#10 + def start_warming; end + + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#19 + def warming(label, _warmup); end + + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#23 + def warmup_stats(_warmup_time_us, timing); end + + private + + # @return [Symbol] format used for benchmarking + # + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#48 + def format; end + + # Add padding to label's right if label's length < 20, + # Otherwise add a new line and 20 whitespaces. + # + # @return [String] Right justified label. + # + # source://benchmark-ips//lib/benchmark/ips/job/stream_report.rb#55 + def rjust(label); end +end + +# Report contains benchmarking entries. +# Perform operations like add new entry, run comparison between entries. +# +# source://benchmark-ips//lib/benchmark/ips/report.rb#8 +class Benchmark::IPS::Report + # Instantiate the Report. + # + # @return [Report] a new instance of Report + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#137 + def initialize; end + + # Add entry to report. + # + # @param iters [Integer] Iterations. + # @param label [String] Entry label. + # @param measurement_cycle [Integer] Number of cycles. + # @param microseconds [Integer] Measured time in microsecond. + # @param stats [Object] Statistical results. + # @return [Report::Entry] Last added entry. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#149 + def add_entry(label, microseconds, iters, stats, measurement_cycle); end + + # Entries data in array for generate json. + # Each entry is a hash, consists of: + # name: Entry#label + # ips: Entry#ips + # stddev: Entry#ips_sd + # microseconds: Entry#microseconds + # iterations: Entry#iterations + # cycles: Entry#measurement_cycles + # + # @return [Array] Array of hashes] Array] Array of hashes + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#165 + def data; end + + # Entry to represent each benchmarked code in Report. + # + # @return [Array] Entries in Report. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#134 + def entries; end + + # Generate json from Report#data to given path. + # + # @param path [String] path to generate json. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#187 + def generate_json(path); end + + # Run comparison of entries. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#181 + def run_comparison(order); end +end + +# Represents benchmarking code data for Report. +# +# source://benchmark-ips//lib/benchmark/ips/report.rb#11 +class Benchmark::IPS::Report::Entry + # Instantiate the Benchmark::IPS::Report::Entry. + # + # @param cycles [Integer] Number of Cycles. + # @param iters [Integer] Iterations. + # @param label [#to_s] Label of entry. + # @param stats [Object] Statistics. + # @param us [Integer] Measured time in microsecond. + # @return [Entry] a new instance of Entry + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#18 + def initialize(label, us, iters, stats, cycles); end + + # Return Entry body text with left padding. + # Body text contains information of iteration per second with + # percentage of standard deviation, iterations in runtime. + # + # @return [String] Left justified body. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#88 + def body; end + + # Print entry to current standard output ($stdout). + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#125 + def display; end + + # Return entry's standard deviation of iteration per second in percentage. + # + # @return [Float] +@ips_sd+ in percentage. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#78 + def error_percentage; end + + # Return header with padding if +@label+ is < length of 20. + # + # @return [String] Right justified header (+@label+). + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#114 + def header; end + + # LEGACY: Iterations per second. + # + # @return [Float] number of iterations per second. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#45 + def ips; end + + # LEGACY: Standard deviation of iteration per second. + # + # @return [Float] standard deviation of iteration per second. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#51 + def ips_sd; end + + # Number of Iterations. + # + # @return [Integer] number of iterations. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#37 + def iterations; end + + # Label of entry. + # + # @return [String] the label of entry. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#29 + def label; end + + # Number of Cycles. + # + # @return [Integer] number of cycles. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#61 + def measurement_cycle; end + + # Measured time in microsecond. + # + # @return [Integer] number of microseconds. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#33 + def microseconds; end + + # Return entry's microseconds in seconds. + # + # @return [Float] +@microseconds+ in seconds. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#82 + def runtime; end + + # source://benchmark-ips//lib/benchmark/ips/report.rb#55 + def samples; end + + # Return entry's microseconds in seconds. + # + # @return [Float] +@microseconds+ in seconds. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#72 + def seconds; end + + # Control if the total time the job took is reported. + # Typically this value is not significant because it's very + # close to the expected time, so it's suppressed by default. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#66 + def show_total_time!; end + + # Statistical summary of samples. + # + # @return [Object] statisical summary. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#41 + def stats; end + + # Return string representation of Entry object. + # + # @return [String] Header and body. + # + # source://benchmark-ips//lib/benchmark/ips/report.rb#120 + def to_s; end +end + +# source://benchmark-ips//lib/benchmark/ips/stats/stats_metric.rb#3 +module Benchmark::IPS::Stats; end + +# source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#5 +class Benchmark::IPS::Stats::Bootstrap + include ::Benchmark::IPS::Stats::StatsMetric + + # @return [Bootstrap] a new instance of Bootstrap + # + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#9 + def initialize(samples, confidence); end + + # Average stat value + # + # @return [Float] central_tendency + # + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#22 + def central_tendency; end + + # Returns the value of attribute data. + # + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#7 + def data; end + + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#44 + def dependencies; end + + # Returns the value of attribute error. + # + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#7 + def error; end + + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#40 + def footer; end + + # Returns the value of attribute samples. + # + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#7 + def samples; end + + # Determines how much slower this stat is than the baseline stat + # if this average is lower than the faster baseline, higher average is better (e.g. ips) (calculate accordingly) + # + # @param baseline [SD|Bootstrap] faster baseline + # + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#30 + def slowdown(baseline); end + + # source://benchmark-ips//lib/benchmark/ips/stats/bootstrap.rb#36 + def speedup(baseline); end +end + +# source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#5 +class Benchmark::IPS::Stats::SD + include ::Benchmark::IPS::Stats::StatsMetric + + # @return [SD] a new instance of SD + # + # source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#9 + def initialize(samples); end + + # Average stat value + # + # @return [Float] central_tendency + # + # source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#17 + def central_tendency; end + + # Returns the value of attribute error. + # + # source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#7 + def error; end + + # source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#37 + def footer; end + + # Returns the value of attribute samples. + # + # source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#7 + def samples; end + + # Determines how much slower this stat is than the baseline stat + # if this average is lower than the faster baseline, higher average is better (e.g. ips) (calculate accordingly) + # + # @param baseline [SD|Bootstrap] faster baseline + # + # source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#25 + def slowdown(baseline); end + + # source://benchmark-ips//lib/benchmark/ips/stats/sd.rb#33 + def speedup(baseline); end +end + +# source://benchmark-ips//lib/benchmark/ips/stats/stats_metric.rb#4 +module Benchmark::IPS::Stats::StatsMetric + # Return entry's standard deviation of iteration per second in percentage. + # + # @return [Float] +@ips_sd+ in percentage. + # + # source://benchmark-ips//lib/benchmark/ips/stats/stats_metric.rb#7 + def error_percentage; end + + # @return [Boolean] + # + # source://benchmark-ips//lib/benchmark/ips/stats/stats_metric.rb#11 + def overlaps?(baseline); end +end + +# Benchmark-ips Gem version. +# +# source://benchmark-ips//lib/benchmark/ips.rb#23 +Benchmark::IPS::VERSION = T.let(T.unsafe(nil), String) + +# Perform calculations on Timing results. +# +# source://benchmark-ips//lib/benchmark/timing.rb#3 +module Benchmark::Timing + class << self + # source://benchmark-ips//lib/benchmark/timing.rb#54 + def add_second(t, s); end + + # Recycle used objects by starting Garbage Collector. + # + # source://benchmark-ips//lib/benchmark/timing.rb#35 + def clean_env; end + + # Calculate (arithmetic) mean of given samples. + # + # @param samples [Array] Samples to calculate mean. + # @return [Float] Mean of given samples. + # + # source://benchmark-ips//lib/benchmark/timing.rb#10 + def mean(samples); end + + # source://benchmark-ips//lib/benchmark/timing.rb#49 + def now; end + + # Calculate standard deviation of given samples. + # + # @param m [Float] Optional mean (Expected value). + # @param samples [Array] Samples to calculate standard deviation. + # @return [Float] standard deviation of given samples. + # + # source://benchmark-ips//lib/benchmark/timing.rb#30 + def stddev(samples, m = T.unsafe(nil)); end + + # source://benchmark-ips//lib/benchmark/timing.rb#59 + def time_us(before, after); end + + # Calculate variance of given samples. + # + # @param m [Float] Optional mean (Expected value). + # @return [Float] Variance of given samples. + # + # source://benchmark-ips//lib/benchmark/timing.rb#18 + def variance(samples, m = T.unsafe(nil)); end + end +end + +# Microseconds per second. +# +# source://benchmark-ips//lib/benchmark/timing.rb#5 +Benchmark::Timing::MICROSECONDS_PER_SECOND = T.let(T.unsafe(nil), Integer) diff --git a/sorbet/rbi/shims/core.rbi b/sorbet/rbi/shims/core.rbi new file mode 100644 index 0000000..23bedc6 --- /dev/null +++ b/sorbet/rbi/shims/core.rbi @@ -0,0 +1,19 @@ +# typed: strict +# frozen_string_literal: true + +# A shim module representing (a subset of) the common interface between Method and UnboundMethod. +module MethodOrUnboundMethod + sig { returns(T::Module[T.untyped]) } + def owner; end + + sig { returns(Symbol) } + def name; end +end + +class Method + include MethodOrUnboundMethod +end + +class UnboundMethod + include MethodOrUnboundMethod +end diff --git a/spec/interface_spec.rb b/spec/interface_spec.rb new file mode 100644 index 0000000..24ddc54 --- /dev/null +++ b/spec/interface_spec.rb @@ -0,0 +1,405 @@ +# typed: ignore +# frozen_string_literal: true + +require "spec_helper" + +module TypeToolkit + class InterfaceSpec < Minitest::Spec + module SimpleInterface + interface! + + abstract def m1 = assert_never_called! + abstract def m2 = assert_never_called! + end + + # A normal class that's completely unrelated `SimpleInterface`. + class NonImpl + def m1 = "NonImpl#m1" + # Does not implement `m2` + end + + # A class that implements some but not all of the SimpleInterface's abstract methods. + class PartialImpl + include SimpleInterface + + def m1 = "PartialImpl#m1" + # Does not provide an implementation for `m2` + end + + # A class that implements all of the SimpleInterface's abstract methods. + class FullImpl + include SimpleInterface + + def initialize + @a = 1 + @b = 2 + end + + def m1 = "FullImpl#m1" + def m2 = "FullImpl#m2" + end + + # A class that provides a partial implementation of `SimpleInterface` for `PartiallyInheritsItsImpl`. + class PartialParent + def m1 = "PartialParent#m1" + # Does not implement `m2` + end + + # A class that implements all of the SimpleInterface's abstract methods, some via inheritance, and some via direct implementation. + class PartiallyInheritsItsImpl < PartialParent + include SimpleInterface + + def m2 = "PartiallyInheritsItsImpl#m2" + end + + describe "An interface" do + describe ".abstract macro" do + it "returns the method name" do + test_context = self + + Module.new do + interface! + + test_context.assert_equal(:the_method_name, abstract(def the_method_name; end)) + end + end + + it "raises when it sees a different method name than what MethodDefRecorder recorded" do + error = assert_raises(RuntimeError) do + Module.new do + interface! + + # Simulate a conflicting `method_added` call by sneaking in a method definition + # between the `def` and the `abstract` call. + def m1; end + + def something_else; end + + abstract(:m1) + end + end + + # Do not rely on this message content! Its content is subject to change! + assert_equal <<~EXPECTED.chomp, error.message + `abstract` expected to see `#m1`, but the last recorded method was called `something_else`. + This can happen when `abstract` is combined with other metaprogramming. + If you think this is a bug, please open an issue: https://github.com/Shopify/type_toolkit/issues + EXPECTED + end + end + + describe ".declared_abstract_instance_methods" do + it "contains all declared abstract methods" do + assert_equal [:m1, :m2], SimpleInterface.declared_abstract_instance_methods + assert_equal [:m1, :m2], SimpleInterface.declared_abstract_instance_methods(true) + assert_equal [:m1, :m2], SimpleInterface.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "only contains unimplemented abstract methods" do + assert_equal [:m1, :m2], SimpleInterface.abstract_instance_methods + assert_equal [:m1, :m2], SimpleInterface.abstract_instance_methods(true) + assert_equal [:m1, :m2], SimpleInterface.abstract_instance_methods(false) + end + end + + describe ".abstract_method?" do + it "returns true for abstract methods" do + assert SimpleInterface.abstract_method?(:m1) + assert SimpleInterface.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute SimpleInterface.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "returns true for abstract methods" do + assert SimpleInterface.abstract_method_declared?(:m1) + assert SimpleInterface.abstract_method_declared?(:m2) + end + end + end + + describe "A class that does not implement the interface" do + describe "a method with the same name as another interface's members" do + # These tests sanity-check that our runtime trickery didn't accidentally change the + # normal behaviour of method calling and `method_missing` for unrelated classes. + + before do + @class = NonImpl + @x = NonImpl.new + end + + it "#respond_to? returns true" do + assert_respond_to @x, :m1 + end + + it "can be called like normal" do + assert_equal "NonImpl#m1", @x.m1 + end + + describe "a Method object for it" do + it "can be called like normal" do + assert_equal "NonImpl#m1", @x.method(:m1).call + end + + it "is not abstract" do + refute_predicate @x.method(:m1), :abstract? + end + end + + describe "an UnboundMethod object for it" do + before { @um = @x.method(:m1).unbind } + it "can be called like normal" do + assert_equal "NonImpl#m1", @um.bind_call(@x) + end + + it "is not abstract" do + refute_predicate @um, :abstract? + end + end + + it "calls a defined method like normal" do + assert_respond_to @x, :m1 + assert_equal "NonImpl#m1", @x.m1 + assert_equal "NonImpl#m1", @x.method(:m1).call + refute_predicate @x.method(:m1), :abstract? + refute_predicate @x.method(:m1).unbind, :abstract? + end + + it "raises NoMethodError for an undefined method like normal" do + refute_respond_to @x, :m2 + assert_raises(NoMethodError) { @x.m2 } + end + end + + it "does not respond to .abstract_method?" do + refute_respond_to @class, :abstract_method? + assert_raises(NoMethodError) { @class.abstract_method?(:m1) } + end + + it "does not respond to .abstract_method_declared?" do + refute_respond_to @class, :abstract_method_declared? + assert_raises(NoMethodError) { @class.abstract_method_declared?(:m1) } + end + + it "does not respond to .declared_abstract_instance_methods" do + refute_respond_to @class, :declared_abstract_instance_methods + assert_raises(NoMethodError) { @class.declared_abstract_instance_methods } + end + + it "does not respond to .abstract_instance_methods" do + refute_respond_to @class, :abstract_instance_methods + assert_raises(NoMethodError) { @class.abstract_instance_methods } + end + end + + describe "A class that partially implements the interface" do + before do + @class = PartialImpl + @x = PartialImpl.new + end + + describe "calling an implemented abstract method" do + it "calls the concrete implementation" do + assert_respond_to @x, :m1 + assert_equal "PartialImpl#m1", @x.m1 + assert_equal "PartialImpl#m1", @x.method(:m1).call + refute_predicate @x.method(:m1), :abstract? + refute_predicate @x.method(:m1).unbind, :abstract? + end + end + + describe "calling an unimplemented abstract method" do + it "raises AbstractMethodNotImplementedError" do + assert_respond_to @x, :m2 + + # Notice it's not `NoMethodError`, so we can give a better error message. + e = assert_abstract { @x.m2 } + + # Do not rely on this message content! Its content is subject to change! + # We only test it to ensure it's formatted correctly. + assert_equal "Abstract method `#m2` was never implemented.", e.message + + m2 = @x.method(:m2) + assert_kind_of Method, m2 + assert_predicate m2, :abstract? + assert_predicate m2.unbind, :abstract? + end + end + + describe "calling a non-abstract method" do + it "calls the concrete implementation" do + assert_respond_to @x, :inspect + assert_kind_of String, @x.inspect + assert_kind_of String, @x.method(:inspect).call + refute_predicate @x.method(:inspect), :abstract? + refute_predicate @x.method(:inspect).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute @class.abstract_method?(:m1) + end + + it "returns true for abstract methods that have not been implemented" do + assert @class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute @class.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "is true for all abstract methods" do + assert @class.abstract_method_declared?(:m1) # Even the one that's been implemented + assert @class.abstract_method_declared?(:m2) + end + + it "is false for non-abstract methods" do + assert @class.public_method_defined?(:inspect) # precondition + refute @class.abstract_method_declared?(:inspect) + end + end + + describe ".declared_abstract_instance_methods" do + it "returns all declared abstract methods, even those that have been implemented" do + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods(true) + assert_equal [], @class.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [:m2], @class.abstract_instance_methods + assert_equal [:m2], @class.abstract_instance_methods(true) + assert_equal [], @class.abstract_instance_methods(false) + end + end + end + + describe "A class that fully implements the interface" do + before do + @class = FullImpl + @x = FullImpl.new + end + + describe "calling an implemented abstract method" do + it "calls the concrete implementation" do + assert_respond_to @x, :m1 + assert_equal "FullImpl#m1", @x.m1 + assert_equal "FullImpl#m1", @x.method(:m1).call + refute_predicate @x.method(:m1), :abstract? + refute_predicate @x.method(:m1).unbind, :abstract? + + assert_respond_to @x, :m2 + assert_equal "FullImpl#m2", @x.m2 + assert_equal "FullImpl#m2", @x.method(:m2).call + refute_predicate @x.method(:m2), :abstract? + refute_predicate @x.method(:m2).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute @class.abstract_method?(:m1) + refute @class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute @class.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "returns true for all abstract methods" do + assert @class.abstract_method_declared?(:m1) + assert @class.abstract_method_declared?(:m2) + end + end + + describe ".declared_abstract_instance_methods" do + it "returns all declared abstract methods, even those that have been implemented" do + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods(true) + assert_equal [], @class.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [], @class.abstract_instance_methods + assert_equal [], @class.abstract_instance_methods(true) + assert_equal [], @class.abstract_instance_methods(false) + end + end + end + + describe "A class that fully implements the interface, partially via inheritance" do + before do + @class = PartiallyInheritsItsImpl + @x = PartiallyInheritsItsImpl.new + end + + describe "calling an abstract method with an inherited implementation" do + it "calls the inherited implementation" do + assert_respond_to @x, :m1 + assert_equal "PartialParent#m1", @x.m1 + assert_equal "PartialParent#m1", @x.method(:m1).call + refute_predicate @x.method(:m1), :abstract? + refute_predicate @x.method(:m1).unbind, :abstract? + end + end + + describe "calling an abstract method with defined in the child implementation" do + it "calls the child implementation" do + assert_respond_to @x, :m2 + assert_equal "PartiallyInheritsItsImpl#m2", @x.m2 + assert_equal "PartiallyInheritsItsImpl#m2", @x.method(:m2).call + refute_predicate @x.method(:m2), :abstract? + refute_predicate @x.method(:m2).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute @class.abstract_method?(:m1) + refute @class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute @class.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "returns true for all abstract methods" do + assert @class.abstract_method_declared?(:m1) + assert @class.abstract_method_declared?(:m2) + end + end + + describe ".declared_abstract_instance_methods" do + it "returns all declared abstract methods, even those that have been implemented" do + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods(true) + assert_equal [], @class.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [], @class.abstract_instance_methods + assert_equal [], @class.abstract_instance_methods(true) + assert_equal [], @class.abstract_instance_methods(false) + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 26f6041..05beae6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,3 +6,17 @@ require "minitest/autorun" require "minitest/spec" + +module Minitest + class Spec + #: () { () -> void } -> TypeToolkit::AbstractMethodNotImplementedError + def assert_abstract(&block) + assert_raises(TypeToolkit::AbstractMethodNotImplementedError, &block) + end + end +end + +#: -> bot +def assert_never_called! + raise Minitest::Assertion, "This should never be called" +end