schleyfox-hookr 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile ADDED
@@ -0,0 +1,39 @@
1
+ # Look in the tasks/setup.rb file for the various options that can be
2
+ # configured in this Rakefile. The .rake files in the tasks directory
3
+ # are where the options are used.
4
+
5
+ begin
6
+ require 'bones'
7
+ Bones.setup
8
+ rescue LoadError
9
+ load 'tasks/setup.rb'
10
+ end
11
+
12
+ ensure_in_path 'lib'
13
+ require 'hookr'
14
+
15
+ task :default => 'spec:run'
16
+
17
+ PROJ.name = 'schleyfox-hookr'
18
+ PROJ.authors = 'Avdi Grimm'
19
+ PROJ.email = 'avdi@avdi.org'
20
+ PROJ.url = 'http://hookr.rubyforge.org'
21
+ PROJ.version = HookR::VERSION
22
+ PROJ.rubyforge.name = 'hookr'
23
+
24
+ PROJ.ann.email[:from] = 'avdi@avdi.org'
25
+ PROJ.ann.email[:to] = 'ruby-talk@ruby-lang.org'
26
+ PROJ.ann.email[:server] = 'smtp.gmail.com'
27
+ PROJ.ann.email[:domain] = 'avdi.org'
28
+ PROJ.ann.email[:port] = 587
29
+ PROJ.ann.email[:acct] = 'avdi.grimm'
30
+ PROJ.ann.email[:authtype] = :plain
31
+
32
+ # TODO I want to be able to use -w here, but RSpec produces a hojillion warnings
33
+ PROJ.ruby_opts = []
34
+ PROJ.spec.opts << '--color'
35
+
36
+
37
+ depend_on 'fail-fast', '1.0.0'
38
+
39
+ # EOF
data/bin/hookr ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.expand_path(
4
+ File.join(File.dirname(__FILE__), %w[.. lib hookr]))
5
+
6
+ # Put your code here
7
+
8
+ # EOF
data/lib/hookr.rb ADDED
@@ -0,0 +1,668 @@
1
+ require 'set'
2
+
3
+ if '1.9'.respond_to?(:force_encoding)
4
+ require 'enumerator'
5
+ MyEnumerator = Enumerator
6
+ else
7
+ require 'generator'
8
+ MyEnumerator = Generator
9
+ end
10
+ require 'rubygems'
11
+ require 'fail_fast'
12
+
13
+ # HookR is a library providing "hooks", aka "signals and slots", aka "events" to
14
+ # your Ruby classes.
15
+ module HookR
16
+
17
+ # No need to document the boilerplate convenience methods defined by Mr. Bones.
18
+ # :stopdoc:
19
+
20
+ VERSION = '1.0.1'
21
+ LIBPATH = ::File.expand_path(::File.dirname(__FILE__)) + ::File::SEPARATOR
22
+ PATH = ::File.dirname(LIBPATH) + ::File::SEPARATOR
23
+
24
+ # Returns the version string for the library.
25
+ #
26
+ def self.version
27
+ VERSION
28
+ end
29
+
30
+ # Returns the library path for the module. If any arguments are given,
31
+ # they will be joined to the end of the libray path using
32
+ # <tt>File.join</tt>.
33
+ #
34
+ def self.libpath( *args )
35
+ args.empty? ? LIBPATH : ::File.join(LIBPATH, args.flatten)
36
+ end
37
+
38
+ # Returns the lpath for the module. If any arguments are given,
39
+ # they will be joined to the end of the path using
40
+ # <tt>File.join</tt>.
41
+ #
42
+ def self.path( *args )
43
+ args.empty? ? PATH : ::File.join(PATH, args.flatten)
44
+ end
45
+
46
+ # Utility method used to rquire all files ending in .rb that lie in the
47
+ # directory below this file that has the same name as the filename passed
48
+ # in. Optionally, a specific _directory_ name can be passed in such that
49
+ # the _filename_ does not have to be equivalent to the directory.
50
+ #
51
+ def self.require_all_libs_relative_to( fname, dir = nil )
52
+ dir ||= ::File.basename(fname, '.*')
53
+ search_me = ::File.expand_path(
54
+ ::File.join(::File.dirname(fname), dir, '*', '*.rb'))
55
+
56
+ Dir.glob(search_me).sort.each {|rb| require rb}
57
+ end
58
+
59
+ # :startdoc:
60
+
61
+ # Include this module to decorate your class with hookable goodness.
62
+ #
63
+ # Note: remember to call super() if you define your own self.inherited().
64
+ module Hooks
65
+ module ClassMethods
66
+ # Returns the hooks exposed by this class
67
+ def hooks
68
+ result = fetch_or_create_hooks.dup.freeze
69
+ end
70
+
71
+ # Define a new hook +name+. If +params+ are supplied, they will become
72
+ # the hook's named parameters.
73
+ def define_hook(name, *params)
74
+ fetch_or_create_hooks << make_hook(name, nil, params)
75
+
76
+ # We must use string evaluation in order to define a method that can
77
+ # receive a block.
78
+ instance_eval(<<-END)
79
+ def #{name}(handle_or_method=nil, &block)
80
+ add_callback(:#{name}, handle_or_method, &block)
81
+ end
82
+ END
83
+ module_eval(<<-END)
84
+ def #{name}(handle=nil, &block)
85
+ add_external_callback(:#{name}, handle, block)
86
+ end
87
+ END
88
+ end
89
+
90
+ def const_missing(const_name)
91
+ if const_name.to_s == "Listener"
92
+ hooks = fetch_or_create_hooks
93
+ listener_class ||= Class.new do
94
+ hooks.each do |hook|
95
+ define_method(hook.name) do |*args|
96
+ # NOOP
97
+ end
98
+ end
99
+ end
100
+ const_set(const_name, listener_class)
101
+ else
102
+ super(const_name)
103
+ end
104
+ end
105
+
106
+ protected
107
+
108
+ def make_hook(name, parent, params)
109
+ Hook.new(name, parent, params)
110
+ end
111
+
112
+ private
113
+
114
+ def inherited(child)
115
+ child.instance_variable_set(:@hooks, fetch_or_create_hooks.deep_copy)
116
+ end
117
+
118
+ def fetch_or_create_hooks
119
+ @hooks ||= HookSet.new
120
+ end
121
+
122
+ end # end of ClassMethods
123
+
124
+ # These methods are used at both the class and instance level
125
+ module CallbackHelpers
126
+ public
127
+
128
+ def remove_callback(hook_name, handle_or_index)
129
+ fetch_or_create_hooks[hook_name].remove_callback(handle_or_index)
130
+ end
131
+
132
+ protected
133
+
134
+ # Add a callback to a named hook
135
+ def add_callback(hook_name, handle_or_method=nil, &block)
136
+ if block
137
+ add_block_callback(hook_name, handle_or_method, block)
138
+ else
139
+ add_method_callback(hook_name, handle_or_method)
140
+ end
141
+ end
142
+
143
+ # Add a callback which will be executed
144
+ def add_wildcard_callback(handle=nil, &block)
145
+ fetch_or_create_hooks[:__wildcard__].add_basic_callback(handle, &block)
146
+ end
147
+
148
+ # Remove a wildcard callback
149
+ def remove_wildcard_callback(handle_or_index)
150
+ remove_callback(:__wildcard__, handle_or_index)
151
+ end
152
+
153
+ private
154
+
155
+ # Add either an internal or external callback depending on the arity of
156
+ # the given +block+
157
+ def add_block_callback(hook_name, handle, block)
158
+ case block.arity
159
+ when -1, 0
160
+ fetch_or_create_hooks[hook_name].add_internal_callback(handle, &block)
161
+ else
162
+ add_external_callback(hook_name, handle, block)
163
+ end
164
+ end
165
+
166
+ # Add a callback which will be executed in the context from which it was defined
167
+ def add_external_callback(hook_name, handle, block)
168
+ fetch_or_create_hooks[hook_name].add_external_callback(handle, &block)
169
+ end
170
+
171
+ def add_basic_callback(hook_name, handle, block)
172
+ fetch_or_create_hooks[hook_name].add_basic_callback(handle, &block)
173
+ end
174
+
175
+ # Add a callback which will call an instance method of the source class
176
+ def add_method_callback(hook_name, method)
177
+ fetch_or_create_hooks[hook_name].add_method_callback(self, method)
178
+ end
179
+
180
+ end # end of CallbackHelpers
181
+
182
+ def self.included(other)
183
+ other.extend(ClassMethods)
184
+ other.extend(CallbackHelpers)
185
+ other.send(:include, CallbackHelpers)
186
+ other.send(:define_hook, :__wildcard__)
187
+ end
188
+
189
+ # returns the hooks exposed by this object
190
+ def hooks
191
+ fetch_or_create_hooks.dup.freeze
192
+ end
193
+
194
+ # Execute all callbacks associated with the hook identified by +hook_name+,
195
+ # plus any wildcard callbacks.
196
+ #
197
+ # When a block is supplied, this method functions differently. In that case
198
+ # the callbacks are executed recursively. The most recently defined callback
199
+ # is executed and passed an event and a set of arguments. Calling
200
+ # event.next will pass execution to the next most recently added callback,
201
+ # which again will be passed an event with a reference to the next callback,
202
+ # and so on. When the list of callbacks are exhausted, the +block+ is
203
+ # executed as if it too were a callback. If at any point event.next is
204
+ # passed arguments, they will replace the value of the callback arguments
205
+ # for callbacks further down the chain.
206
+ #
207
+ # In this way you can use callbacks as "around" advice to a block of
208
+ # code. For instance:
209
+ #
210
+ # execute_hook(:write_data, data) do |data|
211
+ # write(data)
212
+ # end
213
+ #
214
+ # Here, the code exposes a :write_data hook. Any callbacks attached to the
215
+ # hook will "wrap" the data writing event. Callbacks might log when the
216
+ # data writing operation was started and stopped, or they might encrypt the
217
+ # data before it is written, etc.
218
+ def execute_hook(hook_name, *args, &block)
219
+ event = Event.new(self, hook_name, args, !!block)
220
+
221
+ if block
222
+ execute_hook_recursively(hook_name, event, block)
223
+ else
224
+ execute_hook_iteratively(hook_name, event)
225
+ end
226
+ end
227
+
228
+ # Add a listener object. The object should have a method defined for every
229
+ # hook this object publishes.
230
+ def add_listener(listener, handle=listener_to_handle(listener))
231
+ add_wildcard_callback(handle) do |event|
232
+ listener.send(event.name, *event.arguments)
233
+ end
234
+ end
235
+
236
+ # Remove a listener by handle or by the listener object itself
237
+ def remove_listener(handle_or_listener)
238
+ handle = case handle_or_listener
239
+ when Symbol then handle_or_listener
240
+ else listener_to_handle(handle_or_listener)
241
+ end
242
+ remove_wildcard_callback(handle)
243
+ end
244
+
245
+ private
246
+
247
+ def execute_hook_recursively(hook_name, event, block)
248
+ event.callbacks = callback_generator(hook_name, block)
249
+ event.next
250
+ end
251
+
252
+ def execute_hook_iteratively(hook_name, event)
253
+ fetch_or_create_hooks[:__wildcard__].execute_callbacks(event)
254
+ fetch_or_create_hooks[hook_name].execute_callbacks(event)
255
+ end
256
+
257
+ # Returns a Enumerator which yields:
258
+ # 1. Wildcard callbacks, in reverse order, followed by
259
+ # 2. +hook_name+ callbacks, in reverse order, followed by
260
+ # 3. a proc which delegates to +block+
261
+ #
262
+ # Intended for use with recursive hook execution.
263
+ def callback_generator(hook_name, block)
264
+ MyEnumerator.new do |g|
265
+ fetch_or_create_hooks[:__wildcard__].each_callback_reverse do |callback|
266
+ g.yield callback
267
+ end
268
+ fetch_or_create_hooks[hook_name].each_callback_reverse do |callback|
269
+ g.yield callback
270
+ end
271
+ g.yield(lambda do |event|
272
+ block.call(*event.arguments)
273
+ end)
274
+ end
275
+ end
276
+
277
+ def listener_to_handle(listener)
278
+ ("listener_" + listener.object_id.to_s).to_sym
279
+ end
280
+
281
+ def fetch_or_create_hooks
282
+ @hooks ||= self.class.hooks.deep_copy
283
+ end
284
+ end
285
+
286
+ # A single named hook
287
+ Hook = Struct.new(:name, :parent, :params) do
288
+ include FailFast::Assertions
289
+
290
+ def initialize(name, parent=nil, params=[])
291
+ assert(Symbol === name)
292
+ @handles = {}
293
+ super(name, parent || NullHook.new, params)
294
+ end
295
+
296
+ def initialize_copy(original)
297
+ self.name = original.name
298
+ self.parent = original
299
+ self.params = original.params
300
+ @callbacks = CallbackSet.new
301
+ end
302
+
303
+ def ==(other)
304
+ name == other.name
305
+ end
306
+
307
+ def eql?(other)
308
+ self.class == other.class && name == other.name
309
+ end
310
+
311
+ def hash
312
+ name.hash
313
+ end
314
+
315
+ # Returns false. Only true of NullHook.
316
+ def terminal?
317
+ false
318
+ end
319
+
320
+ # Returns true if this hook has a null parent
321
+ def root?
322
+ parent.terminal?
323
+ end
324
+
325
+ def callbacks
326
+ fetch_or_create_callbacks.dup.freeze
327
+ end
328
+
329
+ # Add a callback which will be executed in the context where it was defined
330
+ def add_external_callback(handle=nil, &block)
331
+ if block.arity > -1 && block.arity < params.size
332
+ raise ArgumentError, "Callback has incompatible arity"
333
+ end
334
+ add_block_callback(HookR::ExternalCallback, handle, &block)
335
+ end
336
+
337
+ # Add a callback which will pass only the event object to +block+ - it will
338
+ # not try to pass arguments as well.
339
+ def add_basic_callback(handle=nil, &block)
340
+ add_block_callback(HookR::BasicCallback, handle, &block)
341
+ end
342
+
343
+ # Add a callback which will be executed in the context of the event source
344
+ def add_internal_callback(handle=nil, &block)
345
+ add_block_callback(HookR::InternalCallback, handle, &block)
346
+ end
347
+
348
+ # Add a callback which will send the given +message+ to the event source
349
+ def add_method_callback(klass, message)
350
+ method = klass.instance_method(message)
351
+ add_callback(HookR::MethodCallback.new(message, method, next_callback_index))
352
+ end
353
+
354
+ def add_callback(callback)
355
+ fetch_or_create_callbacks << callback
356
+ callback.handle
357
+ end
358
+
359
+ def remove_callback(handle_or_index)
360
+ assert_exists(handle_or_index)
361
+ case handle_or_index
362
+ when Symbol then fetch_or_create_callbacks.delete_if{|cb| cb.handle == handle_or_index}
363
+ when Integer then fetch_or_create_callbacks.delete_if{|cb| cb.index == handle_or_index}
364
+ else raise ArgumentError, "Key must be integer index or symbolic handle "\
365
+ "(was: #{handle_or_index.inspect})"
366
+ end
367
+ end
368
+
369
+ # Empty this hook of callbacks. Parent hooks may still have callbacks.
370
+ def clear_callbacks!
371
+ fetch_or_create_callbacks.clear
372
+ end
373
+
374
+ # Empty this hook of its own AND parent callbacks. This also disconnects
375
+ # the hook from its parent, if any.
376
+ def clear_all_callbacks!
377
+ disconnect!
378
+ clear_callbacks!
379
+ end
380
+
381
+ # Yields callbacks in order of addition, starting with any parent hooks
382
+ def each_callback(&block)
383
+ parent.each_callback(&block)
384
+ fetch_or_create_callbacks.each(&block)
385
+ end
386
+
387
+ # Yields callbacks in reverse order of addition, starting with own callbacks
388
+ # and then moving on to any parent hooks.
389
+ def each_callback_reverse(&block)
390
+ fetch_or_create_callbacks.each_reverse(&block)
391
+ parent.each_callback_reverse(&block)
392
+ end
393
+
394
+ # Excute the callbacks in order. +source+ is the object initiating the event.
395
+ def execute_callbacks(event)
396
+ parent.execute_callbacks(event)
397
+ fetch_or_create_callbacks.each do |callback|
398
+ callback.call(event)
399
+ end
400
+ end
401
+
402
+ # Callback count including parents
403
+ def total_callbacks
404
+ fetch_or_create_callbacks.size + parent.total_callbacks
405
+ end
406
+
407
+ private
408
+
409
+ def next_callback_index
410
+ return 0 if fetch_or_create_callbacks.empty?
411
+ fetch_or_create_callbacks.map{|cb| cb.index}.max + 1
412
+ end
413
+
414
+ def add_block_callback(type, handle=nil, &block)
415
+ assert_exists(block)
416
+ assert(handle.nil? || Symbol === handle)
417
+ handle ||= next_callback_index
418
+ add_callback(type.new(handle, block, next_callback_index))
419
+ end
420
+
421
+ def fetch_or_create_callbacks
422
+ @callbacks ||= CallbackSet.new
423
+ end
424
+
425
+ def disconnect!
426
+ self.parent = NullHook.new unless root?
427
+ end
428
+ end
429
+
430
+ # A null object class for terminating Hook inheritance chains
431
+ class NullHook
432
+ def each_callback(&block)
433
+ # NOOP
434
+ end
435
+
436
+ def each_callback_reverse(&block)
437
+ # NOOP
438
+ end
439
+
440
+ def execute_callbacks(event)
441
+ # NOOP
442
+ end
443
+
444
+ def total_callbacks
445
+ 0
446
+ end
447
+
448
+ def terminal?
449
+ true
450
+ end
451
+
452
+ def root?
453
+ true
454
+ end
455
+ end
456
+
457
+ class HookSet < Set
458
+ WILDCARD_HOOK = HookR::Hook.new(:__wildcard__)
459
+
460
+ # Find hook by name.
461
+ #
462
+ # TODO: Optimize this.
463
+ def [](key)
464
+ detect {|v| v.name == key} or raise IndexError, "No such hook: #{key}"
465
+ end
466
+
467
+ def deep_copy
468
+ result = HookSet.new
469
+ each do |hook|
470
+ result << hook.dup
471
+ end
472
+ result
473
+ end
474
+
475
+ # Length minus the wildcard hook (if any)
476
+ def length
477
+ if include?(WILDCARD_HOOK)
478
+ super - 1
479
+ else
480
+ super
481
+ end
482
+ end
483
+ end
484
+
485
+ class CallbackSet < SortedSet
486
+
487
+ # Fetch callback by either index or handle
488
+ def [](index)
489
+ case index
490
+ when Integer then detect{|cb| cb.index == index}
491
+ when Symbol then detect{|cb| cb.handle == index}
492
+ else raise ArgumentError, "index must be Integer or Symbol"
493
+ end
494
+ end
495
+
496
+ def to_a
497
+ if frozen? && !@keys
498
+ @hash.keys.sort
499
+ else
500
+ super
501
+ end
502
+ end
503
+
504
+ # get the first callback
505
+ def first
506
+ each do |cb|
507
+ return cb
508
+ end
509
+ end
510
+
511
+ def each_reverse(&block)
512
+ sort{|x, y| y <=> x}.each(&block)
513
+ end
514
+
515
+ end
516
+
517
+ class Callback < Struct.new(:handle, :index)
518
+ include Comparable
519
+ include FailFast::Assertions
520
+
521
+ # Callbacks with the same handle are always equal, which prevents duplicate
522
+ # handles in CallbackSets. Otherwise, callbacks are sorted by index.
523
+ def <=>(other)
524
+ if handle == other.handle
525
+ return 0
526
+ end
527
+ self.index <=> other.index
528
+ end
529
+
530
+ # Must be overridden in subclass
531
+ def call(*args)
532
+ raise NotImplementedError, "Callback is an abstract class"
533
+ end
534
+ end
535
+
536
+ # A base class for callbacks which execute a block
537
+ class BlockCallback < Callback
538
+ attr_reader :block
539
+
540
+ def initialize(handle, block, index)
541
+ @block = block
542
+ super(handle, index)
543
+ end
544
+ end
545
+
546
+ # A callback which will execute outside the event source
547
+ class ExternalCallback < BlockCallback
548
+ def call(event)
549
+ block.call(*event.to_args(block.arity))
550
+ end
551
+ end
552
+
553
+ # A callback which will call a one-arg block with an event object
554
+ class BasicCallback < BlockCallback
555
+ def initialize(handle, block, index)
556
+ check_arity!(block)
557
+ super
558
+ end
559
+
560
+ def call(event)
561
+ block.call(event)
562
+ end
563
+
564
+ private
565
+
566
+ def check_arity!(block)
567
+ if block.arity != 1
568
+ raise ArgumentError, "Callback block must take a single argument"
569
+ end
570
+ end
571
+ end
572
+
573
+ # A callback which will execute in the context of the event source
574
+ class InternalCallback < BlockCallback
575
+ def initialize(handle, block, index)
576
+ assert(block.arity <= 0)
577
+ super(handle, block, index)
578
+ end
579
+
580
+ def call(event)
581
+ event.source.instance_eval(&block)
582
+ end
583
+ end
584
+
585
+ # A callback which will call a method on the event source
586
+ class MethodCallback < Callback
587
+ attr_reader :method
588
+
589
+ def initialize(handle, method, index)
590
+ @method = method
591
+ super(handle, index)
592
+ end
593
+
594
+ def call(event)
595
+ method.bind(event.source).call(*event.to_args(method.arity))
596
+ end
597
+ end
598
+
599
+ # Represents an event which is triggering callbacks.
600
+ #
601
+ # +source+:: The object triggering the event.
602
+ # +name+:: The name of the event
603
+ # +arguments+:: Any arguments passed associated with the event
604
+ Event = Struct.new(:source, :name, :arguments, :recursive, :callbacks) do
605
+ include FailFast::Assertions
606
+
607
+ # Convert to arguments for a callback of the given arity. Given an event
608
+ # with three arguments, the rules are as follows:
609
+ #
610
+ # 1. If arity is -1 (meaning any number of arguments), or 4, the result will
611
+ # be [event, +arguments[0]+, +arguments[1]+, +arguments[2]+]
612
+ # 2. If arity is 3, the result will just be +arguments+
613
+ # 3. If arity is < 3, an error will be raised.
614
+ #
615
+ # Notice that as the arity is reduced, the event argument is trimmed off.
616
+ # However, it is not permitted to generate a subset of the +arguments+ list.
617
+ # If the arity is too small to allow all arguments to be passed, the method
618
+ # fails.
619
+ def to_args(arity)
620
+ case arity
621
+ when -1
622
+ full_arguments
623
+ when (min_argument_count..full_argument_count)
624
+ full_arguments.slice(full_argument_count - arity, arity)
625
+ else
626
+ raise ArgumentError, "Arity must be between #{min_argument_count} "\
627
+ "and #{full_argument_count}"
628
+ end
629
+ end
630
+
631
+ # This method, along with the callback generator defined in Hook,
632
+ # implements recursive callback execution.
633
+ #
634
+ # TODO: Consider making the next() automatically if the callback doesn't
635
+ # call it explicitly.
636
+ #
637
+ # TODO: Consider adding a cancel() method, implementation TBD.
638
+ def next(*args)
639
+ assert(recursive, callbacks)
640
+ event = self.class.new(source, name, arguments, recursive, callbacks)
641
+ event.arguments = args unless args.empty?
642
+ if c = callbacks.next
643
+ c.call(event)
644
+ else
645
+ raise "No more callbacks!"
646
+ end
647
+ end
648
+
649
+ private
650
+
651
+ def full_argument_count
652
+ full_arguments.size
653
+ end
654
+
655
+ def min_argument_count
656
+ arguments.size
657
+ end
658
+
659
+ def full_arguments
660
+ @full_arguments ||= [self, *arguments]
661
+ end
662
+ end
663
+
664
+ end # module HookR
665
+
666
+ HookR.require_all_libs_relative_to(__FILE__)
667
+
668
+ # EOF