Skip to content

Commit

Permalink
Merge pull request #372 from thephpleague/event-dispatcher
Browse files Browse the repository at this point in the history
Implement event dispatching
  • Loading branch information
colinodell committed Jun 4, 2019
2 parents 72e87ec + ad51a80 commit d3fffa3
Show file tree
Hide file tree
Showing 10 changed files with 271 additions and 0 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi

## [Unreleased][unreleased]

### Added

- Added event dispatcher functionality (#359, #372)

## [1.0.0-beta3] - 2019-05-27

### Changed
Expand Down
63 changes: 63 additions & 0 deletions docs/1.0/customization/event-dispatcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
layout: default
title: Event Dispatcher
---

Event Dispatcher
================

This library includes basic event dispatcher functionality. This makes it possible to add hook points throughout the library and third-party extensions which other code can listen for and execute code. If you're familiar with [Symfony's EventDispatcher](https://symfony.com/doc/current/components/event_dispatcher.html) or [PSR-14](https://www.php-fig.org/psr/psr-14/) then this should be very familiar to you.

## Event Class

All events must extend from the `AbstractEvent` class:

```php
use League\CommonMark\Event\AbstractEvent;

class MyCustomEvent extends AbstractEvent {}
```

An event can have any number of methods on it which return useful information the listeners can use or modify.

## Registering Listeners

Listeners can be registered with the `Environment` using the `addEventListener()` method:

```php
public function addEventListener(string $eventClass, callable $listener, int $priority = 0)
```

The parameters for this method are:

1. The fully-qualified name of the event class you wish to observe
2. Any PHP callable to execute when that type of event is dispatched
3. An optional priority (defaults to `0`)

For example:

```php
// Telling the environment which method to call:
$customListener = new MyCustomListener();
$environment->addEventListener(MyCustomEvent::class, [$customListener, 'onDocumentParsed']);

// Or if MyCustomerListener has an __invoke() method:
$environment->addEventListener(MyCustomEvent::class, new MyCustomListener(), 10);

// Or use any other type of callable you wish!
$environment->addEventListener(MyCustomEvent::class, function (MyCustomEvent $event) {
// TODO: Stuff
}, 10);
```

## Dispatching Events

Events can be dispatched via the `$environment->dispatch()` method which takes a single argument - an instance of `AbstractEvent` to dispatch:

```php
$environment->dispatch(new MyCustomEvent());
```

Listeners will be called in order of priority (higher priorities will be called first). If multiple listeners have the same priority, they'll be called in the order in which they were registered. If you'd like your listener to prevent other subsequent events from running, simply call `$event->stopPropagation()`.

Listeners may call any method on the event to get more information about the event, make changes to event data, etc.
1 change: 1 addition & 0 deletions docs/_data/menu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ version:
'Overview': '/1.0/customization/overview/'
'Environment': '/1.0/customization/environment/'
'Extensions': '/1.0/customization/extensions/'
'Event Dispatcher': '/1.0/customization/event-dispatcher/'
'Cursor': '/1.0/customization/cursor/'
'Block Parsing': '/1.0/customization/block-parsing/'
'Inline Parsing': '/1.0/customization/inline-parsing/'
Expand Down
11 changes: 11 additions & 0 deletions src/ConfigurableEnvironmentInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,15 @@ public function addBlockRenderer($blockClass, BlockRendererInterface $blockRende
* @return self
*/
public function addInlineRenderer(string $inlineClass, InlineRendererInterface $renderer, int $priority = 0): ConfigurableEnvironmentInterface;

/**
* Registers the given event listener
*
* @param string $eventClass Fully-qualified class name of the event this listener should respond to
* @param callable $listener Listener to be executed
* @param int $priority Priority (a higher number will be executed earlier)
*
* @return self
*/
public function addEventListener(string $eventClass, callable $listener, int $priority = 0): ConfigurableEnvironmentInterface;
}
40 changes: 40 additions & 0 deletions src/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
use League\CommonMark\Event\AbstractEvent;
use League\CommonMark\Extension\CommonMarkCoreExtension;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Inline\Parser\InlineParserInterface;
Expand Down Expand Up @@ -78,6 +79,11 @@ final class Environment implements EnvironmentInterface, ConfigurableEnvironment
*/
private $inlineRenderersByClass = [];

/**
* @var array<string, PrioritizedList<callable>>
*/
private $listeners = [];

/**
* @var Configuration
*/
Expand Down Expand Up @@ -380,6 +386,40 @@ public function getInlineParserCharacterRegex(): string
return $this->inlineParserCharacterRegex;
}

