diff --git a/.travis.yml b/.travis.yml index a003dfd..9f95351 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ env: - HHVM_VERSION=4.62.0 - HHVM_VERSION=4.64.0 - HHVM_VERSION=4.65.0 + - HHVM_VERSION=4.66.0 - HHVM_VERSION=latest install: - docker pull hhvm/hhvm-proxygen:$HHVM_VERSION diff --git a/README.md b/README.md index f1859c3..b2825b9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ -# event-dispatcher - +# Nazg\Dispatcher diff --git a/composer.json b/composer.json index 178c5a0..9af301e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "nazg/event-dispatcher", - "description": "Event Managiment for Hack", + "name": "nazg/dispatcher", + "description": "Dispatcher for Hack", "keywords": [ "hhvm", "hack", diff --git a/src/Dispatcher.hack b/src/Dispatcher.hack new file mode 100644 index 0000000..ef436a1 --- /dev/null +++ b/src/Dispatcher.hack @@ -0,0 +1,115 @@ +namespace Nazg\Dispatcher; + +use namespace HH\Lib\{Dict, C}; +use function array_key_exists; + +type DispatchToken = string; + +class Dispatcher { + + private string $prefix = 'ID_'; + private ?TPayload $pendingPayload = null; + + public function __construct( + private dict $callbacks = dict[], + private bool $isDispatching = false, + private dict $isHandled = dict[], + private dict $isPending = dict[], + private int $lastID = 1, + ) { } + + public function register( + (function(TPayload):void) $callback + ): DispatchToken { + $this->lastID = $this->lastID + 1; + $id = $this->prefix . $this->lastID; + $this->callbacks[$id] = $callback; + return $id; + } + + public function unregister(DispatchToken $id): void { + invariant( + array_key_exists($id, $this->callbacks), + 'Dispatcher.unregister(...): `%s` does not map to a registered callback.', + $id + ); + $this->callbacks = Dict\filter_with_key( + $this->callbacks, + ($k, $_) ==> $k !== $id + ); + } + + public function waitFor( + vec $ids + ): void { + invariant( + $this->isDispatching, + 'waitFor(...): Must be invoked while dispatching.' + ); + for ($i = 0; $i < C\count($ids); $i++) { + $id = $ids[$i]; + if($this->isPending[$id]) { + invariant( + $this->isHandled[$id], + 'waitFor(...): Circular dependency detected while waiting for `%s`.', + $id + ); + continue; + } + invariant( + $this->callbacks[$id], + 'waitFor(...): `%s` does not map to a registered callback.', + $id + ); + $this->invokeCallback($id); + } + } + + public function dispatch(TPayload $payload): void { + invariant( + !$this->isDispatching, + 'dispatch(...): Cannot dispatch in the middle of a dispatch.' + ); + $this->startDispatching($payload); + try { + foreach ($this->callbacks as $key => $value) { + if($this->isPending[$key]) { + continue; + } + $this->invokeCallback($key); + } + } finally { + $this->stopDispatching(); + } + } + + public function isDispatching(): bool { + return $this->isDispatching; + } + + private function invokeCallback(DispatchToken $id): void { + $this->isPending[$id] = true; + if(array_key_exists($id, $this->callbacks)) { + if($this->pendingPayload is nonnull) { + $this->callbacks[$id]($this->pendingPayload); + } + } + $this->isHandled[$id] = true; + } + + private function startDispatching( + TPayload $payload + ): void { + Dict\map_with_key($this->callbacks, ($k, $_) ==> { + $this->isPending[$k] = false; + $this->isHandled[$k] = false; + }); + $this->pendingPayload = $payload; + $this->isDispatching = true; + } + + private function stopDispatching(): void { + $this->pendingPayload = null; + $this->isDispatching = false; + } +} diff --git a/src/Event.hack b/src/Event.hack deleted file mode 100644 index 011c8d5..0000000 --- a/src/Event.hack +++ /dev/null @@ -1,15 +0,0 @@ -namespace Nazg\EventDispatcher; - -class Event implements StoppableEventInterface { - - private bool $propagationStopped = false; - - <<__Rx>> - public function isPropagationStopped(): bool { - return $this->propagationStopped; - } - - public function stopPropagation(): void { - $this->propagationStopped = true; - } -} diff --git a/src/EventDispatcher.hack b/src/EventDispatcher.hack deleted file mode 100644 index 8a5c13a..0000000 --- a/src/EventDispatcher.hack +++ /dev/null @@ -1,92 +0,0 @@ -namespace Nazg\EventDispatcher; - -use namespace HH\Lib\{Dict, C}; -use function array_key_exists; -use function array_filter; - -class EventDispatcher { - - private dict>> $listeners = dict[]; - private dict> $sorted = dict[]; - - public function dispatch(Event $event, string $eventName): Event { - $listeners = $this->getListeners($eventName); - if ($listeners) { - $this->callListeners($listeners, $eventName, $event); - } - return $event; - } - - <<__Rx>> - public function hasListeners( - ?string $eventName = null - ): bool { - if ($eventName is nonnull) { - return !array_key_exists($eventName, $this->listeners); - } - if(C\count($this->listeners)) { - return true; - } - return false; - } - - public function addListener( - string $eventName, - ListenerInterface $listener, - int $priority = 0 - ): void { - $this->listeners[$eventName][$priority][] = $listener; - } - - protected function callListeners( - dict $listeners, - string $eventName, - Event $event - ): void { - foreach ($listeners as $listener) { - if ($event->isPropagationStopped()) { - break; - } - $listener->handle($event, $eventName, $this); - } - } - - private function sortListeners( - string $eventName - ): void { - Dict\sort_by_key($this->listeners[$eventName]); - $this->sorted[$eventName] = dict[]; - foreach ($this->listeners[$eventName] as $listeners) { - foreach ($listeners as $k => $listener) { - $this->sorted[$eventName][] = $listener; - } - } - } - - public function getListeners( - ?string $eventName = null - ): dict { - if ($eventName is nonnull) { - if (!C\count($this->listeners[$eventName])) { - return dict[]; - } - - if (!array_key_exists($eventName, $this->sorted)) { - $this->sortListeners($eventName); - } - return $this->sorted[$eventName]; - } - foreach ($this->listeners as $eventName => $eventListeners) { - if (!array_key_exists($eventName, $this->sorted)) { - $this->sortListeners($eventName); - } - } - $sorted = array_filter($this->sorted); - $d = dict[]; - foreach ($sorted as $key => $value) { - $d[$key] = $value; - } - /* HH_FIXME[4110] */ - return $d; - } -} diff --git a/src/ListenerInterface.hack b/src/ListenerInterface.hack deleted file mode 100644 index 7de6d39..0000000 --- a/src/ListenerInterface.hack +++ /dev/null @@ -1,10 +0,0 @@ -namespace Nazg\EventDispatcher; - -interface ListenerInterface { - - public function handle( - Event $event, - string $eventName, - EventDispatcher $dispathcher - ): void; -} diff --git a/src/StoppableEventInterface.hack b/src/StoppableEventInterface.hack deleted file mode 100644 index 89f237f..0000000 --- a/src/StoppableEventInterface.hack +++ /dev/null @@ -1,12 +0,0 @@ -namespace Nazg\EventDispatcher; - -interface StoppableEventInterface { - - /** - * Is propagation stopped? - * - * This will typically only be used by the Dispatcher to determine if the - * previous listener halted propagation. - */ - public function isPropagationStopped() : bool; -} diff --git a/tests/DispatcherTest.hack b/tests/DispatcherTest.hack new file mode 100644 index 0000000..4d5239b --- /dev/null +++ b/tests/DispatcherTest.hack @@ -0,0 +1,119 @@ +use type Nazg\Dispatcher\Dispatcher; +use type Facebook\HackTest\HackTest; +use function Facebook\FBExpect\expect; + +final class DispatcherTest extends HackTest { + + private int $call = 0; + + public async function testShouldReturnInctID(): Awaitable { + $dispatcher = new Dispatcher(); + $id = $dispatcher->register(($v) ==> { + $v; + }); + expect($id)->toBeSame('ID_2'); + $id = $dispatcher->register(($v) ==> { + $v; + }); + expect($id)->toBeSame('ID_3'); + } + + public async function testShouldThrowInvariantException(): Awaitable { + $dispatcher = new Dispatcher(); + expect(() ==> $dispatcher->unregister('ID_1')) + ->toThrow(InvariantException::class); + } + + public async function testShouldExecuteCallbacks(): Awaitable { + $dispatcher = new Dispatcher(); + $id = $dispatcher->register(($_) ==> { + $this->call = $this->call + 1; + }); + $payload = dict[]; + $dispatcher->dispatch($payload); + expect($this->call)->toBeSame(1); + $dispatcher->unregister($id); + $dispatcher->register(($_) ==> { + $this->call = $this->call + 1; + }); + $dispatcher->dispatch($payload); + expect($this->call)->toBeSame(2); + } + + public async function testShouldWaitForCallbacks(): Awaitable { + $dispatcher = new Dispatcher(); + $token = $dispatcher->register(($_) ==> { + $this->call = $this->call + 1; + }); + $callback = ($v) ==> { + $this->call = $this->call + 1; + }; + $dispatcher->register(($dict) ==> { + $dispatcher->waitFor(vec[$token]); + expect($this->call)->toBeSame(1); + $callback($dict); + }); + $payload = dict[]; + $dispatcher->dispatch($payload); + expect($this->call)->toBeSame(2); + } + + public async function testShouldWaitForAsyncCallbacks(): Awaitable { + $dispatcher = new Dispatcher(); + $callback = ($v) ==> { + $this->call = $this->call + 1; + }; + + $token = await async { + return $dispatcher->register(($_) ==> { + $this->call = $this->call + 1; + }); + }; + + await async { + $dispatcher->register(($dict) ==> { + $dispatcher->waitFor(vec[$token]); + expect($this->call)->toBeSame(1); + $callback($dict); + }); + }; + $payload = dict[]; + $dispatcher->dispatch($payload); + expect($this->call)->toBeSame(2); + } + + public async function testShouldThrowDispatching(): Awaitable { + $dispatcher = new Dispatcher(); + $dispatcher->register(($payload) ==> { + $this->call = $this->call + 1; + $dispatcher->dispatch($payload); + }); + $payload = dict[]; + expect(() ==> $dispatcher->dispatch($payload)) + ->toThrow(InvariantException::class); + expect($this->call)->toBeSame(1); + } + + public async function testShouldThrowWaitFor(): Awaitable { + $dispatcher = new Dispatcher(); + $token = $dispatcher->register(($payload) ==> { + $this->call = $this->call + 1; + }); + expect(() ==> $dispatcher->waitFor(vec[$token])) + ->toThrow(InvariantException::class); + } + + public async function testShouldThrowWaitForInvalidToken(): Awaitable { + $dispatcher = new Dispatcher(); + $token = '1111111'; + $dispatcher->register(($_) ==> { + $dispatcher->waitFor(vec[$token]); + }); + expect(() ==> $dispatcher->dispatch(dict[])) + ->toThrow(OutOfBoundsException::class); + } + + public async function afterEachTestAsync(): Awaitable { + $this->call = 0; + } +} diff --git a/tests/EventDispatcherTest.hack b/tests/EventDispatcherTest.hack deleted file mode 100644 index a459ecd..0000000 --- a/tests/EventDispatcherTest.hack +++ /dev/null @@ -1,21 +0,0 @@ -use type Nazg\EventDispatcher\{Event, EventDispatcher, ListenerInterface}; -use type Facebook\HackTest\HackTest; -use function Facebook\FBExpect\expect; - -final class EventDispatcherTest extends HackTest { - - public async function testShouldBeEmptyDict(): Awaitable { - $dispatcher = new EventDispatcher(); - expect($dispatcher->getListeners())->toBeSame(dict[]); - } -} - -class MockListener implements ListenerInterface { - public function handle( - Event $event, - string $eventName, - EventDispatcher $dispathcher - ): void { - - } -}