Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions lib/type_toolkit/abstract_method_receiver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,28 @@ module AbstractInstanceMethodReceiver
# This `#method_missing` is hit when calling a potentially abstract method on an instance
# E.g. TheClass.new.maybe_abstract_method
#
# We call `super` first, so that another `method_missing` further up the ancestor chain
# gets a chance to provide a dynamic implementation of the abstract method.
# Only if nothing handled the call do we translate the resulting `NoMethodError`
# into an `AbstractMethodNotImplementedError`.
#
# (Symbol, ...) -> untyped
def method_missing(method_name, ...)
super
rescue NoMethodError => e
# A `NoMethodError` for a *different* method was raised from within a dynamic
# implementation of this method. That's a real error, not an unimplemented abstract method.
raise unless e.name == method_name

c = self.class #: as Class[top] & HasAbstractMethods

if c.abstract_method_declared?(method_name)
raise AbstractMethodNotImplementedError.new(method_name:)
# `cause: nil` hides the unactionable "undefined method" error, which would
# otherwise be noise underneath the more precise error we raise here.
raise AbstractMethodNotImplementedError.new(method_name:), cause: nil
end

super
raise
end

#: (Symbol, ?bool) -> bool
Expand Down
68 changes: 68 additions & 0 deletions spec/interface_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,43 @@ class PartiallyInheritsItsImpl < PartialParent
def m2 = "PartiallyInheritsItsImpl#m2"
end

# A class that provides a dynamic implementation of `m1` via `method_missing`, for `MethodMissingImpl`.
class MethodMissingParent
def method_missing(method_name, ...)
return super unless method_name == :m1

"MethodMissingParent#m1"
end

def respond_to_missing?(method_name, include_private = false)
method_name == :m1 || super
end
end

# A class that implements `m1` dynamically, via the `method_missing` it inherits.
class MethodMissingImpl < MethodMissingParent
include SimpleInterface
# Does not provide an implementation for `m2`
end

# A class whose dynamic implementation of `m1` itself calls an undefined method.
class BrokenMethodMissingParent
def method_missing(method_name, ...)
return super unless method_name == :m1

Object.new.some_undefined_helper
end

def respond_to_missing?(method_name, include_private = false)
method_name == :m1 || super
end
end

# A class whose inherited dynamic implementation of `m1` is broken.
class BrokenMethodMissingImpl < BrokenMethodMissingParent
include SimpleInterface
end

describe "An interface" do
describe ".abstract macro" do
it "returns the method name" do
Expand Down Expand Up @@ -401,5 +438,36 @@ def something_else; end
end
end
end

describe "A class that implements the interface via method_missing" do
before do
@x = MethodMissingImpl.new
end

describe "calling an abstract method implemented via method_missing" do
it "calls the dynamic implementation" do
assert_respond_to @x, :m1
assert_equal "MethodMissingParent#m1", @x.m1
end
end

describe "calling an unimplemented abstract method" do
it "raises AbstractMethodNotImplementedError, without a NoMethodError cause" do
e = assert_abstract { @x.m2 }

# The internal `NoMethodError` (from the call reaching `BasicObject#method_missing`)
# is unactionable noise, so it should not be exposed as the `cause`.
assert_nil e.cause
end
end

describe "calling an abstract method whose dynamic implementation is broken" do
it "propagates the NoMethodError raised inside the dynamic implementation" do
e = assert_raises(NoMethodError) { BrokenMethodMissingImpl.new.m1 }

assert_equal :some_undefined_helper, e.name
end
end
end
end
end