/**
* {@inheritdoc}
*/
public function addEventListener(string $eventClass, callable $listener, int $priority = 0): ConfigurableEnvironmentInterface
{
$this->assertUninitialized('Failed to add event listener.');

if (!isset($this->listeners[$eventClass])) {
$this->listeners[$eventClass] = new PrioritizedList();
}

$this->listeners[$eventClass]->add($listener, $priority);

return $this;
}

/**
* {@inheritdoc}
*/
public function dispatch(AbstractEvent $event): void
{
$this->initializeExtensions();

$type = get_class($event);

foreach ($this->listeners[$type] ?? [] as $listener) {
if ($event->isPropagationStopped()) {
return;
}

$listener($event);
}
}

private function buildInlineParserCharacterRegex()
{
$chars = \array_unique(\array_merge(
Expand Down
8 changes: 8 additions & 0 deletions src/EnvironmentInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use League\CommonMark\Block\Parser\BlockParserInterface;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection;
use League\CommonMark\Event\AbstractEvent;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;

Expand Down Expand Up @@ -75,4 +76,11 @@ public function getInlineRenderersForClass(string $inlineClass): iterable;
* @return string
*/
public function getInlineParserCharacterRegex(): string;

/**
* Dispatches the given event to listeners
*
* @param AbstractEvent $event
*/
public function dispatch(AbstractEvent $event): void;
}
49 changes: 49 additions & 0 deletions src/Event/AbstractEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/**
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* Original code based on the Symfony EventDispatcher "Event" contract
* - (c) 2018-2019 Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace League\CommonMark\Event;

/**
* Base class for classes containing event data.
*
* This class contains no event data. It is used by events that do not pass
* state information to an event handler when an event is raised.
*
* You can call the method stopPropagation() to abort the execution of
* further listeners in your event listener.
*/
abstract class AbstractEvent
{
private $propagationStopped = false;

/**
* Returns whether further event listeners should be triggered.
*/
final public function isPropagationStopped(): bool
{
return $this->propagationStopped;
}

/**
* Stops the propagation of the event to further event listeners.
*
* If multiple event listeners are connected to the same event, no
* further event listener will be triggered once any trigger calls
* stopPropagation().
*/
final public function stopPropagation(): void
{
$this->propagationStopped = true;
}
}
50 changes: 50 additions & 0 deletions tests/unit/EnvironmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
use League\CommonMark\Tests\Unit\Event\FakeEvent;
use League\CommonMark\Util\ConfigurationAwareInterface;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -475,4 +476,53 @@ public function testInlineRendererPrioritization()
$this->assertSame($renderer1, $parsers[1]);
$this->assertSame($renderer3, $parsers[2]);
}

public function testEventDispatching()
{
$environment = new Environment();
$event = new FakeEvent();

$actualOrder = [];

$environment->addEventListener(FakeEvent::class, function (FakeEvent $e) use ($event, &$actualOrder) {
$this->assertSame($event, $e);
$actualOrder[] = 'a';
});

$environment->addEventListener(FakeEvent::class, function (FakeEvent $e) use ($event, &$actualOrder) {
$this->assertSame($event, $e);
$actualOrder[] = 'b';
$e->stopPropagation();
});

$environment->addEventListener(FakeEvent::class, function (FakeEvent $e) use ($event, &$actualOrder) {
$this->assertSame($event, $e);
$actualOrder[] = 'c';
}, 10);

$environment->addEventListener(FakeEvent::class, function (FakeEvent $e) use ($event, &$actualOrder) {
$this->fail('Propogation should have been stopped before here');
});

$environment->dispatch($event);

$this->assertCount(3, $actualOrder);
$this->assertEquals('c', $actualOrder[0]);
$this->assertEquals('a', $actualOrder[1]);
$this->assertEquals('b', $actualOrder[2]);
}

/**
* @expectedException \RuntimeException
*/
public function testAddEventListenerFailsAfterInitialization()
{
$environment = new Environment();
$event = $this->createMock(AbstractEvent::class);

$environment->dispatch($event);

$environment->addEventListener(AbstractEvent::class, function (AbstractEvent $e) {
});
}
}
27 changes: 27 additions & 0 deletions tests/unit/Event/AbstractEventTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

/**
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace League\CommonMark\Tests\Unit\Event;

use PHPUnit\Framework\TestCase;

final class AbstractEventTest extends TestCase
{
public function testStopPropagation()
{
$event = new FakeEvent();

$this->assertFalse($event->isPropagationStopped());

$event->stopPropagation();
$this->assertTrue($event->isPropagationStopped());
}
}
18 changes: 18 additions & 0 deletions tests/unit/Event/FakeEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/**
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace League\CommonMark\Tests\Unit\Event;

use League\CommonMark\Event\AbstractEvent;

final class FakeEvent extends AbstractEvent
{
}

0 comments on commit d3fffa3

Please sign in to comment.