diff --git a/admin/hooks.php b/admin/hooks.php
new file mode 100644
index 00000000000..d1e25555c7a
--- /dev/null
+++ b/admin/hooks.php
@@ -0,0 +1,116 @@
+.
+
+/**
+ * Hooks overview page.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+require_once($CFG->libdir . '/tablelib.php');
+
+admin_externalpage_setup('hooksoverview');
+require_capability('moodle/site:config', \core\context\system::instance());
+
+$hookmanager = \core\hook\manager::get_instance();
+
+echo $OUTPUT->header();
+echo $OUTPUT->heading(get_string('hooksoverview', 'core_admin'));
+
+$table = new html_table();
+$table->head = [get_string('hookname', 'core_admin'), get_string('hookcallbacks', 'core_admin'),
+ get_string('hookdescription', 'core_admin'), get_string('hookdeprecates', 'core_admin')];
+$table->align = ['left', 'left', 'left', 'left'];
+$table->id = 'hookslist';
+$table->attributes['class'] = 'admintable generaltable';
+$table->data = [];
+
+// All hooks referenced from db/hooks.php files.
+$allhooks = $hookmanager->get_all_callbacks();
+
+// Add unused hooks.
+$candidates = $hookmanager->discover_known_hooks();
+foreach ($candidates as $classname) {
+ if (isset($allhooks[$classname])) {
+ continue;
+ }
+ $allhooks[$classname] = [];
+}
+
+foreach ($allhooks as $hookclass => $callbacks) {
+ $cbinfo = [];
+ foreach ($callbacks as $definition) {
+ $iscallable = is_callable($definition['callback'], true, $callbackname);
+ $isoverridden = isset($CFG->hooks_callback_overrides[$hookclass][$definition['callback']]);
+ $info = $callbackname . ' (' . $definition['priority'] . ')';
+ if (!$iscallable) {
+ $info .= ' ' . get_string('error') . '';
+ }
+ if ($isoverridden) {
+ // The lang string meaning should be close enough here.
+ $info .= ' ' . get_string('configoverride', 'core_admin') . '';
+ }
+
+ $cbinfo[] = $info;
+ }
+ if ($cbinfo) {
+ foreach ($cbinfo as $k => $v) {
+ $class = '';
+ if ($definition['disabled']) {
+ $class = 'dimmed_text';
+ }
+ $cbinfo[$k] = "
" . $v . '';
+ }
+ $cbinfo = '' . implode("\n", $cbinfo) . '
';
+ } else {
+ $cbinfo = '';
+ }
+
+ if (!class_exists($hookclass)) {
+ // This could be from a contrib plugin that is compatible with multiple Moodle branches.
+ $description = '' . get_string('hookmissing', 'core_admin') . '';
+ } else {
+ $rc = new \ReflectionClass($hookclass);
+ if ($rc->implementsInterface(\core\hook\described_hook::class)) {
+ $description = call_user_func([$hookclass, 'get_hook_description']);
+ $description = clean_text(markdown_to_html($description), FORMAT_HTML);
+ } else {
+ $description = '' . get_string('hookdescriptionmissing', 'core_admin') . '';
+ }
+ }
+
+ $deprecates = '';
+ if (class_exists($hookclass) && $rc->implementsInterface(\core\hook\deprecated_callback_replacement::class)) {
+ $deprecates = call_user_func([$hookclass, 'get_deprecated_plugin_callbacks']);
+ if ($deprecates) {
+ foreach ($deprecates as $k => $v) {
+ $deprecates[$k] = '' . $v . '';
+ }
+ $deprecates = '' . implode("\n", $deprecates) . '
';
+ }
+ }
+
+ $table->data[] = new html_table_row([$hookclass, $cbinfo, $description, $deprecates]);
+}
+
+echo html_writer::table($table);
+
+echo $OUTPUT->footer();
diff --git a/admin/settings/development.php b/admin/settings/development.php
index 9fb6f0c5042..a588b5eb24e 100644
--- a/admin/settings/development.php
+++ b/admin/settings/development.php
@@ -121,4 +121,7 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
"$CFG->wwwroot/$CFG->admin/purgecaches.php"));
$ADMIN->add('development', new admin_externalpage('thirdpartylibs', new lang_string('thirdpartylibs','admin'), "$CFG->wwwroot/$CFG->admin/thirdpartylibs.php"));
+
+ $ADMIN->add('development', new admin_externalpage('hooksoverview',
+ new lang_string('hooksoverview', 'admin'), "$CFG->wwwroot/$CFG->admin/hooks.php"));
} // end of speedup
diff --git a/lang/en/admin.php b/lang/en/admin.php
index ef1508947fc..912108fe866 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -722,6 +722,13 @@ $string['hiddenuserfields'] = 'Hide user fields';
$string['hidefromall'] = 'Hide from all users';
$string['hidefromnone'] = 'Hide from nobody';
$string['hidefromstudents'] = 'Hide from students';
+$string['hookcallbacks'] = 'Callbacks';
+$string['hookdeprecates'] = 'Deprecated lib.php callbacks';
+$string['hookdescription'] = 'Description';
+$string['hookdescriptionmissing'] = 'Hook does not have a description method';
+$string['hookmissing'] = 'Hook is not available';
+$string['hookname'] = 'Hook';
+$string['hooksoverview'] = 'Hooks overview';
$string['hostname'] = 'Host name';
$string['htmleditor'] = 'HTML editor';
$string['htmleditorsettings'] = 'HTML editor settings';
diff --git a/lang/en/cache.php b/lang/en/cache.php
index aa644ce4beb..5d5a275a549 100644
--- a/lang/en/cache.php
+++ b/lang/en/cache.php
@@ -68,6 +68,7 @@ $string['cachedef_groupdata'] = 'Course group information';
$string['cachedef_h5p_content_type_translations'] = 'H5P content-type libraries translations';
$string['cachedef_h5p_libraries'] = 'H5P libraries';
$string['cachedef_h5p_library_files'] = 'H5P library files';
+$string['cachedef_hookcallbacks'] = 'Hook callbacks';
$string['cachedef_htmlpurifier'] = 'HTML Purifier - cleaned content';
$string['cachedef_langmenu'] = 'List of available languages';
$string['cachedef_license'] = 'List of licences';
diff --git a/lib/classes/component.php b/lib/classes/component.php
index b9ba8a82a31..13b2fd13378 100644
--- a/lib/classes/component.php
+++ b/lib/classes/component.php
@@ -112,6 +112,7 @@ class core_component {
'Psr\\Http\\Client' => 'lib/psr/http-client/src',
'Psr\\Http\\Factory' => 'lib/psr/http-factory/src',
'Psr\\Http\\Message' => 'lib/psr/http-message/src',
+ 'Psr\\EventDispatcher' => 'lib/psr/event-dispatcher/src',
'GuzzleHttp\\Psr7' => 'lib/guzzlehttp/psr7/src',
'GuzzleHttp\\Promise' => 'lib/guzzlehttp/promises/src',
'GuzzleHttp' => 'lib/guzzlehttp/guzzle/src',
diff --git a/lib/classes/hook/deprecated_callback_replacement.php b/lib/classes/hook/deprecated_callback_replacement.php
new file mode 100644
index 00000000000..a2cae6f2795
--- /dev/null
+++ b/lib/classes/hook/deprecated_callback_replacement.php
@@ -0,0 +1,38 @@
+.
+
+namespace core\hook;
+
+/**
+ * Interface for hook callbacks that were deprecated by the hook.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface deprecated_callback_replacement {
+ /**
+ * Returns list of lib.php plugin callbacks that were deprecated by the hook.
+ *
+ * It is used for automatic debugging messages and if present it
+ * also skips relevant legacy callbacks in plugins that implemented callbacks
+ * for this hook (to allow plugin compatibility with multiple Moodle branches).
+ *
+ * @return array
+ */
+ public static function get_deprecated_plugin_callbacks(): array;
+}
diff --git a/lib/classes/hook/described_hook.php b/lib/classes/hook/described_hook.php
new file mode 100644
index 00000000000..484a4a44884
--- /dev/null
+++ b/lib/classes/hook/described_hook.php
@@ -0,0 +1,38 @@
+.
+
+namespace core\hook;
+
+/**
+ * Interface for a hook to provide a description of itself for administrator information.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface described_hook {
+ /**
+ * Mandatory hook purpose description in Markdown format
+ * used on Hooks overview page.
+ *
+ * It should include description of callback priority setting
+ * rules if applicable.
+ *
+ * @return string
+ */
+ public static function get_hook_description(): string;
+}
diff --git a/lib/classes/hook/manager.php b/lib/classes/hook/manager.php
new file mode 100644
index 00000000000..1e27b8fe567
--- /dev/null
+++ b/lib/classes/hook/manager.php
@@ -0,0 +1,603 @@
+.
+
+namespace core\hook;
+
+use Psr\EventDispatcher\EventDispatcherInterface;
+use Psr\EventDispatcher\ListenerProviderInterface;
+use Psr\EventDispatcher\StoppableEventInterface;
+
+/**
+ * Hook manager implementing "Dispatcher" and "Event Provider" from PSR-14.
+ *
+ * Due to class/method naming restrictions and collision with
+ * Moodle events the definitions from PSR-14 should be interpreted as:
+ *
+ * 1. Event --> Hook
+ * 2. Listener --> Hook callback
+ * 3. Emitter --> Hook emitter
+ * 4. Dispatcher --> Hook dispatcher - implemented in manager::dispatch()
+ * 5. Listener Provider --> Hook callback provider - implemented in manager::get_callbacks_for_hook()
+ *
+ * Note that technically any object can be a hook, but it is recommended
+ * to put all hook classes into \component_name\hook namespaces and
+ * each hook should implement \core\hook\described_hook interface.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+final class manager implements
+ EventDispatcherInterface,
+ ListenerProviderInterface {
+
+ /** @var ?manager the one instance of listener provider and dispatcher */
+ private static $instance = null;
+
+ /** @var array list of callback definitions for each hook class. */
+ private $allcallbacks = [];
+
+ /** @var array list of all deprecated lib.php plugin callbacks. */
+ private $alldeprecations = [];
+
+ /**
+ * Constructor can be used only from factory methods.
+ */
+ private function __construct() {
+ }
+
+ /**
+ * Factory method, returns instance of manager that serves
+ * as hook dispatcher and callback provider.
+ *
+ * @return self
+ */
+ public static function get_instance(): manager {
+ if (!self::$instance) {
+ self::$instance = new self();
+ self::$instance->init_standard_callbacks();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Factory method for testing of hook manager in PHPUnit tests.
+ *
+ * @param array $componentfiles list of hook callback files for each component.
+ * @return self
+ */
+ public static function phpunit_get_instance(array $componentfiles): manager {
+ if (!PHPUNIT_TEST) {
+ throw new \coding_exception('Invalid call of manager::phpunit_get_instance() outside of tests');
+ }
+ $instance = new self();
+ $instance->load_callbacks($componentfiles);
+ return $instance;
+ }
+
+ /**
+ * Reset all hook caches. This is intended to be called only
+ * from the admin/hooks.php page after callback override is changed.
+ *
+ * @return void
+ * @codeCoverageIgnore
+ */
+ public function reset_caches(): void {
+ if (PHPUNIT_TEST && $this === self::$instance) {
+ debugging('\core\hook\manager::get_instance()->reset_caches() is not supposed to be called in PHPUnit tests',
+ DEBUG_DEVELOPER);
+ return;
+ }
+
+ // WARNING: This will not work when callback overrides are changed
+ // and multiple web nodes with local cache stores are present - in that
+ // case admins must purge all caches when tweaking callback overrides.
+ $cache = \cache::make('core', 'hookcallbacks');
+ $cache->delete('callbacks');
+ $cache->delete('deprecations');
+
+ $this->init_standard_callbacks();
+ }
+
+ /**
+ * Returns list of callbacks for given hook name.
+ *
+ * NOTE: this is the "Listener Provider" described in PSR-14,
+ * instead of instance parameter it uses real PHP class names.
+ * Moodle hooks should be final and parents of hook class are not
+ * considered when resolving callbacks.
+ *
+ * @param string $hookclassname PHP class name of hook
+ * @return array list of callback definitions
+ */
+ public function get_callbacks_for_hook(string $hookclassname): array {
+ return $this->allcallbacks[$hookclassname] ?? [];
+ }
+
+ /**
+ * Returns list of all callbacks found in deb/hooks.php files.
+ *
+ * @return iterable
+ */
+ public function get_all_callbacks(): iterable {
+ return $this->allcallbacks;
+ }
+
+ /**
+ * Get the list of listeners for the specified event.
+ *
+ * @param object $event The object being listened to (aka hook).
+ * @return iterable
+ * An iterable (array, iterator, or generator) of callables. Each
+ * callable MUST be type-compatible with $event.
+ * Please note that in Moodle the callable must be a string.
+ */
+ public function getListenersForEvent(object $event): iterable {
+ // Callbacks are sorted by priority, highest first at load-time.
+ $hookclassname = get_class($event);
+ $callbacks = $this->get_callbacks_for_hook($hookclassname);
+
+ if (count($callbacks) === 0) {
+ // Nothing is interested in this hook.
+ return new \EmptyIterator();
+ }
+
+ foreach ($callbacks as $definition) {
+ if ($definition['disabled']) {
+ continue;
+ }
+ $callback = $definition['callback'];
+
+ if ($this->is_callback_valid($definition['component'], $callback)) {
+ yield $callback;
+ }
+ }
+ }
+
+ /**
+ * Verify that callback is valid.
+ *
+ * @param string $component
+ * @param string $callback
+ * @return bool
+ */
+ private function is_callback_valid(string $component, string $callback): bool {
+ [$callbackclass, $callbackmethod] = explode('::', $callback, 2);
+ if (!class_exists($callbackclass)) {
+ debugging(
+ "Hook callback definition contains invalid 'callback' class name in '$component'. " .
+ "Callback class '{$callbackclass}' not found.",
+ DEBUG_DEVELOPER,
+ );
+ return false;
+ }
+ $rc = new \ReflectionClass($callbackclass);
+ if (!$rc->hasMethod($callbackmethod)) {
+ debugging(
+ "Hook callback definition contains invalid 'callback' method name in '$component'. " .
+ "Callback method not found.",
+ DEBUG_DEVELOPER,
+ );
+ return false;
+ }
+
+ $rcm = $rc->getMethod($callbackmethod);
+ if (!$rcm->isStatic()) {
+ debugging(
+ "Hook callback definition contains invalid 'callback' method name in '$component'. " .
+ "Callback method not a static method.",
+ DEBUG_DEVELOPER,
+ );
+ return false;
+ }
+
+ if (!is_callable($callback, false, $callablename)) {
+ debugging(
+ "Cannot execute callback '$callablename' from '$component'" .
+ "Callback method not callable.",
+ DEBUG_DEVELOPER
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the list of Hook class names that have registered callbacks.
+ *
+ * @return array
+ */
+ public function get_hooks_with_callbacks(): array {
+ return array_keys($this->allcallbacks);
+ }
+
+ /**
+ * Provide all relevant listeners with an event to process.
+ *
+ * @param object $event The object to process (aka hook).
+ * @return object The Event that was passed, now modified by listeners.
+ */
+ public function dispatch(object $event): object {
+ // We can dispatch only after the lib/setup.php includes,
+ // that is right before the database connection is made,
+ // the MUC caches need to be working already.
+ if (!function_exists('setup_DB')) {
+ debugging('Hooks cannot be dispatched yet', DEBUG_DEVELOPER);
+ return $event;
+ }
+
+ $callbacks = $this->getListenersForEvent($event);
+
+ if (empty($callbacks)) {
+ // Nothing is interested in this hook.
+ return $event;
+ }
+
+ foreach ($callbacks as $callback) {
+ // Note: PSR-14 states:
+ // If passed a Stoppable Event, a Dispatcher
+ // MUST call isPropagationStopped() on the Event before each Listener has been called.
+ // If that method returns true it MUST return the Event to the Emitter immediately and
+ // MUST NOT call any further Listeners. This implies that if an Event is passed to the
+ // Dispatcher that always returns true from isPropagationStopped(), zero listeners will be called.
+ // Ergo, we check for a stopped event before calling each listener, not afterwards.
+ if ($event instanceof StoppableEventInterface) {
+ if ($event->isPropagationStopped()) {
+ return $event;
+ }
+ }
+
+ call_user_func($callback, $event);
+ }
+
+ // Developers need to be careful to not create infinite loops in hook callbacks.
+ return $event;
+ }
+
+ /**
+ * Initialise list of all callbacks for each hook.
+ *
+ * @return void
+ */
+ private function init_standard_callbacks(): void {
+ global $CFG;
+
+ $this->allcallbacks = [];
+ $this->alldeprecations = [];
+
+ $cache = null;
+ // @codeCoverageIgnoreStart
+ if (!PHPUNIT_TEST && !CACHE_DISABLE_ALL) {
+ $cache = \cache::make('core', 'hookcallbacks');
+ $callbacks = $cache->get('callbacks');
+ $deprecations = $cache->get('deprecations');
+ if (is_array($callbacks) && is_array($deprecations)) {
+ $this->allcallbacks = $callbacks;
+ $this->alldeprecations = $deprecations;
+ return;
+ }
+ }
+ // @codeCoverageIgnoreEnd
+
+ // Get list of all files with callbacks, one per component.
+ $components = ['core' => "{$CFG->dirroot}/lib/db/hooks.php"];
+ $plugintypes = \core_component::get_plugin_types();
+ foreach ($plugintypes as $plugintype => $plugintypedir) {
+ $plugins = \core_component::get_plugin_list($plugintype);
+ foreach ($plugins as $pluginname => $plugindir) {
+ if (!$plugindir) {
+ continue;
+ }
+
+ $components["{$plugintype}_{$pluginname}"] = "{$plugindir}/db/hooks.php";
+ }
+ }
+
+ // Load the callbacks and apply overrides.
+ $this->load_callbacks($components);
+
+ if ($cache) {
+ $cache->set('callbacks', $this->allcallbacks);
+ $cache->set('deprecations', $this->alldeprecations);
+ }
+ }
+
+ /**
+ * Load callbacks from component db/hooks.php files.
+ *
+ * @param array $componentfiles list of all components with their callback files
+ * @return void
+ */
+ private function load_callbacks(array $componentfiles): void {
+ $this->allcallbacks = [];
+ $this->alldeprecations = [];
+
+ array_map(
+ [$this, 'add_component_callbacks'],
+ array_keys($componentfiles),
+ $componentfiles,
+ );
+ $this->load_callback_overrides();
+ $this->prioritise_callbacks();
+ $this->fetch_deprecated_callbacks();
+ }
+
+ /**
+ * In extremely special cases admins may decide to override callbacks via config.php setting.
+ */
+ private function load_callback_overrides(): void {
+ global $CFG;
+
+ if (!property_exists($CFG, 'hooks_callback_overrides')) {
+ return;
+ }
+
+ if (!is_iterable($CFG->hooks_callback_overrides)) {
+ debugging('hooks_callback_overrides must be an array', DEBUG_DEVELOPER);
+ return;
+ }
+
+ foreach ($CFG->hooks_callback_overrides as $hookclassname => $overrides) {
+ if (!is_iterable($overrides)) {
+ debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER);
+ continue;
+ }
+
+ if (!array_key_exists($hookclassname, $this->allcallbacks)) {
+ debugging('hooks_callback_overrides must be an array of arrays with existing hook classnames', DEBUG_DEVELOPER);
+ continue;
+ }
+
+ foreach ($overrides as $callback => $override) {
+ if (!is_array($override)) {
+ debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER);
+ continue;
+ }
+
+ $found = false;
+ foreach ($this->allcallbacks[$hookclassname] as $index => $definition) {
+ if ($definition['callback'] === $callback) {
+ if (isset($override['priority'])) {
+ $definition['defaultpriority'] = $definition['priority'];
+ $definition['priority'] = (int) $override['priority'];
+ }
+
+ if (!empty($override['disabled'])) {
+ $definition['disabled'] = true;
+ }
+
+ $this->allcallbacks[$hookclassname][$index] = $definition;
+ $found = true;
+ break;
+ }
+ }
+ if (!$found) {
+ debugging("Unable to find callback '{$callback}' for '{$hookclassname}'", DEBUG_DEVELOPER);
+ }
+ }
+ }
+ }
+
+ /**
+ * Prioritise the callbacks.
+ */
+ private function prioritise_callbacks(): void {
+ // Prioritise callbacks.
+ foreach ($this->allcallbacks as $hookclassname => $hookcallbacks) {
+ \core_collator::asort_array_of_arrays_by_key($hookcallbacks, 'priority', \core_collator::SORT_NUMERIC);
+ $hookcallbacks = array_reverse($hookcallbacks);
+ $this->allcallbacks[$hookclassname] = $hookcallbacks;
+ }
+ }
+
+ /**
+ * Fetch the list of callbacks that this hook replaces.
+ */
+ private function fetch_deprecated_callbacks(): void {
+ $candidates = self::discover_known_hooks();
+
+ /** @var class-string $hookclassname */
+ foreach ($candidates as $hookclassname) {
+ // It's 2023 and PHP still doesn't provide a simple way to detect if a class implements an interface without
+ // that class being instantiated.
+ $rc = new \ReflectionClass($hookclassname);
+ if (!$rc->implementsInterface(\core\hook\deprecated_callback_replacement::class)) {
+ continue;
+ }
+ $deprecations = $hookclassname::get_deprecated_plugin_callbacks();
+ if (!$deprecations) {
+ continue;
+ }
+ foreach ($deprecations as $deprecation) {
+ $this->alldeprecations[$deprecation][] = $hookclassname;
+ }
+ }
+ }
+
+ /**
+ * Add hook callbacks from file.
+ *
+ * @param string $component component where hook callbacks are defined
+ * @param string $hookfile file with list of all callbacks for component
+ * @return void
+ */
+ private function add_component_callbacks(string $component, string $hookfile): void {
+ if (!file_exists($hookfile)) {
+ return;
+ }
+
+ $parsecallbacks = function($hookfile) {
+ $callbacks = [];
+ include($hookfile);
+ return $callbacks;
+ };
+
+ $callbacks = $parsecallbacks($hookfile);
+
+ if (!is_array($callbacks) || !$callbacks) {
+ return;
+ }
+
+ foreach ($callbacks as $callbackdata) {
+ if (empty($callbackdata['hook'])) {
+ debugging("Hook callback definition requires 'hook' name in '$component'", DEBUG_DEVELOPER);
+ continue;
+ }
+
+ $callbackmethod = $this->normalise_callback($component, $callbackdata);
+ if ($callbackmethod === null) {
+ continue;
+ }
+
+ $callback = [
+ 'callback' => $callbackmethod,
+ 'component' => $component,
+ 'disabled' => false,
+ 'priority' => 100,
+ ];
+
+ if (isset($callbackdata['priority'])) {
+ $callback['priority'] = (int) $callbackdata['priority'];
+ }
+
+ $hook = ltrim($callbackdata['hook'], '\\'); // Normalise hook class name.
+ $this->allcallbacks[$hook][] = $callback;
+ }
+ }
+
+ /**
+ * Normalise the callback class::method value.
+ *
+ * @param string $component
+ * @param array $callback
+ * @return null|string
+ */
+ private function normalise_callback(string $component, array $callback): ?string {
+ if (empty($callback['callback'])) {
+ debugging("Hook callback definition requires 'callback' callable in '$component'", DEBUG_DEVELOPER);
+ return null;
+ }
+ $classmethod = $callback['callback'];
+ if (!is_string($classmethod)) {
+ debugging("Hook callback definition contains invalid 'callback' string in '$component'", DEBUG_DEVELOPER);
+ return null;
+ }
+ if (!str_contains($classmethod, '::')) {
+ debugging(
+ "Hook callback definition contains invalid 'callback' static class method string in '$component'",
+ DEBUG_DEVELOPER
+ );
+ return null;
+ }
+
+ // Normalise the callback class::method name, we use it later as an identifier.
+ $classmethod = ltrim($classmethod, '\\');
+
+ return $classmethod;
+ }
+
+ /**
+ * Is the plugin callback from lib.php deprecated by any hook?
+ *
+ * @param string $plugincallback short callback name without the component prefix
+ * @return bool
+ */
+ public function is_deprecated_plugin_callback(string $plugincallback): bool {
+ return isset($this->alldeprecations[$plugincallback]);
+ }
+
+ /**
+ * Is there a hook callback in component that deprecates given lib.php plugin callback?
+ *
+ * NOTE: if there is both hook and deprecated callback then we ignore the old callback
+ * to allow compatibility of contrib plugins with multiple Moodle branches.
+ *
+ * @param string $component
+ * @param string $plugincallback short callback name without the component prefix
+ * @return bool
+ */
+ public function is_deprecating_hook_present(string $component, string $plugincallback): bool {
+ if (!isset($this->alldeprecations[$plugincallback])) {
+ return false;
+ }
+
+ foreach ($this->alldeprecations[$plugincallback] as $hookclassname) {
+ if (!isset($this->allcallbacks[$hookclassname])) {
+ continue;
+ }
+ foreach ($this->allcallbacks[$hookclassname] as $definition) {
+ if ($definition['component'] === $component) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns list of hooks discovered through standardised Moodle methods.
+ *
+ * Note that the exact discovery logic may change in the future,
+ * for now this looks for hooks mentioned in callback registrations
+ * and non-abstract classes in \component_name\hook namespaces that
+ * implement described_hook interface.
+ *
+ * @return array hook class names
+ */
+ public function discover_known_hooks(): array {
+ $hooks = [];
+
+ // All classes references in callbacks are considered to be known hooks.
+ foreach ($this->allcallbacks as $classname => $definition) {
+ if (!class_exists($classname)) {
+ continue;
+ }
+ $hooks[] = $classname;
+ }
+
+ // For classes in hook namespace we have more requirements.
+ $components = ['core'];
+ foreach (\core_component::get_plugin_types() as $plugintype => $plugintypedir) {
+ foreach (\core_component::get_plugin_list($plugintype) as $pluginname => $plugindir) {
+ $components[] = $plugintype . '_' . $pluginname;
+ }
+ }
+ foreach ($components as $component) {
+ $classnames = array_keys(\core_component::get_component_classes_in_namespace($component, 'hook'));
+ foreach ($classnames as $classname) {
+ if (isset($this->allcallbacks[$classname])) {
+ continue;
+ }
+ if (!class_exists($classname)) {
+ continue;
+ }
+ $rc = new \ReflectionClass($classname);
+ if ($rc->isAbstract()) {
+ continue;
+ }
+ if (!$rc->implementsInterface(\core\hook\described_hook::class)) {
+ continue;
+ }
+ $hooks[] = $classname;
+ }
+ }
+
+ return $hooks;
+ }
+}
diff --git a/lib/db/caches.php b/lib/db/caches.php
index 8d0c2be2d74..e4c67e08ca2 100644
--- a/lib/db/caches.php
+++ b/lib/db/caches.php
@@ -77,6 +77,19 @@ $definitions = array(
'simpledata' => true,
),
+ // Hook callbacks cache.
+ // There is a static cache in hook manager, data is fetched once per page on first hook execution.
+ // This cache needs to be invalidated during upgrades when code changes and when callbacks
+ // overrides are updated.
+ 'hookcallbacks' => array(
+ 'mode' => cache_store::MODE_APPLICATION,
+ 'simplekeys' => true,
+ 'simpledata' => true,
+ 'staticacceleration' => false,
+ // WARNING: Manual cache purge may be required when overriding hook callbacks.
+ 'canuselocalstore' => true,
+ ),
+
// Cache for question definitions. This is used by the question_bank class.
// Users probably do not need to know about this cache. They will just call
// question_bank::load_question.
diff --git a/lib/moodlelib.php b/lib/moodlelib.php
index 7f98a71e89d..a90318c1f11 100644
--- a/lib/moodlelib.php
+++ b/lib/moodlelib.php
@@ -7861,9 +7861,10 @@ function get_plugin_list_with_function($plugintype, $function, $file = 'lib.php'
* @param string $file the name of file within the plugin that defines the
* function. Defaults to lib.php.
* @param bool $include Whether to include the files that contain the functions or not.
+ * @param bool $migratedtohook if true this is a deprecated lib.php callback, if hook callback is present then do nothing
* @return array with [plugintype][plugin] = functionname
*/
-function get_plugins_with_function($function, $file = 'lib.php', $include = true) {
+function get_plugins_with_function($function, $file = 'lib.php', $include = true, bool $migratedtohook = false) {
global $CFG;
if (during_initial_install() || isset($CFG->upgraderunning)) {
@@ -7871,6 +7872,25 @@ function get_plugins_with_function($function, $file = 'lib.php', $include = true
return [];
}
+ $plugincallback = $function;
+ $filtermigrated = function($plugincallback, $pluginfunctions): array {
+ foreach ($pluginfunctions as $plugintype => $plugins) {
+ foreach ($plugins as $plugin => $unusedfunction) {
+ $component = $plugintype . '_' . $plugin;
+ if (\core\hook\manager::get_instance()->is_deprecated_plugin_callback($plugincallback)) {
+ if (\core\hook\manager::get_instance()->is_deprecating_hook_present($component, $plugincallback)) {
+ // Ignore the old callback, it is there only for older Moodle versions.
+ unset($pluginfunctions[$plugintype][$plugin]);
+ } else {
+ debugging("Callback $plugincallback in $component component should be migrated to new hook callback",
+ DEBUG_DEVELOPER);
+ }
+ }
+ }
+ }
+ return $pluginfunctions;
+ };
+
$cache = \cache::make('core', 'plugin_functions');
// Including both although I doubt that we will find two functions definitions with the same name.
@@ -7925,6 +7945,9 @@ function get_plugins_with_function($function, $file = 'lib.php', $include = true
// If the cache is dirty, we should fall through and let it rebuild.
if (!$dirty) {
+ if ($migratedtohook && $file === 'lib.php') {
+ $pluginfunctions = $filtermigrated($plugincallback, $pluginfunctions);
+ }
return $pluginfunctions;
}
}
@@ -7972,6 +7995,10 @@ function get_plugins_with_function($function, $file = 'lib.php', $include = true
$cache->set($key, $pluginfunctions);
}
+ if ($migratedtohook && $file === 'lib.php') {
+ $pluginfunctions = $filtermigrated($plugincallback, $pluginfunctions);
+ }
+
return $pluginfunctions;
}
@@ -8061,12 +8088,13 @@ function get_list_of_plugins($directory='mod', $exclude='', $basedir='') {
* @param string $action feature's action
* @param array $params parameters of callback function, should be an array
* @param mixed $default default value if callback function hasn't been defined, or if it retursn null.
+ * @param bool $migratedtohook if true this is a deprecated callback, if hook callback is present then do nothing
* @return mixed
*
* @todo Decide about to deprecate and drop plugin_callback() - MDL-30743
*/
-function plugin_callback($type, $name, $feature, $action, $params = null, $default = null) {
- return component_callback($type . '_' . $name, $feature . '_' . $action, (array) $params, $default);
+function plugin_callback($type, $name, $feature, $action, $params = null, $default = null, bool $migratedtohook = false) {
+ return component_callback($type . '_' . $name, $feature . '_' . $action, (array) $params, $default, $migratedtohook);
}
/**
@@ -8076,9 +8104,10 @@ function plugin_callback($type, $name, $feature, $action, $params = null, $defau
* @param string $function the rest of the function name, e.g. 'cron' will end up calling 'mod_quiz_cron'
* @param array $params parameters of callback function
* @param mixed $default default value if callback function hasn't been defined, or if it retursn null.
+ * @param bool $migratedtohook if true this is a deprecated callback, if hook callback is present then do nothing
* @return mixed
*/
-function component_callback($component, $function, array $params = array(), $default = null) {
+function component_callback($component, $function, array $params = array(), $default = null, bool $migratedtohook = false) {
$functionname = component_callback_exists($component, $function);
@@ -8093,6 +8122,19 @@ function component_callback($component, $function, array $params = array(), $def
}
if ($functionname) {
+ if ($migratedtohook) {
+ if (\core\hook\manager::get_instance()->is_deprecated_plugin_callback($function)) {
+ if (\core\hook\manager::get_instance()->is_deprecating_hook_present($component, $function)) {
+ // Do not call the old lib.php callback,
+ // it is there for compatibility with older Moodle versions only.
+ return null;
+ } else {
+ debugging("Callback $function in $component component should be migrated to new hook callback",
+ DEBUG_DEVELOPER);
+ }
+ }
+ }
+
// Function exists, so just return function result.
$ret = call_user_func_array($functionname, $params);
if (is_null($ret)) {
diff --git a/lib/phpunit/bootstrap.php b/lib/phpunit/bootstrap.php
index e8be0752c2d..dd15d1bb189 100644
--- a/lib/phpunit/bootstrap.php
+++ b/lib/phpunit/bootstrap.php
@@ -223,6 +223,10 @@ if (PHPUNIT_UTIL) {
return;
}
+// Make sure the hook manager gets initialised before anybody tries to override callbacks,
+// this is not using caches intentionally to help with development.
+\core\hook\manager::get_instance();
+
// is database and dataroot ready for testing?
list($errorcode, $message) = phpunit_util::testing_ready_problem();
// print some version info
diff --git a/lib/psr/event-dispatcher/LICENSE b/lib/psr/event-dispatcher/LICENSE
new file mode 100644
index 00000000000..3f1559b2ad6
--- /dev/null
+++ b/lib/psr/event-dispatcher/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 PHP-FIG
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/lib/psr/event-dispatcher/README.md b/lib/psr/event-dispatcher/README.md
new file mode 100644
index 00000000000..01a8c1aaf1c
--- /dev/null
+++ b/lib/psr/event-dispatcher/README.md
@@ -0,0 +1,12 @@
+Event Dispatcher
+==============
+
+This repository holds all interfaces related to [PSR-14 (Event Dispatcher)][psr-url].
+
+Note that this is not an Event Dispatcher implementation of its own. It is merely interfaces that describe the components of an Event Dispatcher.
+
+You can find [implementations][implementation-url] and [installation instructions][package-url] for the specification on the packagist.
+
+[psr-url]: https://www.php-fig.org/psr/psr-14/
+[package-url]: https://packagist.org/packages/psr/event-dispatcher
+[implementation-url]: https://packagist.org/providers/psr/event-dispatcher-implementation
diff --git a/lib/psr/event-dispatcher/composer.json b/lib/psr/event-dispatcher/composer.json
new file mode 100644
index 00000000000..adc828572b3
--- /dev/null
+++ b/lib/psr/event-dispatcher/composer.json
@@ -0,0 +1,29 @@
+{
+ "name": "psr/event-dispatcher",
+ "description": "Standard interfaces for event handling.",
+ "type": "library",
+ "keywords": ["psr", "psr-14", "events"],
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\EventDispatcher\\": "src/"
+ }
+ },
+ "suggest": {
+ "fig/event-dispatcher-util": "Provides some useful PSR-14 utilities"
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ }
+}
diff --git a/lib/psr/event-dispatcher/src/EventDispatcherInterface.php b/lib/psr/event-dispatcher/src/EventDispatcherInterface.php
new file mode 100644
index 00000000000..4306fa91560
--- /dev/null
+++ b/lib/psr/event-dispatcher/src/EventDispatcherInterface.php
@@ -0,0 +1,21 @@
+
+ * An iterable (array, iterator, or generator) of callables. Each
+ * callable MUST be type-compatible with $event.
+ */
+ public function getListenersForEvent(object $event) : iterable;
+}
diff --git a/lib/psr/event-dispatcher/src/StoppableEventInterface.php b/lib/psr/event-dispatcher/src/StoppableEventInterface.php
new file mode 100644
index 00000000000..59550816467
--- /dev/null
+++ b/lib/psr/event-dispatcher/src/StoppableEventInterface.php
@@ -0,0 +1,26 @@
+.
+
+namespace test_plugin;
+
+/**
+ * Fixture for testing of hooks.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+final class callbacks {
+ /** @var string[] list of calls */
+ public static $calls = [];
+
+ /**
+ * Callback tester.
+ *
+ * @param \test_plugin\hook\hook $hook
+ * @return void
+ */
+ public static function test1(\test_plugin\hook\hook $hook): void {
+ self::$calls[] = 'test1';
+ }
+
+ /**
+ * Callback tester.
+ *
+ * @param \test_plugin\hook\hook $hook
+ * @return void
+ */
+ public static function test2(\test_plugin\hook\hook $hook): void {
+ self::$calls[] = 'test2';
+ }
+
+ /**
+ * Callback tester.
+ *
+ * @param \test_plugin\hook\stoppablehook $hook
+ * @return void
+ */
+ public static function stop1(\test_plugin\hook\stoppablehook $hook): void {
+ self::$calls[] = 'stop1';
+ $hook->stop();
+ }
+
+ /**
+ * Callback tester.
+ *
+ * @param \test_plugin\hook\stoppablehook $hook
+ * @return void
+ */
+ public static function stop2(\test_plugin\hook\stoppablehook $hook): void {
+ self::$calls[] = 'stop2';
+ $hook->stop();
+ }
+
+ /**
+ * Callback tester for exceptions.
+ *
+ * @param \test_plugin\hook\hook $hook
+ * @return void
+ */
+ public static function exception(\test_plugin\hook\hook $hook): void {
+ self::$calls[] = 'exception';
+ throw new \Exception('grrr');
+ }
+}
diff --git a/lib/tests/fixtures/hook/hook.php b/lib/tests/fixtures/hook/hook.php
new file mode 100644
index 00000000000..0003aef41ae
--- /dev/null
+++ b/lib/tests/fixtures/hook/hook.php
@@ -0,0 +1,44 @@
+.
+
+namespace test_plugin\hook;
+
+/**
+ * Fixture for testing of hooks.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+final class hook implements
+ \core\hook\described_hook,
+ \core\hook\deprecated_callback_replacement {
+
+ /**
+ * Hook description.
+ */
+ public static function get_hook_description(): string {
+ return 'Test hook 1.';
+ }
+
+ /**
+ * Deprecation info.
+ */
+ public static function get_deprecated_plugin_callbacks(): array {
+ return ['oldcallback'];
+ }
+}
diff --git a/lib/tests/fixtures/hook/hooks1_broken.php b/lib/tests/fixtures/hook/hooks1_broken.php
new file mode 100644
index 00000000000..1cfe896c3f3
--- /dev/null
+++ b/lib/tests/fixtures/hook/hooks1_broken.php
@@ -0,0 +1,42 @@
+.
+
+/**
+ * Fixtures for hook testing.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'callback' => 'test_plugin\\callbacks::test2',
+ 'priority' => 100,
+ ],
+ [
+ 'hook' => 'test_plugin\\hook\\hook',
+ 'priority' => 100,
+ ],
+ [
+ 'hook' => 'test_plugin\\hook\\hook',
+ 'callback' => 'test_plugin\\callbackstest2',
+ 'priority' => 100,
+ ],
+];
diff --git a/lib/tests/fixtures/hook/hooks1_exception.php b/lib/tests/fixtures/hook/hooks1_exception.php
new file mode 100644
index 00000000000..8048e6a3df1
--- /dev/null
+++ b/lib/tests/fixtures/hook/hooks1_exception.php
@@ -0,0 +1,34 @@
+.
+
+/**
+ * Fixtures for hook testing.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'hook' => 'test_plugin\\hook\\hook',
+ 'callback' => 'test_plugin\\callbacks::exception',
+ 'priority' => 1000,
+ ],
+];
diff --git a/lib/tests/fixtures/hook/hooks1_missing.php b/lib/tests/fixtures/hook/hooks1_missing.php
new file mode 100644
index 00000000000..095438946ea
--- /dev/null
+++ b/lib/tests/fixtures/hook/hooks1_missing.php
@@ -0,0 +1,34 @@
+.
+
+/**
+ * Fixtures for hook testing.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'hook' => 'test_plugin\\hook\\hook',
+ 'callback' => 'test_plugin\\callbacks::missing',
+ 'priority' => 1000,
+ ],
+];
diff --git a/lib/tests/fixtures/hook/hooks1_stoppable.php b/lib/tests/fixtures/hook/hooks1_stoppable.php
new file mode 100644
index 00000000000..ec22664fa43
--- /dev/null
+++ b/lib/tests/fixtures/hook/hooks1_stoppable.php
@@ -0,0 +1,34 @@
+.
+
+/**
+ * Fixtures for hook testing.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'hook' => 'test_plugin\\hook\\stoppablehook',
+ 'callback' => 'test_plugin\\callbacks::stop1',
+ 'priority' => 400,
+ ],
+];
diff --git a/lib/tests/fixtures/hook/hooks1_valid.php b/lib/tests/fixtures/hook/hooks1_valid.php
new file mode 100644
index 00000000000..78266ac4890
--- /dev/null
+++ b/lib/tests/fixtures/hook/hooks1_valid.php
@@ -0,0 +1,33 @@
+.
+
+/**
+ * Fixtures for hook testing.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'hook' => 'test_plugin\\hook\\hook',
+ 'callback' => 'test_plugin\\callbacks::test1',
+ ],
+];
diff --git a/lib/tests/fixtures/hook/hooks2_stoppable.php b/lib/tests/fixtures/hook/hooks2_stoppable.php
new file mode 100644
index 00000000000..0d6a8166f91
--- /dev/null
+++ b/lib/tests/fixtures/hook/hooks2_stoppable.php
@@ -0,0 +1,34 @@
+.
+
+/**
+ * Fixtures for hook testing.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'hook' => 'test_plugin\\hook\\stoppablehook',
+ 'callback' => 'test_plugin\\callbacks::stop2',
+ 'priority' => 200,
+ ],
+];
diff --git a/lib/tests/fixtures/hook/hooks2_valid.php b/lib/tests/fixtures/hook/hooks2_valid.php
new file mode 100644
index 00000000000..3880ec008d2
--- /dev/null
+++ b/lib/tests/fixtures/hook/hooks2_valid.php
@@ -0,0 +1,34 @@
+.
+
+/**
+ * Fixtures for hook testing.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'hook' => 'test_plugin\\hook\\hook',
+ 'callback' => 'test_plugin\\callbacks::test2',
+ 'priority' => 200,
+ ],
+];
diff --git a/lib/tests/fixtures/hook/stoppablehook.php b/lib/tests/fixtures/hook/stoppablehook.php
new file mode 100644
index 00000000000..938b7787c3a
--- /dev/null
+++ b/lib/tests/fixtures/hook/stoppablehook.php
@@ -0,0 +1,56 @@
+.
+
+namespace test_plugin\hook;
+
+use Psr\EventDispatcher\StoppableEventInterface;
+
+/**
+ * Fixture for testing of hooks.
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+final class stoppablehook implements
+ StoppableEventInterface,
+ \core\hook\described_hook {
+
+ /** @var bool stoppable flag */
+ private $stopped = false;
+
+ /**
+ * Hook description.
+ */
+ public static function get_hook_description(): string {
+ return 'Test hook 2.';
+ }
+
+ /**
+ * Stop other callbacks.
+ */
+ public function stop(): void {
+ $this->stopped = true;
+ }
+
+ /**
+ * Indicates if callback propagation should stop.
+ */
+ public function isPropagationStopped(): bool {
+ return $this->stopped;
+ }
+}
diff --git a/lib/tests/hook/manager_test.php b/lib/tests/hook/manager_test.php
new file mode 100644
index 00000000000..a2e31ab9c8a
--- /dev/null
+++ b/lib/tests/hook/manager_test.php
@@ -0,0 +1,367 @@
+.
+
+namespace core\hook;
+
+/**
+ * Hooks tests.
+ *
+ * @coversDefaultClass \core\hook\manager
+ *
+ * @package core
+ * @author Petr Skoda
+ * @copyright 2022 Open LMS
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager_test extends \advanced_testcase {
+ /**
+ * Test public factory method to get hook manager.
+ * @covers ::get_instance
+ */
+ public function test_get_instance() {
+ $manager = manager::get_instance();
+ $this->assertInstanceOf(manager::class, $manager);
+
+ $this->assertSame($manager, manager::get_instance());
+ }
+
+ /**
+ * Test getting of manager test instance.
+ * @covers ::phpunit_get_instance
+ */
+ public function test_phpunit_get_instance() {
+ $testmanager = manager::phpunit_get_instance([]);
+ $this->assertSame([], $testmanager->get_hooks_with_callbacks());
+
+ // We get a new instance every time.
+ $this->assertNotSame($testmanager, manager::phpunit_get_instance([]));
+
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks());
+ }
+
+ /**
+ * Test reset of test instance.
+ *
+ * NOTE: normal hook manger instance cannot be reset in PHPUnit test
+ * because it may be used to control the test environment itself.
+ *
+ * @covers ::reset_caches
+ * @covers ::init_standard_callbacks
+ */
+ public function test_reset_caches() {
+ $testmanager = manager::phpunit_get_instance([]);
+ $this->assertSame([], $testmanager->get_hooks_with_callbacks());
+
+ $testmanager->reset_caches();
+ $manager = manager::get_instance();
+ $this->assertSame($manager->get_hooks_with_callbacks(), $testmanager->get_hooks_with_callbacks());
+ }
+
+ /**
+ * Test loading and parsing of callbacks from files.
+ *
+ * @covers ::get_callbacks_for_hook
+ * @covers ::get_hooks_with_callbacks
+ * @covers ::load_callbacks
+ * @covers ::add_component_callbacks
+ */
+ public function test_callbacks() {
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php',
+ 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks());
+ $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook');
+ $this->assertCount(2, $callbacks);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test2',
+ 'component' => 'test_plugin2',
+ 'disabled' => false,
+ 'priority' => 200,
+ ], $callbacks[0]);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test1',
+ 'component' => 'test_plugin1',
+ 'disabled' => false,
+ 'priority' => 100,
+ ], $callbacks[1]);
+
+ $this->assertDebuggingNotCalled();
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_broken.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertSame([], $testmanager->get_hooks_with_callbacks());
+ $debuggings = $this->getDebuggingMessages();
+ $this->resetDebugging();
+ $this->assertSame('Hook callback definition requires \'hook\' name in \'test_plugin1\'',
+ $debuggings[0]->message);
+ $this->assertSame('Hook callback definition requires \'callback\' callable in \'test_plugin1\'',
+ $debuggings[1]->message);
+ $this->assertSame('Hook callback definition contains invalid \'callback\' static class method string in \'test_plugin1\'',
+ $debuggings[2]->message);
+ $this->assertCount(3, $debuggings);
+ }
+
+ /**
+ * Test hook dispatching, that is callback execution.
+ * @covers ::dispatch
+ */
+ public function test_dispatch(): void {
+ require_once(__DIR__ . '/../fixtures/hook/hook.php');
+ require_once(__DIR__ . '/../fixtures/hook/callbacks.php');
+
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php',
+ 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ \test_plugin\callbacks::$calls = [];
+ $hook = new \test_plugin\hook\hook();
+ $result = $testmanager->dispatch($hook);
+ $this->assertSame($hook, $result);
+ $this->assertSame(['test2', 'test1'], \test_plugin\callbacks::$calls);
+ \test_plugin\callbacks::$calls = [];
+ $this->assertDebuggingNotCalled();
+ }
+
+ /**
+ * Test hook dispatching, that is callback execution.
+ * @covers ::dispatch
+ */
+ public function test_dispatch_with_exception(): void {
+ require_once(__DIR__ . '/../fixtures/hook/hook.php');
+ require_once(__DIR__ . '/../fixtures/hook/callbacks.php');
+
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_exception.php',
+ 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+
+ $hook = new \test_plugin\hook\hook();
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('grrr');
+
+ $testmanager->dispatch($hook);
+ }
+
+ /**
+ * Test hook dispatching, that is callback execution.
+ * @covers ::dispatch
+ */
+ public function test_dispatch_with_invalid(): void {
+ // Missing callbacks is ignored.
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_missing.php',
+ 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ \test_plugin\callbacks::$calls = [];
+
+ $hook = new \test_plugin\hook\hook();
+
+ $testmanager->dispatch($hook);
+ $this->assertDebuggingCalled(
+ "Hook callback definition contains invalid 'callback' method name in 'test_plugin1'. Callback method not found.",
+ );
+ $this->assertSame(['test2'], \test_plugin\callbacks::$calls);
+ }
+
+ /**
+ * Test stoppping of hook dispatching.
+ * @covers ::dispatch
+ */
+ public function test_dispatch_stoppable() {
+ require_once(__DIR__ . '/../fixtures/hook/stoppablehook.php');
+ require_once(__DIR__ . '/../fixtures/hook/callbacks.php');
+
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_stoppable.php',
+ 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_stoppable.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ \test_plugin\callbacks::$calls = [];
+ $hook = new \test_plugin\hook\stoppablehook();
+ $result = $testmanager->dispatch($hook);
+ $this->assertSame($hook, $result);
+ $this->assertSame(['stop1'], \test_plugin\callbacks::$calls);
+ \test_plugin\callbacks::$calls = [];
+ $this->assertDebuggingNotCalled();
+ }
+
+ /**
+ * Test deprecated callback lookup.
+ * @covers ::is_deprecated_plugin_callback
+ */
+ public function testy_is_deprecated_plugin_callback() {
+ require_once(__DIR__ . '/../fixtures/hook/hook.php');
+
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertTrue($testmanager->is_deprecated_plugin_callback('oldcallback'));
+ $this->assertFalse($testmanager->is_deprecated_plugin_callback('legacycallback'));
+ }
+
+ /**
+ * Test detection of legacy callbacks.
+ * @covers ::is_deprecating_hook_present
+ */
+ public function testy_is_deprecating_hook_present() {
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php',
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ // There is not much to test because there should not be any legacy callbacks left,
+ // plugin that have these should test their code.
+ $this->assertFalse($testmanager->is_deprecating_hook_present('test_pluing', 'xyz'));
+ }
+
+ /**
+ * Tests callbacks can be overridden via CFG settings.
+ * @covers ::load_callbacks
+ * @covers ::dispatch
+ */
+ public function test_callback_overriding() {
+ global $CFG;
+ $this->resetAfterTest();
+
+ $componentfiles = [
+ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php',
+ 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php',
+ ];
+
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks());
+ $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook');
+ $this->assertCount(2, $callbacks);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test2',
+ 'component' => 'test_plugin2',
+ 'disabled' => false,
+ 'priority' => 200,
+ ], $callbacks[0]);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test1',
+ 'component' => 'test_plugin1',
+ 'disabled' => false,
+ 'priority' => 100,
+ ], $callbacks[1]);
+
+ $CFG->hooks_callback_overrides = [
+ 'test_plugin\\hook\\hook' => [
+ 'test_plugin\\callbacks::test2' => ['priority' => 33]
+ ]
+ ];
+
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks());
+ $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook');
+ $this->assertCount(2, $callbacks);
+ $this->normalise_callbacks($callbacks);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test1',
+ 'component' => 'test_plugin1',
+ 'disabled' => false,
+ 'priority' => 100,
+ ], $callbacks[0]);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test2',
+ 'component' => 'test_plugin2',
+ 'defaultpriority' => 200,
+ 'disabled' => false,
+ 'priority' => 33,
+ ], $callbacks[1]);
+
+ $CFG->hooks_callback_overrides = [
+ 'test_plugin\\hook\\hook' => [
+ 'test_plugin\\callbacks::test2' => ['priority' => 33, 'disabled' => true]
+ ]
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks());
+ $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook');
+ $this->assertCount(2, $callbacks);
+ $this->normalise_callbacks($callbacks);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test1',
+ 'component' => 'test_plugin1',
+ 'disabled' => false,
+ 'priority' => 100,
+ ],
+ $callbacks[0]);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test2',
+ 'component' => 'test_plugin2',
+ 'defaultpriority' => 200,
+ 'disabled' => true,
+ 'priority' => 33,
+ ], $callbacks[1]);
+
+ $CFG->hooks_callback_overrides = [
+ 'test_plugin\\hook\\hook' => [
+ 'test_plugin\\callbacks::test2' => ['disabled' => true],
+ ]
+ ];
+ $testmanager = manager::phpunit_get_instance($componentfiles);
+ $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks());
+ $callbacks = $testmanager->get_callbacks_for_hook('test_plugin\\hook\\hook');
+ $this->assertCount(2, $callbacks);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test2',
+ 'component' => 'test_plugin2',
+ 'disabled' => true,
+ 'priority' => 200,
+ ], $callbacks[0]);
+ $this->assertSame([
+ 'callback' => 'test_plugin\\callbacks::test1',
+ 'component' => 'test_plugin1',
+ 'disabled' => false,
+ 'priority' => 100,
+ ], $callbacks[1]);
+
+ require_once(__DIR__ . '/../fixtures/hook/hook.php');
+ require_once(__DIR__ . '/../fixtures/hook/callbacks.php');
+
+ \test_plugin\callbacks::$calls = [];
+ $hook = new \test_plugin\hook\hook();
+ $result = $testmanager->dispatch($hook);
+ $this->assertSame($hook, $result);
+ $this->assertSame(['test1'], \test_plugin\callbacks::$calls);
+ \test_plugin\callbacks::$calls = [];
+ $this->assertDebuggingNotCalled();
+ }
+
+ /**
+ * Normalise the sort order of callbacks to help with asserts.
+ *
+ * @param array $callbacks
+ * @return void
+ */
+ private function normalise_callbacks(array &$callbacks): void {
+ foreach ($callbacks as &$callback) {
+ ksort($callback);
+ }
+ }
+}
diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml
index 14e5eaf0865..625bcbf251d 100644
--- a/lib/thirdpartylibs.xml
+++ b/lib/thirdpartylibs.xml
@@ -629,6 +629,14 @@ All rights reserved.
MIT
https://github.com/php-fig/http-message
+
+ psr/event-dispatcher
+ event-dispatcher
+ Provides interfaces that descirbe an event dispatching mechanism.
+ 1.0.0
+ MIT
+ https://github.com/php-fig/event-dispatcher
+
phpxmlrpc
phpxmlrpc