3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
13 * The MixinScanner scans the list of actives extensions and their required mixins.
15 class CRM_Extension_MixinScanner
{
18 * @var CRM_Extension_Mapper
23 * @var CRM_Extension_Manager
29 * A list of base-paths which are implicitly supported by 'include' directives.
30 * Sorted with the longest paths first.
32 protected $relativeBases;
36 * Ex: ['civix' => ['1.0.0' => 'path/to/civix@1.0.0.mixin.php']]
38 protected $allFuncFiles = [];
42 * If we have not scanned for live funcs, then NULL.
43 * Otherwise, every live version-requirement is mapped to the corresponding file.
44 * Ex: ['civix@1' => 'path/to/civix@1.0.0.mixin.php']
46 protected $liveFuncFiles = NULL;
49 * @var \CRM_Extension_MixInfo[]
51 protected $mixInfos = [];
54 * CRM_Extension_ClassLoader constructor.
55 * @param \CRM_Extension_Mapper|null $mapper
56 * @param \CRM_Extension_Manager|null $manager
57 * @param bool $relativize
58 * Whether to store paths in relative form.
59 * Enabling this may slow-down scanning a bit, and it has no benefit when for on-demand loaders.
60 * However, if the loader is cached, then it may make for smaller, more portable cache-file.
62 public function __construct(?\CRM_Extension_Mapper
$mapper = NULL, \CRM_Extension_Manager
$manager = NULL, $relativize = TRUE) {
63 $this->mapper
= $mapper ?
: CRM_Extension_System
::singleton()->getMapper();
64 $this->manager
= $manager ?
: CRM_Extension_System
::singleton()->getManager();
66 $this->relativeBases
= [Civi
::paths()->getVariable('civicrm.root', 'path')];
67 // Previous drafts used `relativeBases=explode(include_path)`. However, this produces unstable results
68 // when flip through the phases of the lifecycle - because the include_path changes throughout the lifecycle.
69 usort($this->relativeBases
, function($a, $b) {
70 return strlen($b) - strlen($a);
74 $this->relativeBases
= NULL;
79 * @return array{0: funcFiles, 1: mixInfos}
81 public function build() {
83 return $this->compile();
87 * Search through known extensions
89 protected function scan() {
90 foreach ($this->getInstalledKeys() as $key) {
92 $path = $this->mapper
->keyToBasePath($key);
93 $this->addMixInfo($this->createMixInfo($path . DIRECTORY_SEPARATOR
. CRM_Extension_Info
::FILENAME
));
94 $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*.mixin.php"));
95 $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*/mixin.php"), TRUE);
97 catch (CRM_Extension_Exception_ParseException
$e) {
98 error_log(sprintf('MixinScanner: Failed to read extension (%s)', $key));
102 $this->addFunctionFiles($this->findFunctionFiles(Civi
::paths()->getPath('[civicrm.root]/mixin/*@*.mixin.php')));
103 $this->addFunctionFiles($this->findFunctionFiles(Civi
::paths()->getPath('[civicrm.root]/mixin/*@*/mixin.php')), TRUE);
107 * Optimize the metadata, removing information that is not needed at runtime.
111 * - Remove any unnecessary $mixInfos (ie they have no mixins).
112 * - Given the available versions and expectations, pick the best $liveFuncFiles.
113 * - Drop $allFuncFiles.
115 protected function compile() {
116 $this->liveFuncFiles
= [];
117 $allFuncs = $this->allFuncFiles ??
[];
119 $sortByVer = function ($a, $b) {
120 return version_compare($a, $b /* ignore third arg */);
122 foreach (array_keys($allFuncs) as $name) {
123 uksort($allFuncs[$name], $sortByVer);
126 $this->mixInfos
= array_filter($this->mixInfos
, function(CRM_Extension_MixInfo
$mixInfo) {
127 return !empty($mixInfo->mixins
);
130 foreach ($this->mixInfos
as $ext) {
131 /** @var \CRM_Extension_MixInfo $ext */
132 foreach ($ext->mixins
as $verExpr) {
133 list ($name, $expectVer) = explode('@', $verExpr);
135 // NOTE: allFuncs[$name] is sorted by increasing version number. Choose highest satisfactory match.
136 foreach ($allFuncs[$name] ??
[] as $availVer => $availFile) {
137 if (static::satisfies($expectVer, $availVer)) {
138 $matchFile = $availFile;
142 $this->liveFuncFiles
[$verExpr] = $matchFile;
145 error_log(sprintf('MixinLoader: Failed to locate match for "%s"', $verExpr));
150 $this->allFuncFiles
= NULL;
152 return [$this->liveFuncFiles
, $this->mixInfos
];
156 * @param CRM_Extension_MixInfo $mix
158 * @throws \CRM_Extension_Exception_ParseException
160 public function addMixInfo(CRM_Extension_MixInfo
$mix) {
161 $this->mixInfos
[$mix->longName
] = $mix;
166 * @param array|string $files
167 * Ex: 'path/to/some/file@1.0.0.mixin.php'
168 * @param bool $deepRead
169 * If TRUE, then the file will be read to find metadata.
172 public function addFunctionFiles($files, $deepRead = FALSE) {
173 $files = (array) $files;
174 foreach ($files as $file) {
175 if (preg_match(';^([^@]+)@([^@]+)\.mixin\.php$;', basename($file), $m)) {
176 $this->allFuncFiles
[$m[1]][$m[2]] = $file;
181 $header = $this->loadFunctionFileHeader($file);
182 if (isset($header['mixinName'], $header['mixinVersion'])) {
183 $this->allFuncFiles
[$header['mixinName']][$header['mixinVersion']] = $file;
187 error_log(sprintf('MixinLoader: Invalid mixin header for "%s". @mixinName and @mixinVersion required.', $file));
192 error_log(sprintf('MixinLoader: File \"%s\" cannot be parsed.', $file));
197 private function loadFunctionFileHeader($file) {
198 $php = file_get_contents($file, TRUE);
199 foreach (token_get_all($php) as $token) {
200 if (is_array($token) && in_array($token[0], [T_DOC_COMMENT
, T_COMMENT
, T_FUNC_C
, T_METHOD_C
, T_TRAIT_C
, T_CLASS_C
])) {
201 return \Civi\Api4\Utils\ReflectionUtils
::parseDocBlock($token[1]);
210 private function getInstalledKeys() {
213 $statuses = $this->manager
->getStatuses();
215 foreach ($statuses as $key => $status) {
216 if ($status === CRM_Extension_Manager
::STATUS_INSTALLED
) {
225 * @param string $infoFile
226 * Path to the 'info.xml' file
227 * @return \CRM_Extension_MixInfo
228 * @throws \CRM_Extension_Exception_ParseException
230 private function createMixInfo(string $infoFile) {
231 $info = CRM_Extension_Info
::loadFromFile($infoFile);
232 $instance = new CRM_Extension_MixInfo();
233 $instance->longName
= $info->key
;
234 $instance->shortName
= $info->file
;
235 $instance->path
= rtrim(dirname($infoFile), '/' . DIRECTORY_SEPARATOR
);
236 $instance->mixins
= $info->mixins
;
241 * @param string $globPat
243 * Ex: ['mix/xml-menu-autoload@1.0.mixin.php']
245 private function findFunctionFiles($globPat) {
246 $useRel = $this->relativeBases
!== NULL;
248 $funcFiles = (array) glob($globPat);
250 foreach ($funcFiles as $shimFile) {
251 $shimFileRel = $useRel ?
$this->relativize($shimFile) : $shimFile;
252 $result[] = $shimFileRel;
258 * Convert the absolute $file to an expression that is supported by 'include'.
260 * @param string $file
263 private function relativize($file) {
264 foreach ($this->relativeBases
as $relativeBase) {
265 if (CRM_Utils_File
::isChildPath($relativeBase, $file)) {
266 return ltrim(CRM_Utils_File
::relativize($file, $relativeBase), '/' . DIRECTORY_SEPARATOR
);
273 * @param string $expectVer
274 * @param string $actualVer
277 private static function satisfies($expectVer, $actualVer) {
278 [$expectMajor] = explode('.', $expectVer);
279 [$actualMajor] = explode('.', $actualVer);
280 return ($expectMajor == $actualMajor) && version_compare($actualVer, $expectVer, '>=');