Last active
June 9, 2022 07:57
-
-
Save lygaret/f231f31f751c64e650d3993671fc8f6d to your computer and use it in GitHub Desktop.
Revisions
-
lygaret revised this gist
Jun 9, 2022 . 1 changed file with 6 additions and 0 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,6 @@ -m markdown --private --protected --embed-mixins --default-return void **/*.rb -
lygaret revised this gist
Jun 9, 2022 . 1 changed file with 1 addition and 1 deletion.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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/f231f31f751c64e650d3993671fc8f6d.git" ``` -
lygaret revised this gist
Jun 9, 2022 . 3 changed files with 222 additions and 0 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 -
lygaret created this gist
Jun 9, 2022 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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" ```