require 'set'
require 'generator'
require 'rubygems'
require 'fail_fast'
# HookR is a library providing "hooks", aka "signals and slots", aka "events" to
# your Ruby classes.
module HookR
# No need to document the boilerplate convenience methods defined by Mr. Bones.
# :stopdoc:
VERSION = '1.1.0'
LIBPATH = ::File.expand_path(::File.dirname(__FILE__)) + ::File::SEPARATOR
PATH = ::File.dirname(LIBPATH) + ::File::SEPARATOR
# Returns the version string for the library.
#
def self.version
VERSION
end
# Returns the library path for the module. If any arguments are given,
# they will be joined to the end of the libray path using
# File.join.
#
def self.libpath( *args )
args.empty? ? LIBPATH : ::File.join(LIBPATH, args.flatten)
end
# Returns the lpath for the module. If any arguments are given,
# they will be joined to the end of the path using
# File.join.
#
def self.path( *args )
args.empty? ? PATH : ::File.join(PATH, args.flatten)
end
# Utility method used to rquire all files ending in .rb that lie in the
# directory below this file that has the same name as the filename passed
# in. Optionally, a specific _directory_ name can be passed in such that
# the _filename_ does not have to be equivalent to the directory.
#
def self.require_all_libs_relative_to( fname, dir = nil )
dir ||= ::File.basename(fname, '.*')
search_me = ::File.expand_path(
::File.join(::File.dirname(fname), dir, '*', '*.rb'))
Dir.glob(search_me).sort.each {|rb| require rb}
end
# :startdoc:
# Include this module to decorate your class with hookable goodness.
#
# Note: remember to call super() if you define your own self.inherited().
module Hooks
module ClassMethods
# Returns the hooks exposed by this class
def hooks
result = fetch_or_create_hooks.dup.freeze
end
# Define a new hook +name+. If +params+ are supplied, they will become
# the hook's named parameters.
def define_hook(name, *params)
fetch_or_create_hooks << make_hook(name, nil, params)
# We must use string evaluation in order to define a method that can
# receive a block.
instance_eval(<<-END)
def #{name}(handle_or_method=nil, &block)
add_callback(:#{name}, handle_or_method, &block)
end
END
module_eval(<<-END)
def #{name}(handle=nil, &block)
add_external_callback(:#{name}, handle, block)
end
END
end
def const_missing(const_name)
if const_name.to_s == "Listener"
hooks = fetch_or_create_hooks
listener_class ||= Class.new do
hooks.each do |hook|
define_method(hook.name) do |*args|
# NOOP
end
end
end
const_set(const_name, listener_class)
else
super(const_name)
end
end
def extended(object)
super(object)
class << object
include ::HookR::Hooks
end
end
protected
def make_hook(name, parent, params)
Hook.new(name, parent, params)
end
private
def inherited(child)
child.instance_variable_set(:@hooks, fetch_or_create_hooks.deep_copy)
end
def fetch_or_create_hooks
@hooks ||= HookSet.new
end
end # end of ClassMethods
# These methods are used at both the class and instance level
module CallbackHelpers
public
def remove_callback(hook_name, handle_or_index)
fetch_or_create_hooks[hook_name].remove_callback(handle_or_index)
end
protected
# Add a callback to a named hook
def add_callback(hook_name, handle_or_method=nil, &block)
if block
add_block_callback(hook_name, handle_or_method, block)
else
add_method_callback(hook_name, handle_or_method)
end
end
# Add a callback which will be executed
def add_wildcard_callback(handle=nil, &block)
fetch_or_create_hooks[:__wildcard__].add_basic_callback(handle, &block)
end
# Remove a wildcard callback
def remove_wildcard_callback(handle_or_index)
remove_callback(:__wildcard__, handle_or_index)
end
private
# Add either an internal or external callback depending on the arity of
# the given +block+
def add_block_callback(hook_name, handle, block)
case block.arity
when -1, 0
fetch_or_create_hooks[hook_name].add_internal_callback(handle, &block)
else
add_external_callback(hook_name, handle, block)
end
end
# Add a callback which will be executed in the context from which it was defined
def add_external_callback(hook_name, handle, block)
fetch_or_create_hooks[hook_name].add_external_callback(handle, &block)
end
def add_basic_callback(hook_name, handle, block)
fetch_or_create_hooks[hook_name].add_basic_callback(handle, &block)
end
# Add a callback which will call an instance method of the source class
def add_method_callback(hook_name, method)
fetch_or_create_hooks[hook_name].add_method_callback(self, method)
end
end # end of CallbackHelpers
def self.included(other)
other.extend(ClassMethods)
other.extend(CallbackHelpers)
other.send(:include, CallbackHelpers)
other.send(:define_hook, :__wildcard__)
end
# returns the hooks exposed by this object
def hooks
fetch_or_create_hooks.dup
end
# Execute all callbacks associated with the hook identified by +hook_name+,
# plus any wildcard callbacks.
#
# When a block is supplied, this method functions differently. In that case
# the callbacks are executed recursively. The most recently defined callback
# is executed and passed an event and a set of arguments. Calling
# event.next will pass execution to the next most recently added callback,
# which again will be passed an event with a reference to the next callback,
# and so on. When the list of callbacks are exhausted, the +block+ is
# executed as if it too were a callback. If at any point event.next is
# passed arguments, they will replace the value of the callback arguments
# for callbacks further down the chain.
#
# In this way you can use callbacks as "around" advice to a block of
# code. For instance:
#
# execute_hook(:write_data, data) do |data|
# write(data)
# end
#
# Here, the code exposes a :write_data hook. Any callbacks attached to the
# hook will "wrap" the data writing event. Callbacks might log when the
# data writing operation was started and stopped, or they might encrypt the
# data before it is written, etc.
def execute_hook(hook_name, *args, &block)
event = Event.new(self, hook_name, args, !!block)
if block
execute_hook_recursively(hook_name, event, block)
else
execute_hook_iteratively(hook_name, event)
end
end
# Add a listener object. The object should have a method defined for every
# hook this object publishes.
def add_listener(listener, handle=listener_to_handle(listener))
add_wildcard_callback(handle) do |event|
listener.send(event.name, *event.arguments)
end
end
# Remove a listener by handle or by the listener object itself
def remove_listener(handle_or_listener)
handle = case handle_or_listener
when Symbol then handle_or_listener
else listener_to_handle(handle_or_listener)
end
remove_wildcard_callback(handle)
end
private
def execute_hook_recursively(hook_name, event, block)
event.callbacks = callback_generator(hook_name, block)
event.next
end
def execute_hook_iteratively(hook_name, event)
fetch_or_create_hooks[:__wildcard__].execute_callbacks(event)
fetch_or_create_hooks[hook_name].execute_callbacks(event)
end
# Returns a Generator which yields:
# 1. Wildcard callbacks, in reverse order, followed by
# 2. +hook_name+ callbacks, in reverse order, followed by
# 3. a proc which delegates to +block+
#
# Intended for use with recursive hook execution.
def callback_generator(hook_name, block)
Generator.new do |g|
fetch_or_create_hooks[:__wildcard__].each_callback_reverse do |callback|
g.yield callback
end
fetch_or_create_hooks[hook_name].each_callback_reverse do |callback|
g.yield callback
end
g.yield(lambda do |event|
block.call(*event.arguments)
end)
end
end
def listener_to_handle(listener)
("listener_" + listener.object_id.to_s).to_sym
end
def fetch_or_create_hooks
@hooks ||= inherited_hooks.deep_copy
end
def inherited_hooks
singleton_class_hooks | class_hooks
end
def singleton_class_hooks
(class << self; self; end).hooks
end
def class_hooks
self.class.ancestors.inject(HookSet.new) { |hooks, a|
a_hooks = a.respond_to?(:hooks) ? a.hooks : HookSet.new
hooks | a_hooks
}
end
end
# A single named hook
Hook = Struct.new(:name, :parent, :params) do
include FailFast::Assertions
def initialize(name, parent=nil, params=[])
assert(Symbol === name)
@handles = {}
super(name, parent || NullHook.new, params)
end
def initialize_copy(original)
self.name = original.name
self.parent = original
self.params = original.params
@callbacks = CallbackSet.new
end
def ==(other)
name == other.name
end
def eql?(other)
self.class == other.class && name == other.name
end
def hash
name.hash
end
# Returns false. Only true of NullHook.
def terminal?
false
end
# Returns true if this hook has a null parent
def root?
parent.terminal?
end
def callbacks
fetch_or_create_callbacks.dup
end
# Add a callback which will be executed in the context where it was defined
def add_external_callback(handle=nil, &block)
if block.arity > -1 && block.arity < params.size
raise ArgumentError, "Callback has incompatible arity"
end
add_block_callback(HookR::ExternalCallback, handle, &block)
end
# Add a callback which will pass only the event object to +block+ - it will
# not try to pass arguments as well.
def add_basic_callback(handle=nil, &block)
add_block_callback(HookR::BasicCallback, handle, &block)
end
# Add a callback which will be executed in the context of the event source
def add_internal_callback(handle=nil, &block)
add_block_callback(HookR::InternalCallback, handle, &block)
end
# Add a callback which will send the given +message+ to the event source
def add_method_callback(klass, message)
method = klass.instance_method(message)
add_callback(HookR::MethodCallback.new(message, method, next_callback_index))
end
def add_callback(callback)
fetch_or_create_callbacks << callback
callback.handle
end
def remove_callback(handle_or_index)
assert_exists(handle_or_index)
case handle_or_index
when Symbol then fetch_or_create_callbacks.delete_if{|cb| cb.handle == handle_or_index}
when Integer then fetch_or_create_callbacks.delete_if{|cb| cb.index == handle_or_index}
else raise ArgumentError, "Key must be integer index or symbolic handle "\
"(was: #{handle_or_index.inspect})"
end
end
# Empty this hook of callbacks. Parent hooks may still have callbacks.
def clear_callbacks!
fetch_or_create_callbacks.clear
end
# Empty this hook of its own AND parent callbacks. This also disconnects
# the hook from its parent, if any.
def clear_all_callbacks!
disconnect!
clear_callbacks!
end
# Yields callbacks in order of addition, starting with any parent hooks
def each_callback(&block)
parent.each_callback(&block)
fetch_or_create_callbacks.each(&block)
end
# Yields callbacks in reverse order of addition, starting with own callbacks
# and then moving on to any parent hooks.
def each_callback_reverse(&block)
fetch_or_create_callbacks.each_reverse(&block)
parent.each_callback_reverse(&block)
end
# Excute the callbacks in order. +source+ is the object initiating the event.
def execute_callbacks(event)
parent.execute_callbacks(event)
fetch_or_create_callbacks.each do |callback|
callback.call(event)
end
end
# Callback count including parents
def total_callbacks
fetch_or_create_callbacks.size + parent.total_callbacks
end
private
def next_callback_index
return 0 if fetch_or_create_callbacks.empty?
fetch_or_create_callbacks.map{|cb| cb.index}.max + 1
end
def add_block_callback(type, handle=nil, &block)
assert_exists(block)
assert(handle.nil? || Symbol === handle)
handle ||= next_callback_index
add_callback(type.new(handle, block, next_callback_index))
end
def fetch_or_create_callbacks
@callbacks ||= CallbackSet.new
end
def disconnect!
self.parent = NullHook.new unless root?
end
end
# A null object class for terminating Hook inheritance chains
class NullHook
def each_callback(&block)
# NOOP
end
def each_callback_reverse(&block)
# NOOP
end
def execute_callbacks(event)
# NOOP
end
def total_callbacks
0
end
def terminal?
true
end
def root?
true
end
end
class HookSet < Set
WILDCARD_HOOK = HookR::Hook.new(:__wildcard__)
# Find hook by name.
#
# TODO: Optimize this.
def [](key)
detect {|v| v.name == key} or raise IndexError, "No such hook: #{key}"
end
def deep_copy
result = HookSet.new
each do |hook|
result << hook.dup
end
result
end
# Length minus the wildcard hook (if any)
def length
if include?(WILDCARD_HOOK)
super - 1
else
super
end
end
end
class CallbackSet < SortedSet
# Fetch callback by either index or handle
def [](index)
case index
when Integer then detect{|cb| cb.index == index}
when Symbol then detect{|cb| cb.handle == index}
else raise ArgumentError, "index must be Integer or Symbol"
end
end
# get the first callback
def first
each do |cb|
return cb
end
end
def each_reverse(&block)
sort{|x, y| y <=> x}.each(&block)
end
end
Callback = Struct.new(:handle, :index) do
include Comparable
include FailFast::Assertions
# Callbacks with the same handle are always equal, which prevents duplicate
# handles in CallbackSets. Otherwise, callbacks are sorted by index.
def <=>(other)
if handle == other.handle
return 0
end
self.index <=> other.index
end
# Must be overridden in subclass
def call(*args)
raise NotImplementedError, "Callback is an abstract class"
end
end
# A base class for callbacks which execute a block
class BlockCallback < Callback
attr_reader :block
def initialize(handle, block, index)
@block = block
super(handle, index)
end
end
# A callback which will execute outside the event source
class ExternalCallback < BlockCallback
def call(event)
block.call(*event.to_args(block.arity))
end
end
# A callback which will call a one-arg block with an event object
class BasicCallback < BlockCallback
def initialize(handle, block, index)
check_arity!(block)
super
end
def call(event)
block.call(event)
end
private
def check_arity!(block)
if block.arity != 1
raise ArgumentError, "Callback block must take a single argument"
end
end
end
# A callback which will execute in the context of the event source
class InternalCallback < BlockCallback
def initialize(handle, block, index)
assert(block.arity <= 0)
super(handle, block, index)
end
def call(event)
event.source.instance_eval(&block)
end
end
# A callback which will call a method on the event source
class MethodCallback < Callback
attr_reader :method
def initialize(handle, method, index)
@method = method
super(handle, index)
end
def call(event)
method.bind(event.source).call(*event.to_args(method.arity))
end
end
# Represents an event which is triggering callbacks.
#
# +source+:: The object triggering the event.
# +name+:: The name of the event
# +arguments+:: Any arguments passed associated with the event
Event = Struct.new(:source, :name, :arguments, :recursive, :callbacks) do
include FailFast::Assertions
# Convert to arguments for a callback of the given arity. Given an event
# with three arguments, the rules are as follows:
#
# 1. If arity is -1 (meaning any number of arguments), or 4, the result will
# be [event, +arguments[0]+, +arguments[1]+, +arguments[2]+]
# 2. If arity is 3, the result will just be +arguments+
# 3. If arity is < 3, an error will be raised.
#
# Notice that as the arity is reduced, the event argument is trimmed off.
# However, it is not permitted to generate a subset of the +arguments+ list.
# If the arity is too small to allow all arguments to be passed, the method
# fails.
def to_args(arity)
case arity
when -1
full_arguments
when (min_argument_count..full_argument_count)
full_arguments.slice(full_argument_count - arity, arity)
else
raise ArgumentError, "Arity must be between #{min_argument_count} "\
"and #{full_argument_count}"
end
end
# This method, along with the callback generator defined in Hook,
# implements recursive callback execution.
#
# TODO: Consider making the next() automatically if the callback doesn't
# call it explicitly.
#
# TODO: Consider adding a cancel() method, implementation TBD.
def next(*args)
assert(recursive, callbacks)
event = self.class.new(source, name, arguments, recursive, callbacks)
event.arguments = args unless args.empty?
if callbacks.next?
callbacks.next.call(event)
else
raise "No more callbacks!"
end
end
private
def full_argument_count
full_arguments.size
end
def min_argument_count
arguments.size
end
def full_arguments
@full_arguments ||= [self, *arguments]
end
end
end # module HookR
HookR.require_all_libs_relative_to(__FILE__)
# EOF