Newer
Older
catch
committed
<?php
namespace Drupal\Core\Extension;
use Drupal\Component\FileCache\FileCacheFactory;
use Drupal\Core\DrupalKernel;
catch
committed
use Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator;
Alex Pott
committed
use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\Request;
catch
committed
/**
* Discovers available extensions in the filesystem.
Alex Pott
committed
*
* To also discover test modules, add
* @code
* $settings['extension_discovery_scan_tests'] = TRUE;
* @endcode
Alex Pott
committed
* to your settings.php.
catch
committed
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
*/
class ExtensionDiscovery {
/**
* Origin directory weight: Core.
*/
const ORIGIN_CORE = 0;
/**
* Origin directory weight: Installation profile.
*/
const ORIGIN_PROFILE = 1;
/**
* Origin directory weight: sites/all.
*/
const ORIGIN_SITES_ALL = 2;
/**
* Origin directory weight: Site-wide directory.
*/
const ORIGIN_ROOT = 3;
/**
* Origin directory weight: Parent site directory of a test site environment.
*/
const ORIGIN_PARENT_SITE = 4;
/**
* Origin directory weight: Site-specific directory.
*/
const ORIGIN_SITE = 5;
/**
* Regular expression to match PHP function names.
*
* @see http://php.net/manual/functions.user-defined.php
*/
const PHP_FUNCTION_PATTERN = '/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/';
/**
* InfoParser instance for parsing .info.yml files.
*
* @var \Drupal\Core\Extension\InfoParser
*/
protected $infoParser;
/**
* Previously discovered files keyed by origin directory and extension type.
*
* @var array
*/
protected static $files = array();
/**
* List of installation profile directories to additionally scan.
*
* @var array
*/
protected $profileDirectories;
/**
* The app root for the current operation.
*
* @var string
*/
protected $root;
/**
* The file cache object.
*
* @var \Drupal\Component\FileCache\FileCacheInterface
*/
protected $fileCache;
/**
* The site path.
*
* @var string
*/
protected $sitePath;
/**
* Constructs a new ExtensionDiscovery object.
*
* @param string $root
* The app root.
* @param bool $use_file_cache
* Whether file cache should be used.
* @param string[] $profile_directories
* The available profile directories
* @param string $site_path
* The path to the site.
*/
public function __construct($root, $use_file_cache = TRUE, $profile_directories = NULL, $site_path = NULL) {
$this->root = $root;
$this->fileCache = $use_file_cache ? FileCacheFactory::get('extension_discovery') : NULL;
$this->profileDirectories = $profile_directories;
$this->sitePath = $site_path;
}
catch
committed
/**
* Discovers available extensions of a given type.
*
* Finds all extensions (modules, themes, etc) that exist on the site. It
* searches in several locations. For instance, to discover all available
* modules:
* @code
* $listing = new ExtensionDiscovery(\Drupal::root());
catch
committed
* $modules = $listing->scan('module');
* @endcode
*
* The following directories will be searched (in the order stated):
* - the core directory; i.e., /core
* - the installation profile directory; e.g., /core/profiles/standard
* - the legacy site-wide directory; i.e., /sites/all
* - the site-wide directory; i.e., /
* - the site-specific directory; e.g., /sites/example.com
*
Alex Pott
committed
* To also find test modules, add
* @code
* $settings['extension_discovery_scan_tests'] = TRUE;
Alex Pott
committed
* to your settings.php.
*
catch
committed
* The information is returned in an associative array, keyed by the extension
* name (without .info.yml extension). Extensions found later in the search
* will take precedence over extensions found earlier - unless they are not
* compatible with the current version of Drupal core.
*
* @param string $type
* The extension type to search for. One of 'profile', 'module', 'theme', or
* 'theme_engine'.
* @param bool $include_tests
* (optional) Whether to explicitly include or exclude test extensions. By
* default, test extensions are only discovered when in a test environment.
*
* @return \Drupal\Core\Extension\Extension[]
* An associative array of Extension objects, keyed by extension name.
*/
public function scan($type, $include_tests = NULL) {
// Determine the installation profile directories to scan for extensions,
// unless explicit profile directories have been set. Exclude profiles as we
// cannot have profiles within profiles.
if (!isset($this->profileDirectories) && $type != 'profile') {
catch
committed
$this->setProfileDirectoriesFromSettings();
}
// Search the core directory.
$searchdirs[static::ORIGIN_CORE] = 'core';
// Search the legacy sites/all directory.
$searchdirs[static::ORIGIN_SITES_ALL] = 'sites/all';
// Search for contributed and custom extensions in top-level directories.
// The scan uses a whitelist to limit recursion to the expected extension
// type specific directory names only.
$searchdirs[static::ORIGIN_ROOT] = '';
// Simpletest uses the regular built-in multi-site functionality of Drupal
// for running web tests. As a consequence, extensions of the parent site
// located in a different site-specific directory are not discovered in a
// test site environment, because the site directories are not the same.
// Therefore, add the site directory of the parent site to the search paths,
// so that contained extensions are still discovered.
// @see \Drupal\simpletest\WebTestBase::setUp()
if ($parent_site = Settings::get('test_parent_site')) {
catch
committed
$searchdirs[static::ORIGIN_PARENT_SITE] = $parent_site;
}
// Find the site-specific directory to search. Since we are using this
// method to discover extensions including profiles, we might be doing this
// at install time. Therefore Kernel service is not always available, but is
// preferred.
if (\Drupal::hasService('kernel')) {
$searchdirs[static::ORIGIN_SITE] = \Drupal::service('site.path');
}
else {
$searchdirs[static::ORIGIN_SITE] = $this->sitePath ?: DrupalKernel::findSitePath(Request::createFromGlobals());
}
catch
committed
// Unless an explicit value has been passed, manually check whether we are
// in a test environment, in which case test extensions must be included.
// Test extensions can also be included for debugging purposes by setting a
// variable in settings.php.
catch
committed
if (!isset($include_tests)) {
$include_tests = Settings::get('extension_discovery_scan_tests') || drupal_valid_test_ua();
catch
committed
}
$files = array();
foreach ($searchdirs as $dir) {
// Discover all extensions in the directory, unless we did already.
if (!isset(static::$files[$this->root][$dir][$include_tests])) {
static::$files[$this->root][$dir][$include_tests] = $this->scanDirectory($dir, $include_tests);
catch
committed
}
// Only return extensions of the requested type.
if (isset(static::$files[$this->root][$dir][$include_tests][$type])) {
$files += static::$files[$this->root][$dir][$include_tests][$type];
catch
committed
}
}
// If applicable, filter out extensions that do not belong to the current
catch
committed
// installation profiles.
$files = $this->filterByProfileDirectories($files);
// Sort the discovered extensions by their originating directories.
catch
committed
$origin_weights = array_flip($searchdirs);
$files = $this->sort($files, $origin_weights);
catch
committed
// Process and return the list of extensions keyed by extension name.
catch
committed
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
return $this->process($files);
}
/**
* Sets installation profile directories based on current site settings.
*
* @return $this
*/
public function setProfileDirectoriesFromSettings() {
$this->profileDirectories = array();
$profile = drupal_get_profile();
// For SimpleTest to be able to test modules packaged together with a
// distribution we need to include the profile of the parent site (in
// which test runs are triggered).
if (drupal_valid_test_ua() && !drupal_installation_attempted()) {
$testing_profile = \Drupal::config('simpletest.settings')->get('parent_profile');
if ($testing_profile && $testing_profile != $profile) {
$this->profileDirectories[] = drupal_get_path('profile', $testing_profile);
}
}
// In case both profile directories contain the same extension, the actual
// profile always has precedence.
if ($profile) {
$this->profileDirectories[] = drupal_get_path('profile', $profile);
}
return $this;
}
/**
* Gets the installation profile directories to be scanned.
*
* @return array
* A list of installation profile directory paths relative to the system
* root directory.
*/
public function getProfileDirectories() {
return $this->profileDirectories;
}
/**
* Sets explicit profile directories to scan.
*
* @param array $paths
* A list of installation profile directory paths relative to the system
* root directory (without trailing slash) to search for extensions.
*
* @return $this
*/
public function setProfileDirectories(array $paths = NULL) {
$this->profileDirectories = $paths;
return $this;
}
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
/**
* Filters out extensions not belonging to the scanned installation profiles.
*
* @param \Drupal\Core\Extension\Extension[] $all_files.
* The list of all extensions.
*
* @return \Drupal\Core\Extension\Extension[]
* The filtered list of extensions.
*/
protected function filterByProfileDirectories(array $all_files) {
if (empty($this->profileDirectories)) {
return $all_files;
}
$all_files = array_filter($all_files, function ($file) {
if (strpos($file->subpath, 'profiles') !== 0) {
// This extension doesn't belong to a profile, ignore it.
return TRUE;
}
foreach ($this->profileDirectories as $weight => $profile_path) {
if (strpos($file->getPath(), $profile_path) === 0) {
// Parent profile found.
return TRUE;
}
}
return FALSE;
});
return $all_files;
}
/**
* Sorts the discovered extensions.
*
* @param \Drupal\Core\Extension\Extension[] $all_files.
* The list of all extensions.
* @param array $weights
* An array of weights, keyed by originating directory.
*
* @return \Drupal\Core\Extension\Extension[]
* The sorted list of extensions.
*/
protected function sort(array $all_files, array $weights) {
$origins = array();
$profiles = array();
foreach ($all_files as $key => $file) {
// If the extension does not belong to a profile, just apply the weight
// of the originating directory.
if (strpos($file->subpath, 'profiles') !== 0) {
$origins[$key] = $weights[$file->origin];
$profiles[$key] = NULL;
}
// If the extension belongs to a profile but no profile directories are
// defined, then we are scanning for installation profiles themselves.
// In this case, profiles are sorted by origin only.
elseif (empty($this->profileDirectories)) {
$origins[$key] = static::ORIGIN_PROFILE;
$profiles[$key] = NULL;
}
else {
// Apply the weight of the originating profile directory.
foreach ($this->profileDirectories as $weight => $profile_path) {
if (strpos($file->getPath(), $profile_path) === 0) {
$origins[$key] = static::ORIGIN_PROFILE;
$profiles[$key] = $weight;
continue 2;
}
}
}
}
// Now sort the extensions by origin and installation profile(s).
// The result of this multisort can be depicted like the following matrix,
// whereas the first integer is the weight of the originating directory and
// the second is the weight of the originating installation profile:
// 0 core/modules/node/node.module
// 1 0 profiles/parent_profile/modules/parent_module/parent_module.module
// 1 1 core/profiles/testing/modules/compatible_test/compatible_test.module
// 2 sites/all/modules/common/common.module
// 3 modules/devel/devel.module
// 4 sites/default/modules/custom/custom.module
array_multisort($origins, SORT_ASC, $profiles, SORT_ASC, $all_files);
return $all_files;
}
catch
committed
/**
* Processes the filtered and sorted list of extensions.
*
* Extensions discovered in later search paths override earlier, unless they
* are not compatible with the current version of Drupal core.
*
* @param \Drupal\Core\Extension\Extension[] $all_files
* The sorted list of all extensions that were found.
*
* @return \Drupal\Core\Extension\Extension[]
* The filtered list of extensions, keyed by extension name.
*/
protected function process(array $all_files) {
$files = array();
// Duplicate files found in later search directories take precedence over
// earlier ones; they replace the extension in the existing $files array.
foreach ($all_files as $file) {
$files[$file->getName()] = $file;
catch
committed
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
}
return $files;
}
/**
* Recursively scans a base directory for the requested extension type.
*
* @param string $dir
* A relative base directory path to scan, without trailing slash.
* @param bool $include_tests
* Whether to include test extensions. If FALSE, all 'tests' directories are
* excluded in the search.
*
* @return array
* An associative array whose keys are extension type names and whose values
* are associative arrays of \Drupal\Core\Extension\Extension objects, keyed
* by absolute path name.
*
* @see \Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator
*/
protected function scanDirectory($dir, $include_tests) {
$files = array();
// In order to scan top-level directories, absolute directory paths have to
// be used (which also improves performance, since any configured PHP
// include_paths will not be consulted). Retain the relative originating
// directory being scanned, so relative paths can be reconstructed below
// (all paths are expected to be relative to $this->root).
catch
committed
$dir_prefix = ($dir == '' ? '' : "$dir/");
$absolute_dir = ($dir == '' ? $this->root : $this->root . "/$dir");
catch
committed
if (!is_dir($absolute_dir)) {
return $files;
}
// Use Unix paths regardless of platform, skip dot directories, follow
// symlinks (to allow extensions to be linked from elsewhere), and return
// the RecursiveDirectoryIterator instance to have access to getSubPath(),
// since SplFileInfo does not support relative paths.
$flags = \FilesystemIterator::UNIX_PATHS;
$flags |= \FilesystemIterator::SKIP_DOTS;
$flags |= \FilesystemIterator::FOLLOW_SYMLINKS;
$flags |= \FilesystemIterator::CURRENT_AS_SELF;
$directory_iterator = new \RecursiveDirectoryIterator($absolute_dir, $flags);
// Allow directories specified in settings.php to be ignored. You can use
// this to not check for files in common special-purpose directories. For
// example, node_modules and bower_components. Ignoring irrelevant
// directories is a performance boost.
$ignore_directories = Settings::get('file_scan_ignore_directories', []);
catch
committed
// Filter the recursive scan to discover extensions only.
// Important: Without a RecursiveFilterIterator, RecursiveDirectoryIterator
// would recurse into the entire filesystem directory tree without any kind
// of limitations.
$filter = new RecursiveExtensionFilterIterator($directory_iterator, $ignore_directories);
catch
committed
$filter->acceptTests($include_tests);
// The actual recursive filesystem scan is only invoked by instantiating the
// RecursiveIteratorIterator.
$iterator = new \RecursiveIteratorIterator($filter,
\RecursiveIteratorIterator::LEAVES_ONLY,
// Suppress filesystem errors in case a directory cannot be accessed.
\RecursiveIteratorIterator::CATCH_GET_CHILD
);
foreach ($iterator as $key => $fileinfo) {
// All extension names in Drupal have to be valid PHP function names due
// to the module hook architecture.
if (!preg_match(static::PHP_FUNCTION_PATTERN, $fileinfo->getBasename('.info.yml'))) {
continue;
}
if ($this->fileCache && $cached_extension = $this->fileCache->get($fileinfo->getPathName())) {
$files[$cached_extension->getType()][$key] = $cached_extension;
continue;
}
catch
committed
// Determine extension type from info file.
$type = FALSE;
$file = $fileinfo->openFile('r');
while (!$type && !$file->eof()) {
preg_match('@^type:\s*(\'|")?(\w+)\1?\s*$@', $file->fgets(), $matches);
if (isset($matches[2])) {
$type = $matches[2];
catch
committed
}
}
if (empty($type)) {
continue;
}
$name = $fileinfo->getBasename('.info.yml');
$pathname = $dir_prefix . $fileinfo->getSubPathname();
// Determine whether the extension has a main extension file.
catch
committed
// For theme engines, the file extension is .engine.
if ($type == 'theme_engine') {
catch
committed
$filename = $name . '.engine';
}
// For profiles/modules/themes, it is the extension type.
catch
committed
else {
$filename = $name . '.' . $type;
}
if (!file_exists($this->root . '/' . dirname($pathname) . '/' . $filename)) {
$filename = NULL;
}
catch
committed
$extension = new Extension($this->root, $type, $pathname, $filename);
catch
committed
// Track the originating directory for sorting purposes.
$extension->subpath = $fileinfo->getSubPath();
catch
committed
$extension->origin = $dir;
$files[$type][$key] = $extension;
if ($this->fileCache) {
$this->fileCache->set($fileinfo->getPathName(), $extension);
}
catch
committed
}
return $files;
}
/**
* Returns a parser for .info.yml files.
*
* @return \Drupal\Core\Extension\InfoParser
* The InfoParser instance.
*/
protected function getInfoParser() {
if (!isset($this->infoParser)) {
$this->infoParser = new InfoParser();
}
return $this->infoParser;
}
}