From 3d3be8295b412c990d1522c98035c6e0198a5da9 Mon Sep 17 00:00:00 2001 From: kshitiz sinha Date: Tue, 28 Jul 2026 03:17:39 +0530 Subject: [PATCH] Allow abstract methods to be implemented via method_missing Previously, AbstractInstanceMethodReceiver#method_missing raised AbstractMethodNotImplementedError before calling super, so a method_missing defined further up the ancestor chain never got the chance to provide a dynamic implementation of an abstract method. Now super is called first, and the resulting NoMethodError is only translated into AbstractMethodNotImplementedError when it is about the same method that was called. NoMethodErrors raised for other methods (e.g. from inside a broken dynamic implementation) propagate unchanged, and the translated error hides its NoMethodError cause, which is unactionable noise. Closes #11 --- lib/type_toolkit/abstract_method_receiver.rb | 17 ++++- spec/interface_spec.rb | 68 ++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) 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