MDL-39854 reimplement Frankenstyle support and enable classloader
[moodle.git] / lib / classes / component.php
CommitLineData
9e19a0f0
PS
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Components (core subsystems + plugins) related code.
19 *
20 * @package core
21 * @copyright 2013 Petr Skoda {@link http://skodak.org}
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25/**
26 * Collection of components related methods.
27 */
28class core_component {
29 /** @var array list of ignored directories - watch out for auth/db exception */
30 protected static $ignoreddirs = array('CVS'=>true, '_vti_cnf'=>true, 'simpletest'=>true, 'db'=>true, 'yui'=>true, 'tests'=>true, 'classes'=>true);
31 /** @var array list plugin types that support subplugins, do not add more here unless absolutely necessary */
32 protected static $supportsubplugins = array('mod', 'editor');
33
34 /** @var null cache of plugin types */
35 protected static $plugintypes = null;
36 /** @var null cache of plugin locations */
37 protected static $plugins = null;
38 /** @var null cache of core subsystems */
39 protected static $subsystems = null;
40 /** @var null list of all known classes that can be autoloaded */
41 protected static $classmap = null;
42
43 /**
44 * Class loader for Frankenstyle named classes in standard locations.
45 * Frankenstyle namespaces are supported.
46 *
47 * The expected location for core classes is:
48 * 1/ core_xx_yy_zz ---> lib/classes/xx_yy_zz.php
49 * 2/ \core\xx_yy_zz ---> lib/classes/xx_yy_zz.php
50 * 3/ \core\xx\yy_zz ---> lib/classes/xx/yy_zz.php
51 *
52 * The expected location for plugin classes is:
53 * 1/ mod_name_xx_yy_zz ---> mod/name/classes/xx_yy_zz.php
54 * 2/ \mod_name\xx_yy_zz ---> mod/name/classes/xx_yy_zz.php
55 * 3/ \mod_name\xx\yy_zz ---> mod/name/classes/xx/yy_zz.php
56 *
57 * @param string $classname
58 */
59 public static function classloader($classname) {
60 self::init();
61
62 if (isset(self::$classmap[$classname])) {
63 // Global $CFG is expected in included scripts.
64 global $CFG;
65 // Function include would be faster, but for BC it is better to include only once.
66 include_once(self::$classmap[$classname]);
67 return;
68 }
69 }
70
71 /**
72 * Initialise caches, always call before accessing self:: caches.
73 */
74 protected static function init() {
75 global $CFG;
76
77 // Init only once per request/CLI execution, we ignore changes done afterwards.
78 if (isset(self::$plugintypes)) {
79 return;
80 }
81
82 if (PHPUNIT_TEST or !empty($CFG->early_install_lang)) {
83 // 1/ Do not bother storing the file for unit tests,
84 // we need fresh copy for each execution and
85 // later we keep it in memory.
86 // 2/ We can not write to dataroot in installer yet.
87 self::fill_all_caches();
88 return;
89 }
90
91 // Note: cachedir MUST be shared by all servers in a cluster, sorry guys...
92 // MUC should use classloading, we can not depend on it here.
93 $cachefile = "$CFG->cachedir/core_component.php";
94
95 if (!CACHE_DISABLE_ALL and !self::is_developer()) {
96 // 1/ Use the cache only outside of install and upgrade.
97 // 2/ Let developers add/remove classes in developer mode.
98 if (is_readable($cachefile)) {
99 $cache = false;
100 include($cachefile);
101 if (!is_array($cache)) {
102 // Something is very wrong.
103 } else if (!isset($cache['plugintypes']) or !isset($cache['plugins']) or !isset($cache['subsystems']) or !isset($cache['classmap'])) {
104 // Something is very wrong.
105 } else if ($cache['plugintypes']['mod'] !== "$CFG->dirroot/mod") {
106 // Dirroot was changed.
107 } else {
108 // The cache looks ok, let's use it.
109 self::$plugintypes = $cache['plugintypes'];
110 self::$plugins = $cache['plugins'];
111 self::$subsystems = $cache['subsystems'];
112 self::$classmap = $cache['classmap'];
113 return;
114 }
115 }
116 }
117
118 $cachedir = dirname($cachefile);
119 if (!is_dir($cachedir)) {
120 mkdir($cachedir, $CFG->directorypermissions, true);
121 }
122
123 if (!isset(self::$plugintypes)) {
124 self::fill_all_caches();
125
126 // This needs to be atomic and self-fixing as much as possible.
127
128 $content = self::get_cache_content();
129 if (file_exists($cachefile)) {
130 if (sha1_file($cachefile) === sha1($content)) {
131 return;
132 }
133 unlink($cachefile);
134 }
135
136 if ($fp = @fopen($cachefile.'.tmp', 'xb')) {
137 fwrite($fp, $content);
138 fclose($fp);
139 @rename($cachefile.'.tmp', $cachefile);
140 @chmod($cachefile, $CFG->filepermissions);
141 }
142 @unlink($cachefile.'.tmp'); // Just in case anything fails (race condition).
143 }
144 }
145
146 /**
147 * Are we in developer debug mode?
148 *
149 * Note: You need to set "$CFG->debug = (E_ALL | E_STRICT);" in config.php,
150 * the reason is we need to use this before we setup DB connection or caches for CFG.
151 *
152 * @return bool
153 */
154 protected static function is_developer() {
155 global $CFG;
156
157 if (!isset($CFG->config_php_settings['debug'])) {
158 return false;
159 }
160
161 $debug = (int)$CFG->config_php_settings['debug'];
162 if ($debug & E_ALL and $debug & E_STRICT) {
163 return true;
164 }
165
166 return false;
167 }
168
169 /**
170 * Create cache file content.
171 *
172 * @return string
173 */
174 protected static function get_cache_content() {
175 $cache = array(
176 'subsystems' => var_export(self::$subsystems, true),
177 'plugintypes' => var_export(self::$plugintypes, true),
178 'plugins' => var_export(self::$plugins, true),
179 'classmap' => var_export(self::$classmap, true),
180 );
181
182 return '<?php
183$cache = '.var_export($cache, true).';
184';
185 }
186
187 /**
188 * Fill all caches.
189 */
190 protected static function fill_all_caches() {
191 self::$subsystems = self::fetch_subsystems();
192
193 self::$plugintypes = self::fetch_plugintypes();
194
195 self::$plugins = array();
196 foreach (self::$plugintypes as $type => $fulldir) {
197 self::$plugins[$type] = self::fetch_plugins($type, $fulldir);
198 }
199
200 self::fill_classmap_cache();
201 }
202
203 /**
204 * Returns list of core subsystems.
205 * @return array
206 */
207 protected static function fetch_subsystems() {
208 global $CFG;
209
210 // NOTE: Any additions here must be verified to not collide with existing add-on modules and subplugins!!!
211
212 $info = array(
213 'access' => null,
214 'admin' => $CFG->dirroot.'/'.$CFG->admin,
215 'auth' => $CFG->dirroot.'/auth',
216 'backup' => $CFG->dirroot.'/backup/util/ui',
217 'badges' => $CFG->dirroot.'/badges',
218 'block' => $CFG->dirroot.'/blocks',
219 'blog' => $CFG->dirroot.'/blog',
220 'bulkusers' => null,
221 'cache' => $CFG->dirroot.'/cache',
222 'calendar' => $CFG->dirroot.'/calendar',
223 'cohort' => $CFG->dirroot.'/cohort',
224 'condition' => null,
225 'completion' => null,
226 'countries' => null,
227 'course' => $CFG->dirroot.'/course',
228 'currencies' => null,
229 'dbtransfer' => null,
230 'debug' => null,
231 'dock' => null,
232 'editor' => $CFG->dirroot.'/lib/editor',
233 'edufields' => null,
234 'enrol' => $CFG->dirroot.'/enrol',
235 'error' => null,
236 'filepicker' => null,
237 'files' => $CFG->dirroot.'/files',
238 'filters' => null,
239 //'fonts' => null, // Bogus.
240 'form' => $CFG->dirroot.'/lib/form',
241 'grades' => $CFG->dirroot.'/grade',
242 'grading' => $CFG->dirroot.'/grade/grading',
243 'group' => $CFG->dirroot.'/group',
244 'help' => null,
245 'hub' => null,
246 'imscc' => null,
247 'install' => null,
248 'iso6392' => null,
249 'langconfig' => null,
250 'license' => null,
251 'mathslib' => null,
252 'media' => null,
253 'message' => $CFG->dirroot.'/message',
254 'mimetypes' => null,
255 'mnet' => $CFG->dirroot.'/mnet',
256 //'moodle.org' => null, // Not used any more.
257 'my' => $CFG->dirroot.'/my',
258 'notes' => $CFG->dirroot.'/notes',
259 'pagetype' => null,
260 'pix' => null,
261 'plagiarism' => $CFG->dirroot.'/plagiarism',
262 'plugin' => null,
263 'portfolio' => $CFG->dirroot.'/portfolio',
264 'publish' => $CFG->dirroot.'/course/publish',
265 'question' => $CFG->dirroot.'/question',
266 'rating' => $CFG->dirroot.'/rating',
267 'register' => $CFG->dirroot.'/'.$CFG->admin.'/registration', // Broken badly if $CFG->admin changed.
268 'repository' => $CFG->dirroot.'/repository',
269 'rss' => $CFG->dirroot.'/rss',
270 'role' => $CFG->dirroot.'/'.$CFG->admin.'/roles',
271 'search' => null,
272 'table' => null,
273 'tag' => $CFG->dirroot.'/tag',
274 'timezones' => null,
275 'user' => $CFG->dirroot.'/user',
276 'userkey' => null,
277 'webservice' => $CFG->dirroot.'/webservice',
278 );
279
280 return $info;
281 }
282
283 /**
284 * Returns list of known plugin types.
285 * @return array
286 */
287 protected static function fetch_plugintypes() {
288 global $CFG;
289
290 $types = array(
291 'qtype' => $CFG->dirroot.'/question/type',
292 'mod' => $CFG->dirroot.'/mod',
293 'auth' => $CFG->dirroot.'/auth',
294 'enrol' => $CFG->dirroot.'/enrol',
295 'message' => $CFG->dirroot.'/message/output',
296 'block' => $CFG->dirroot.'/blocks',
297 'filter' => $CFG->dirroot.'/filter',
298 'editor' => $CFG->dirroot.'/lib/editor',
299 'format' => $CFG->dirroot.'/course/format',
300 'profilefield' => $CFG->dirroot.'/user/profile/field',
301 'report' => $CFG->dirroot.'/report',
302 'coursereport' => $CFG->dirroot.'/course/report', // Must be after system reports.
303 'gradeexport' => $CFG->dirroot.'/grade/export',
304 'gradeimport' => $CFG->dirroot.'/grade/import',
305 'gradereport' => $CFG->dirroot.'/grade/report',
306 'gradingform' => $CFG->dirroot.'/grade/grading/form',
307 'mnetservice' => $CFG->dirroot.'/mnet/service',
308 'webservice' => $CFG->dirroot.'/webservice',
309 'repository' => $CFG->dirroot.'/repository',
310 'portfolio' => $CFG->dirroot.'/portfolio',
311 'qbehaviour' => $CFG->dirroot.'/question/behaviour',
312 'qformat' => $CFG->dirroot.'/question/format',
313 'plagiarism' => $CFG->dirroot.'/plagiarism',
314 'tool' => $CFG->dirroot.'/'.$CFG->admin.'/tool',
315 'cachestore' => $CFG->dirroot.'/cache/stores',
316 'cachelock' => $CFG->dirroot.'/cache/locks',
317
318 );
319
320 if (!empty($CFG->themedir) and is_dir($CFG->themedir) ) {
321 $types['theme'] = $CFG->themedir;
322 } else {
323 $types['theme'] = $CFG->dirroot.'/theme';
324 }
325
326 foreach (self::$supportsubplugins as $type) {
327 $subpluginowners = self::fetch_plugins($type, $types[$type]);
328 foreach ($subpluginowners as $ownerdir) {
329 if (file_exists("$ownerdir/db/subplugins.php")) {
330 $subplugins = array();
331 include("$ownerdir/db/subplugins.php");
332 foreach ($subplugins as $subtype => $dir) {
333 if (!preg_match('/^[a-z][a-z0-9]*$/', $subtype)) {
334 error_log("Invalid subtype '$subtype'' detected in '$ownerdir', invalid characters present.");
335 continue;
336 }
337 if (isset(self::$subsystems[$subtype])) {
338 error_log("Invalid subtype '$subtype'' detected in '$ownerdir', duplicates core subsystem.");
339 continue;
340 }
341 $types[$subtype] = $CFG->dirroot.'/'.$dir;
342 }
343 }
344 }
345 }
346
347 // Local is always last!
348 $types['local'] = $CFG->dirroot.'/local';
349
350 return $types;
351 }
352
353 /**
354 * Returns list of plugins of given type in given directory.
355 * @param string $plugintype
356 * @param string $fulldir
357 * @return array
358 */
359 protected static function fetch_plugins($plugintype, $fulldir) {
360 global $CFG;
361
362 $fulldirs = (array)$fulldir;
363 if ($plugintype === 'theme') {
364 if (realpath($fulldir) !== realpath($CFG->dirroot.'/theme')) {
365 // Include themes in standard location too.
366 array_unshift($fulldirs, $CFG->dirroot.'/theme');
367 }
368 }
369
370 $result = array();
371
372 foreach ($fulldirs as $fulldir) {
373 if (!is_dir($fulldir)) {
374 continue;
375 }
376 $items = new \DirectoryIterator($fulldir);
377 foreach ($items as $item) {
378 if ($item->isDot() or !$item->isDir()) {
379 continue;
380 }
381 $pluginname = $item->getFilename();
382 if ($plugintype === 'auth' and $pluginname === 'db') {
383 // Special exception for this wrong plugin name.
384 } else if (isset(self::$ignoreddirs[$pluginname])) {
385 continue;
386 }
387 if (!self::is_valid_plugin_name($plugintype, $pluginname)) {
388 // Always ignore plugins with problematic names here.
389 continue;
390 }
391 $result[$pluginname] = $fulldir.'/'.$pluginname;
392 unset($item);
393 }
394 unset($items);
395 }
396
397 ksort($result);
398 return $result;
399 }
400
401 /**
402 * Find all classes that can be autoloaded including frankenstyle namespaces.
403 */
404 protected static function fill_classmap_cache() {
405 global $CFG;
406
407 self::$classmap = array();
408
409 self::load_classes('core', "$CFG->dirroot/lib/classes");
410
411 foreach (self::$subsystems as $subsystem => $fulldir) {
412 self::load_classes('core_'.$subsystem, "$fulldir/classes");
413 }
414
415 foreach (self::$plugins as $plugintype => $plugins) {
416 foreach ($plugins as $pluginname => $fulldir) {
417 self::load_classes($plugintype.'_'.$pluginname, "$fulldir/classes");
418 }
419 }
420
421 // Note: Add a few extra legacy classes here if necessary.
422 //self::$classmap['textlib'] = "$CFG->dirroot/lib/textlib.class.php";
423 //self::$classmap['collatorlib'] = "$CFG->dirroot/lib/textlib.class.php";
424 }
425
426 /**
427 * Find classes in directory and recurse to subdirs.
428 * @param string $component
429 * @param string $fulldir
430 * @param string $namespace
431 */
432 protected static function load_classes($component, $fulldir, $namespace = '') {
433 if (!is_dir($fulldir)) {
434 return;
435 }
436
437 $items = new \DirectoryIterator($fulldir);
438 foreach ($items as $item) {
439 if ($item->isDot()) {
440 continue;
441 }
442 if ($item->isDir()) {
443 $dirname = $item->getFilename();
444 self::load_classes($component, "$fulldir/$dirname", $namespace.'\\'.$dirname);
445 continue;
446 }
447
448 $filename = $item->getFilename();
449 $classname = preg_replace('/\.php$/', '', $filename);
450
451 if ($filename === $classname) {
452 // Not a php file.
453 continue;
454 }
455 if ($namespace === '') {
456 // Legacy long frankenstyle class name.
457 self::$classmap[$component.'_'.$classname] = "$fulldir/$filename";
458 }
459 // New namespaced classes.
460 self::$classmap[$component.$namespace.'\\'.$classname] = "$fulldir/$filename";
461 }
462 unset($item);
463 unset($items);
464 }
465
466 /**
467 * List all core subsystems and their location
468 *
469 * This is a whitelist of components that are part of the core and their
470 * language strings are defined in /lang/en/<<subsystem>>.php. If a given
471 * plugin is not listed here and it does not have proper plugintype prefix,
472 * then it is considered as course activity module.
473 *
474 * The location is absolute file path to dir. NULL means there is no special
475 * directory for this subsystem. If the location is set, the subsystem's
476 * renderer.php is expected to be there.
477 *
478 * @return array of (string)name => (string|null)full dir location
479 */
480 public static function get_core_subsystems() {
481 self::init();
482 return self::$subsystems;
483 }
484
485 /**
486 * Get list of available plugin types together with their location.
487 *
488 * @return array as (string)plugintype => (string)fulldir
489 */
490 public static function get_plugin_types() {
491 self::init();
492 return self::$plugintypes;
493 }
494
495 /**
496 * Get list of plugins of given type.
497 *
498 * @param string $plugintype
499 * @return array as (string)pluginname => (string)fulldir
500 */
501 public static function get_plugin_list($plugintype) {
502 self::init();
503
504 if (!isset(self::$plugins[$plugintype])) {
505 return array();
506 }
507 return self::$plugins[$plugintype];
508 }
509
510 /**
511 * Get a list of all the plugins of a given type that define a certain class
512 * in a certain file. The plugin component names and class names are returned.
513 *
514 * @param string $plugintype the type of plugin, e.g. 'mod' or 'report'.
515 * @param string $class the part of the name of the class after the
516 * frankenstyle prefix. e.g 'thing' if you are looking for classes with
517 * names like report_courselist_thing. If you are looking for classes with
518 * the same name as the plugin name (e.g. qtype_multichoice) then pass ''.
519 * Frankenstyle namespaces are also supported.
520 * @param string $file the name of file within the plugin that defines the class.
521 * @return array with frankenstyle plugin names as keys (e.g. 'report_courselist', 'mod_forum')
522 * and the class names as values (e.g. 'report_courselist_thing', 'qtype_multichoice').
523 */
524 public static function get_plugin_list_with_class($plugintype, $class, $file = null) {
525 global $CFG; // Necessary in case it is referenced by included PHP scripts.
526
527 if ($class) {
528 $suffix = '_' . $class;
529 } else {
530 $suffix = '';
531 }
532
533 $pluginclasses = array();
534 $plugins = self::get_plugin_list($plugintype);
535 foreach ($plugins as $plugin => $fulldir) {
536 // Try class in frankenstyle namespace.
537 if ($class) {
538 $classname = '\\' . $plugintype . '_' . $plugin . '\\' . $class;
539 if (class_exists($classname, true)) {
540 $pluginclasses[$plugintype . '_' . $plugin] = $classname;
541 continue;
542 }
543 }
544
545 // Try autoloading of class with frankenstyle prefix.
546 $classname = $plugintype . '_' . $plugin . $suffix;
547 if (class_exists($classname, true)) {
548 $pluginclasses[$plugintype . '_' . $plugin] = $classname;
549 continue;
550 }
551
552 // Fall back to old file location and class name.
553 if ($file and file_exists("$fulldir/$file")) {
554 include_once("$fulldir/$file");
555 if (class_exists($classname, false)) {
556 $pluginclasses[$plugintype . '_' . $plugin] = $classname;
557 continue;
558 }
559 }
560 }
561
562 return $pluginclasses;
563 }
564
565 /**
566 * Returns the exact absolute path to plugin directory.
567 *
568 * @param string $plugintype type of plugin
569 * @param string $pluginname name of the plugin
570 * @return string full path to plugin directory; null if not found
571 */
572 public static function get_plugin_directory($plugintype, $pluginname) {
573 if (empty($pluginname)) {
574 // Invalid plugin name, sorry.
575 return null;
576 }
577
578 self::init();
579
580 if (!isset(self::$plugins[$plugintype][$pluginname])) {
581 return null;
582 }
583 return self::$plugins[$plugintype][$pluginname];
584 }
585
586 /**
587 * Returns the exact absolute path to plugin directory.
588 *
589 * @param string $subsystem type of core subsystem
590 * @return string full path to subsystem directory; null if not found
591 */
592 public static function get_subsystem_directory($subsystem) {
593 self::init();
594
595 if (!isset(self::$subsystems[$subsystem])) {
596 return null;
597 }
598 return self::$subsystems[$subsystem];
599 }
600
601 /**
602 * This method validates a plug name. It is much faster than calling clean_param.
603 *
604 * @param string $plugintype type of plugin
605 * @param string $pluginname a string that might be a plugin name.
606 * @return bool if this string is a valid plugin name.
607 */
608 public static function is_valid_plugin_name($plugintype, $pluginname) {
609 if ($plugintype === 'mod') {
610 // Modules must not have the same name as core subsystems.
611 if (!isset(self::$subsystems)) {
612 // Watch out, this is called from init!
613 self::init();
614 }
615 if (isset(self::$subsystems[$pluginname])) {
616 return false;
617 }
618 // Modules MUST NOT have any underscores,
619 // component normalisation would break very badly otherwise!
620 return (bool)preg_match('/^[a-z][a-z0-9]*$/', $pluginname);
621
622 } else {
623 return (bool)preg_match('/^[a-z](?:[a-z0-9_](?!__))*[a-z0-9]$/', $pluginname);
624 }
625 }
626
627 /**
628 * Normalize the component name using the "frankenstyle" rules.
629 *
630 * Note: this does not verify the validity of plugin or type names.
631 *
632 * @param string $component
633 * @return array as (string)$type => (string)$plugin
634 */
635 public static function normalize_component($component) {
636 if ($component === 'moodle' or $component === 'core' or $component === '') {
637 return array('core', null);
638 }
639
640 if (strpos($component, '_') === false) {
641 self::init();
642 if (array_key_exists($component, self::$subsystems)) {
643 $type = 'core';
644 $plugin = $component;
645 } else {
646 // Everything else without underscore is a module.
647 $type = 'mod';
648 $plugin = $component;
649 }
650
651 } else {
652 list($type, $plugin) = explode('_', $component, 2);
653 if ($type === 'moodle') {
654 $type = 'core';
655 }
656 // Any unknown type must be a subplugin.
657 }
658
659 return array($type, $plugin);
660 }
661
662 /**
663 * Return exact absolute path to a plugin directory.
664 *
665 * @param string $component name such as 'moodle', 'mod_forum'
666 * @return string full path to component directory; NULL if not found
667 */
668 public static function get_component_directory($component) {
669 global $CFG;
670
671 list($type, $plugin) = self::normalize_component($component);
672
673 if ($type === 'core') {
674 if ($plugin === null) {
675 return $path = $CFG->libdir;
676 }
677 return self::get_subsystem_directory($plugin);
678 }
679
680 return self::get_plugin_directory($type, $plugin);
681 }
682}