Monkey-patching methods only inside my own code (by automatically using refinements?)

124 Views Asked by At

In my Ruby (on Rails) project, I’d like to forbid or restrict the usage of some methods provided by the standard library. Examples: I’d like to forbid calling Float#to_d because I had a rounding error when someone was using that method on a Float literal. I’d like to restrict String#to_d to work only with fully valid Strings because I had some bug resulting from 'string'.to_d returning 0.0.

Monkey-patching / overriding these methods globally is of course a bad idea. It may break some dependency.

Adding a linter rule that scans the code to not have calls to any #to_d method has the problem that it falsely restricts calling legit methods like Integer#to_d. Of course, all the legit methods could be added to the corresponding classes under a different name. But this requires adding a lot of boilerplace (for the methods) and changing all calls of these methods.

I also considered using refinements. This would be similar to monkey-patching, but apply only to scopes where the refinement is used. However, having to add using statements to every file would be ugly and error-prone. Is it possible to activate a refinement automatically for every file in my project, but not for dependencies?

1

There are 1 best solutions below

0
Amadan On

You can monkeypatch if you're careful about it. I tried to implement it, see if it works. Obviously, you'll take a large performance hit, so I wouldn't recommend it in production :P One could speed it up quite a bit by memoising the tested locations instead of going up the directory tree each time the method is called.

The idea is to move the original method out of the way if defined on the class, then substitute a method that will check whether or not you're calling from your code. I'm using the directory that contains .git directory as your project directory; if you have a vendor directory directly in your project directory, it is exempt, as is everything outside the project directory. If you are in a location that is exempt, just pass things along to either the saved old method or up the inheritance chain; if not, scream foul.

require 'pathname'

PROJECT_CODE = Pathname.new(__dir__).ascend.find { |loc| (loc / '.git').directory? }
VENDOR_CODE = PROJECT_CODE / 'vendor'

class ForbiddenMethodError < StandardError; end

def forbid_method(klass, meth, &block)
  case
  when klass.instance_methods(false).include?(meth)
    old_meth = :"forbid_method_old_#{meth}"
    klass.alias_method old_meth, meth
  when klass.respond_to?(meth)
    old_meth = nil
  else
    raise ArgumentError, "No such method: #{klass}##{meth}"
  end

  klass.define_method(meth) do |*args|
    if !block || instance_exec(*args, &block)
      caller_loc = Pathname.new(caller_locations.first.path).expand_path
      caller_loc.ascend do |ancestor|
        case ancestor
        when PROJECT_CODE
          raise ForbiddenMethodError, "#{klass}##{meth}", caller[2..]
        when VENDOR_CODE
          break
        end
      end
    end

    if old_meth
      send(old_meth, *args)
    else
      super(*args)
    end
  end
end

With this, you can make Float#to_d and (conditionally) String#to_d fail in project code:

require 'bigdecimal/util'
forbid_method(Float, :to_d)
forbid_method(String, :to_d) { !BigDecimal(self) rescue true }

If you pass a block, the function is only forbidden if the block condition is truthy. (The block will get passed all the method's arguments, and will execute with the forbidden method's receiver as self.)