diff --git a/lib/type_toolkit/abstract_method_receiver.rb b/lib/type_toolkit/abstract_method_receiver.rb index eab9238..4117e26 100644 --- a/lib/type_toolkit/abstract_method_receiver.rb +++ b/lib/type_toolkit/abstract_method_receiver.rb @@ -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 diff --git a/spec/interface_spec.rb b/spec/interface_spec.rb index 24ddc54..8f81e27 100644 --- a/spec/interface_spec.rb +++ b/spec/interface_spec.rb @@ -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 @@ -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