From 07a1dc04f8942c6b33b11bf9a0e343ffb0ecace4 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 19 May 2023 08:34:01 +0800 Subject: [PATCH] MDL-74954 core: Add hook discovery feature --- admin/classes/table/hook_list_table.php | 274 ++++++++++++++++++++++++ admin/hooks.php | 79 +------ lang/en/admin.php | 13 +- lib/classes/hook/discovery_agent.php | 33 +++ lib/classes/hook/manager.php | 47 ++-- lib/classes/hooks.php | 67 ++++++ 6 files changed, 403 insertions(+), 110 deletions(-) create mode 100644 admin/classes/table/hook_list_table.php create mode 100644 lib/classes/hook/discovery_agent.php create mode 100644 lib/classes/hooks.php diff --git a/admin/classes/table/hook_list_table.php b/admin/classes/table/hook_list_table.php new file mode 100644 index 00000000000..2dd6966455d --- /dev/null +++ b/admin/classes/table/hook_list_table.php @@ -0,0 +1,274 @@ +. + +namespace core_admin\table; + +use core_plugin_manager; +use flexible_table; +use html_writer; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); +require_once("{$CFG->libdir}/tablelib.php"); + +/** + * Plugin Management table. + * + * @package core_admin + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_list_table extends flexible_table { + + /** @var \core\plugininfo\base[] The plugin list */ + protected array $plugins = []; + + /** @var int The number of enabled plugins of this type */ + protected int $enabledplugincount = 0; + + /** @var core_plugin_manager */ + protected core_plugin_manager $pluginmanager; + + /** @var string The plugininfo class for this plugintype */ + protected string $plugininfoclass; + + /** @var stdClass[] The list of emitted hooks with metadata */ + protected array $emitters; + + public function __construct() { + global $CFG; + + $this->define_baseurl('/admin/hooks.php'); + parent::__construct('core_admin-hook_list_table'); + + // Add emitted hooks. + $this->emitters = \core\hook\manager::discover_known_hooks(); + + $this->setup_column_configuration(); + $this->setup(); + } + + /** + * Set up the column configuration for this table. + */ + protected function setup_column_configuration(): void { + $columnlist = [ + 'details' => get_string('hookname', 'core_admin'), + 'callbacks' => get_string('hookcallbacks', 'core_admin'), + 'deprecates' => get_string('hookdeprecates', 'core_admin'), + ]; + $this->define_columns(array_keys($columnlist)); + $this->define_headers(array_values($columnlist)); + + $columnswithhelp = [ + 'callbacks' => new \help_icon('hookcallbacks', 'admin'), + ]; + $columnhelp = array_map(function (string $column) use ($columnswithhelp): ?\renderable { + if (array_key_exists($column, $columnswithhelp)) { + return $columnswithhelp[$column]; + } + + return null; + }, array_keys($columnlist)); + $this->define_help_for_headers($columnhelp); + } + + /** + * Print the table. + */ + public function out(): void { + // All hook consumers referenced from the db/hooks.php files. + $hookmanager = \core\hook\manager::get_instance(); + $allhooks = $hookmanager->get_all_callbacks(); + + // Add any unused hooks. + foreach (array_keys($this->emitters) as $classname) { + if (isset($allhooks[$classname])) { + continue; + } + $allhooks[$classname] = []; + } + + foreach ($allhooks as $classname => $consumers) { + $this->add_data_keyed( + $this->format_row((object) [ + 'classname' => $classname, + 'callbacks' => $consumers, + ]), + $this->get_row_class($classname), + ); + } + + $this->finish_output(false); + } + + protected function col_details(stdClass $row): string { + return $row->classname . + $this->get_description($row) . + html_writer::div($this->get_tags_for_row($row)); + } + + /** + * Show the name column content. + * + * @param stdClass $row + * @return string + */ + protected function get_description(stdClass $row): string { + if (!array_key_exists($row->classname, $this->emitters)) { + return ''; + } + + return html_writer::tag( + 'small', + clean_text(markdown_to_html($this->emitters[$row->classname]['description']), FORMAT_HTML), + ); + } + + protected function col_deprecates(stdClass $row): string { + if (!class_exists($row->classname)) { + return ''; + } + + $rc = new \ReflectionClass($row->classname); + if (!$rc->implementsInterface(\core\hook\deprecated_callback_replacement::class)) { + return ''; + } + $deprecates = call_user_func([$row->classname, 'get_deprecated_plugin_callbacks']); + if (count($deprecates) === 0) { + return ''; + } + $content = html_writer::start_tag('ul'); + + foreach ($deprecates as $deprecatedmethod) { + $content .= html_writer::tag('li', $deprecatedmethod); + } + $content .= html_writer::end_tag('ul'); + return $content; + } + + protected function col_callbacks(stdClass $row): string { + global $CFG; + + $hookclass = $row->classname; + $cbinfo = []; + foreach ($row->callbacks as $definition) { + $iscallable = is_callable($definition['callback'], false, $callbackname); + $isoverridden = isset($CFG->hooks_callback_overrides[$hookclass][$definition['callback']]); + $info = "{$callbackname} ({$definition['priority']})"; + if (!$iscallable) { + $info .= ' '; + $info .= $this->get_tag( + get_string('error'), + 'danger', + get_string('hookcallbacknotcallable', 'core_admin', $callbackname), + ); + } + if ($isoverridden) { + // The lang string meaning should be close enough here. + $info .= $this->get_tag( + get_string('hookconfigoverride', 'core_admin'), + 'warning', + get_string('hookconfigoverride_help', 'core_admin'), + ); + } + + $cbinfo[] = $info; + } + + if ($cbinfo) { + $output = html_writer::start_tag('ol'); + foreach ($cbinfo as $callback) { + $class = ''; + if ($definition['disabled']) { + $class = 'dimmed_text'; + } + $output .= html_writer::tag('li', $callback, ['class' => $class]); + } + $output .= html_writer::end_tag('ol'); + return $output; + } else { + return ''; + } + } + + /** + * Get the HTML to display the badge with tooltip. + * + * @param string $tag The main text to display + * @param null|string $type The pill type + * @param null|string $tooltip The content of the tooltip + * @return string + */ + protected function get_tag( + string $tag, + ?string $type = null, + ?string $tooltip = null, + ): string { + $attributes = []; + + if ($type === null) { + $type = 'info'; + } + + if ($tooltip) { + $attributes['data-toggle'] = 'tooltip'; + $attributes['title'] = $tooltip; + } + return html_writer::span($tag, "badge badge-{$type}", $attributes); + } + + /** + * Get the code to display a set of tags for this table row. + * + * @param stdClass $row + * @return string + */ + protected function get_tags_for_row(stdClass $row): string { + if (!array_key_exists($row->classname, $this->emitters)) { + // This hook has been defined in the db/hooks.php file + // but does not refer to a hook in this version of Moodle. + return $this->get_tag( + get_string('hookunknown', 'core_admin'), + 'warning', + get_string('hookunknown_desc', 'core_admin'), + ); + } + + if (!class_exists($row->classname)) { + // This hook has been defined in a hook discovery agent, but the class it refers to could not be found. + return $this->get_tag( + get_string('hookclassmissing', 'core_admin'), + 'warning', + get_string('hookclassmissing_desc', 'core_admin'), + ); + } + + $tags = $this->emitters[$row->classname]['tags'] ?? []; + $taglist = array_map(function($tag): string { + if (is_array($tag)) { + return $this->get_tag(...$tag); + } + return $this->get_tag($tag, 'badge badge-info'); + }, $tags); + + return implode("\n", $taglist); + } + + protected function get_row_class(string $classname): string { + return ''; + } +} diff --git a/admin/hooks.php b/admin/hooks.php index d1e25555c7a..e132b65525d 100644 --- a/admin/hooks.php +++ b/admin/hooks.php @@ -35,82 +35,7 @@ $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 = ''; - } - } - - $table->data[] = new html_table_row([$hookclass, $cbinfo, $description, $deprecates]); -} - -echo html_writer::table($table); +$table = new \core_admin\table\hook_list_table(); +$table->out(); echo $OUTPUT->footer(); diff --git a/lang/en/admin.php b/lang/en/admin.php index 912108fe866..437139f8f35 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -723,10 +723,21 @@ $string['hidefromall'] = 'Hide from all users'; $string['hidefromnone'] = 'Hide from nobody'; $string['hidefromstudents'] = 'Hide from students'; $string['hookcallbacks'] = 'Callbacks'; +$string['hookcallbacks_help'] = 'The list of callbacks which will be called when the hook is dispatched. + +The order shown is the order in which callbacks are called. + +A callback with a higher priority will be called before one with lower priority.'; +$string['hookcallbacknotcallable'] = 'This callback is not callable. This could be because the class or method does not exist, or because the method is not public.'; +$string['hookconfigoverride'] = 'Overridden'; +$string['hookconfigoverride_help'] = 'The definition of this callback has been overridden in the site configuration file, config.php'; $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['hookclassmissing'] = 'Hook class not found'; +$string['hookclassmissing_desc'] = 'The hook discovery agent has returned a class that does not exist.'; +$string['hookunknown'] = 'Hook not found'; +$string['hookunknown_desc'] = 'The object that this callback listens to is not available. It may have been removed or renamed, or it may not be available in this version of Moodle.'; $string['hookname'] = 'Hook'; $string['hooksoverview'] = 'Hooks overview'; $string['hostname'] = 'Host name'; diff --git a/lib/classes/hook/discovery_agent.php b/lib/classes/hook/discovery_agent.php new file mode 100644 index 00000000000..3cbdb6d2ae2 --- /dev/null +++ b/lib/classes/hook/discovery_agent.php @@ -0,0 +1,33 @@ +. + +namespace core\hook; + +/** + * This interface describes a component which can discover hooks in its own namespace. + * + * @package core + * @copyright Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface discovery_agent { + /** + * Discover hooks belonging to the component. + * + * @return array + */ + public static function discover_hooks(): array; +} diff --git a/lib/classes/hook/manager.php b/lib/classes/hook/manager.php index 1e27b8fe567..3b0c1666db8 100644 --- a/lib/classes/hook/manager.php +++ b/lib/classes/hook/manager.php @@ -129,7 +129,7 @@ final class manager implements } /** - * Returns list of all callbacks found in deb/hooks.php files. + * Returns list of all callbacks found in db/hooks.php files. * * @return iterable */ @@ -412,7 +412,10 @@ final class manager implements $candidates = self::discover_known_hooks(); /** @var class-string $hookclassname */ - foreach ($candidates as $hookclassname) { + foreach (array_keys($candidates) as $hookclassname) { + if (!class_exists($hookclassname)) { + continue; + } // 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); @@ -560,42 +563,22 @@ final class manager implements * * @return array hook class names */ - public function discover_known_hooks(): array { - $hooks = []; + public static function discover_known_hooks(): array { + $hooks = \core\hooks::discover_hooks(); + + foreach (\core_component::get_component_names() as $component) { + $classname = "{$component}\\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; + $rc = new \ReflectionClass($classname); + if (!$rc->implementsInterface(\core\hook\hook_discover_agent::class)) { + continue; } + + $hooks = array_merge($hooks, $classname::discover_hooks()); } return $hooks; diff --git a/lib/classes/hooks.php b/lib/classes/hooks.php new file mode 100644 index 00000000000..5edb4846fa9 --- /dev/null +++ b/lib/classes/hooks.php @@ -0,0 +1,67 @@ +. + +namespace core; + +/** + * Hook discovery agent for core. + * + * @package core + * @copyright Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hooks implements \core\hook\discovery_agent { + public static function discover_hooks(): array { + // Describe any hard-coded hooks which can't be easily discovered by namespace. + $hooks = []; + + $hooks = array_merge($hooks, self::discover_hooks_in_namespace('core', 'hook')); + + return $hooks; + } + + public static function discover_hooks_in_namespace(string $component, string $namespace): array { + $classes = \core_component::get_component_classes_in_namespace($component, $namespace); + + $hooks = []; + foreach (array_keys($classes) as $classname) { + $rc = new \ReflectionClass($classname); + if ($rc->isAbstract()) { + // Skip abstract classes. + continue; + } + + if (is_a($classname, \core\hook\manager::class, true)) { + // Skip the manager. + continue; + } + + $hooks[$classname] = [ + 'class' => $classname, + 'description' => '', + 'tags' => [], + ]; + + if ($rc->implementsInterface(\core\hook\described_hook::class)) { + $hooks[$classname]['description'] = $classname::get_hook_description(); + } + + + } + + return $hooks; + } +} -- 2.43.0