Skip to content

Instantly share code, notes, and snippets.

@lygaret
Last active June 9, 2022 07:57
Show Gist options
  • Select an option

  • Save lygaret/f231f31f751c64e650d3993671fc8f6d to your computer and use it in GitHub Desktop.

Select an option

Save lygaret/f231f31f751c64e650d3993671fc8f6d to your computer and use it in GitHub Desktop.

Revisions

  1. lygaret revised this gist Jun 9, 2022. 1 changed file with 6 additions and 0 deletions.
    6 changes: 6 additions & 0 deletions .yardopts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,6 @@
    -m markdown
    --private
    --protected
    --embed-mixins
    --default-return void
    **/*.rb
  2. lygaret revised this gist Jun 9, 2022. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -80,5 +80,5 @@ t.startup("other mode")
    Add this repo to your `Gemfile`:

    ```ruby
    gem "method-hooks", "~> 0", git: "https://gist.github.com/117441fc5236de9f7d54b76894d69dec.git"
    gem "method-hooks", "~> 0", git: "https://gist.github.com/f231f31f751c64e650d3993671fc8f6d.git"
    ```
  3. lygaret revised this gist Jun 9, 2022. 3 changed files with 222 additions and 0 deletions.
    15 changes: 15 additions & 0 deletions method-hooks.gemspec
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,15 @@
    require "yaml"

    header = YAML.safe_load_file("readme.md")
    Gem::Specification.new do |spec|
    spec.name = header["name"]
    spec.version = header["version"]
    spec.authors = ["Jon Raphaelson"]
    spec.email = ["jon@accidental.cc"]

    spec.summary = header["summary"]
    spec.license = "MIT"

    spec.files = Dir.glob("**/*.rb", base: __dir__)
    spec.require_paths = ["."]
    end
    121 changes: 121 additions & 0 deletions method-hooks.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,121 @@
    module Accidental
    # Included on a class, {Accidental::MethodHooks} extends that class -- and instances of
    # that class -- to support arbitrary hooks to be run before/after any method.
    #
    # One use case is to allow "plugins", where a plugin is a module that's included
    # in a class, and can participate in that class instance's lifecycle by hooking
    # lifecycle hook points exposed at the top level. See {System} for an example.
    #
    # Hooks are added by replacing the hooked method, and as such, hooks will not
    # be run if a method is redefined, or is defined _after_ the hook attempt.
    #
    # @example
    # class Foo
    # include Accidental::MethodHooks
    # def foobar
    # puts "Foo#foobar"
    # end
    # end
    #
    # Foo.hook(:before, :foobar) do |foo|
    # puts "hooked #{foo} before foobar!"
    # end
    #
    # f = Foo.new#
    # f.foobar
    # #=> hooked #<Foo:0x000000010bce5908> before foobar!
    # #=> Foo#foobar
    #
    # g = Foo.new
    # g.hook(:after, :foobar) do |foo|
    # puts "hooked g, specifically, after foobar! #{foo} == g is #{foo == g}"
    # end
    # #=> hooked #<Foo:0x000000010c6c84c0> before foobar!
    # #=> Foo#foobar
    # #=> hooked g, specifically, after foobar! #<Foo:0x000000010c6c84c0> == g is true
    module MethodHooks

    VALID_HOOKS = %i[before after around error].freeze

    def self.included(mod)
    mod.extend ClassMethods
    mod.prepend InstanceMethods
    end

    module InstanceMethods
    def initialize(...)
    super(...)

    # instance level hooks, through singleton classes
    singleton_class.extend ClassMethods
    end

    def hook(...) = singleton_class.hook(...)
    end

    module ClassMethods

    # Hook the given stage of the given method call on the reciever.
    # @param hook [string] a valid hook stage, see {VALID_HOOKS}
    # @param meth [symbol] the name of the method to hook, must _already_ be defined on the class/instance
    # @param callee [#call,nil] a callable, which will be the target of the hook, or nil if a block is given
    # @return the hooked object
    def hook(hook, meth, callee = nil, &block)
    raise ArgumentError, "hook: #{hook}?" unless VALID_HOOKS.include? hook
    raise ArgumentError, "hook: #{name}##{meth}?" unless instance_methods.include? meth
    raise ArgumentError, "hook: #{callee} _and_ block given!" if callee && block
    raise ArgumentError, "hook: #{callee} not callable?" if callee && !callee.respond_to?(:call)

    hooks_for(hook, meth) << (callee || block)
    ensure_hook(meth)

    self
    end

    private

    # @param hook [symbol] the hook stage to search for hooks
    # @param meth [symbol] the method to search for hooks
    # @return [Array<#call>] a modifiable array of callable hooks for the given method/hook
    def hooks_for(hook, meth)
    @hooks ||= {}
    @hooks[meth] ||= VALID_HOOKS.each_with_object({}) { |h, m| m[h] = [] }
    @hooks[meth][hook]
    end

    # ensure the given method has been overridden to invoke hooks
    # @param meth [symbol] the method to override with a hook
    def ensure_hook(meth)
    name = "__hooked_#{meth}"
    return if instance_methods.include?(name.to_sym)

    # redefine the hooked method to send the hooks and then call then aliased name
    alias_method name, meth
    define_method(meth) do |*args, **kwargs, &block|
    exec = proc { |hook| hook.call(self, *args, **kwargs) }
    hooks = [singleton_class, self.class]

    begin
    hooks.flat_map { _1.send(:hooks_for, :before, meth) }.each(&exec)
    send(name, *args, **kwargs, &block).tap do
    hooks.flat_map { _1.send(:hooks_for, :after, meth) }.each(&exec)
    end
    rescue => ex
    hooks.flat_map { _1.send(:hooks_for, :error, meth) }.each { _1.call(self, ex, *args, **kwargs) }
    raise
    end
    end

    # define_method(meth) do |*args, **kwargs, &block|
    # self.class.hooks_for(meth, :around)
    # .reverse
    # .reduce(runner) { |h, hs| ->(*a,**k) { hs.call(*a,**k,&h) } }
    # .call(*args, **kwargs, &block)
    # end

    # return with the hook registry for this method
    end
    end

    end
    end
    86 changes: 86 additions & 0 deletions method-hooks.test.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,86 @@
    require "minitest/mock"
    require "minitest/autorun"
    require "./method-hooks"

    describe Accidental::MethodHooks do

    before do
    @klass = Class.new do
    include Accidental::MethodHooks

    def some_hookpoint(*args, **kwargs, &block)
    [args, kwargs, block&.call(self, args, kwargs)]
    end
    end

    @subject = @klass.new
    end

    specify "with no hooks defined" do
    resp = @subject.some_hookpoint(1, 2, foo: "bar") { "block response" }

    refute_empty resp, "it should have been the args"
    args, kwargs, block = resp

    assert_equal args, [1, 2]
    assert_equal kwargs, { foo: "bar" }
    assert_equal block, "block response"
    end

    describe "with a class level hook defined" do
    before do
    @callspy = Minitest::Mock.new
    @klass.hook(:before, :some_hookpoint) do |target, *args, **kwargs|
    @callspy.hit(target, args, kwargs)
    end
    end

    it "should have called the spy with the args" do
    @callspy.expect(:hit, nil, [@subject, Array, Hash])

    @subject.some_hookpoint(1, 2, foo: "bar")
    assert @callspy.verify
    end

    it "should only have executed the caller block once (not in the hook)" do
    @blockspy = Minitest::Mock.new
    @blockspy.expect(:hit, nil, [@subject, [1,2], {foo:"bar"}])
    @callspy.expect(:hit, nil, [@subject, Array, Hash])

    @subject.some_hookpoint(1, 2, foo: "bar") do |target, args, kwargs|
    @blockspy.hit(target,args,kwargs)
    end

    assert @blockspy.verify
    assert @callspy.verify
    end
    end

    describe "with an instance level hook defined" do
    before do
    @classspy = Minitest::Mock.new
    @instancespy = Minitest::Mock.new

    @klass.hook(:before, :some_hookpoint) do |target|
    @classspy.hit(target)
    end

    @subject.hook(:before, :some_hookpoint) do |target|
    @instancespy.hit(target)
    end
    end

    it "should call both hooks" do
    @blockspy = Minitest::Mock.new
    @blockspy.expect(:hit, nil)
    @classspy.expect(:hit, nil, [@subject])
    @instancespy.expect(:hit, nil, [@subject])

    @subject.some_hookpoint { @blockspy.hit }

    assert @blockspy.verify
    assert @classspy.verify
    assert @instancespy.verify
    end
    end
    end
  4. lygaret created this gist Jun 9, 2022.
    84 changes: 84 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,84 @@
    ---
    name: method-hooks
    version: 0.0.1
    summary: |
    a small, simple mixin for adding before/after hooks to methods
    on your objects
    ---

    Adds the ability to hook methods at a class or instance level,
    in order to colocate side-effects where they belong.

    Designed to handle separating "plugins" out of a main class file
    into smaller concerns, but still needing to participate in the
    classes full lifecycle.

    ### Example

    ```ruby
    require 'method-hooks'

    class System
    # install by including the module
    include Accidental::MethodHooks

    # any method is a hook point
    def startup(mode)
    puts "System#startup! (#{mode})"
    end

    # any method you'd like to hook needs to be defined first
    # so, include system plugins after hook point definitions
    include System::Plugin
    end

    # then, add some functionality via mixin

    module System::Plugin
    # the "included" callback takes care of registering hooks
    def self.included(mod)
    mod.hook(:after, :startup, method(:plugin_startup))
    end

    # hook blocks (or any callable) are passed:
    # the target and the original method arguments.
    def plugin_startup(self, mode)
    puts "System::Plugin#plugin_startup! #{mode}"
    end
    end

    # and then in use, ...

    s = System.new
    t = System.new

    # you can hook at a class level...
    System.hook(:after, :startup) do |system, mode|
    puts "class-level hook! #{mode}"
    end

    # or at an instance level, for hooks pertaining a single instance
    t.hook(:after, :startup) do |target, mode|
    assert t.equal? target
    puts "t.startup! #{mode}"
    end

    s.startup("some mode")
    # => System#startup! (some mode)
    # => System::Plugin#plugin_startup! some mode
    # => class-level hook! some mode

    t.startup("other mode")
    # => System#startup! (other mode)
    # => System::Plugin#plugin_startup! other mode
    # => class-level hook! other mode
    # => t.startup! other mode
    ```

    ## Usage

    Add this repo to your `Gemfile`:

    ```ruby
    gem "method-hooks", "~> 0", git: "https://gist.github.com/117441fc5236de9f7d54b76894d69dec.git"
    ```