Merge pull request #22548 from eileenmcnaughton/raw
[civicrm-core.git] / CRM / Extension / MixinScanner.php
index 433f647d0bfb20894697603d318a13b5e9800d32..d433757ce9a8c2b7c071378357af8a4fc5282908 100644 (file)
@@ -31,10 +31,29 @@ class CRM_Extension_MixinScanner {
    */
   protected $relativeBases;
 
+  /**
+   * @var array
+   *   Ex: ['civix' => ['1.0.0' => 'path/to/civix@1.0.0.mixin.php']]
+   */
+  protected $allFuncFiles = [];
+
+  /**
+   * @var array|null
+   *   If we have not scanned for live funcs, then NULL.
+   *   Otherwise, every live version-requirement is mapped to the corresponding file.
+   *   Ex: ['civix@1' => 'path/to/civix@1.0.0.mixin.php']
+   */
+  protected $liveFuncFiles = NULL;
+
+  /**
+   * @var \CRM_Extension_MixInfo[]
+   */
+  protected $mixInfos = [];
+
   /**
    * CRM_Extension_ClassLoader constructor.
-   * @param \CRM_Extension_Mapper|NULL $mapper
-   * @param \CRM_Extension_Manager|NULL $manager
+   * @param \CRM_Extension_Mapper|null $mapper
+   * @param \CRM_Extension_Manager|null $manager
    * @param bool $relativize
    *   Whether to store paths in relative form.
    *   Enabling this may slow-down scanning a bit, and it has no benefit when for on-demand loaders.
@@ -57,27 +76,132 @@ class CRM_Extension_MixinScanner {
   }
 
   /**
-   * @return \CRM_Extension_MixinLoader
+   * @return array{0: funcFiles, 1: mixInfos}
    */
-  public function createLoader() {
-    $l = new CRM_Extension_MixinLoader();
+  public function build() {
+    $this->scan();
+    return $this->compile();
+  }
 
+  /**
+   * Search through known extensions
+   */
+  protected function scan() {
     foreach ($this->getInstalledKeys() as $key) {
       try {
         $path = $this->mapper->keyToBasePath($key);
-        $l->addMixInfo($this->createMixInfo($path . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME));
-        $l->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*.mixin.php"));
-        $l->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*/mixin.php"), TRUE);
+        $this->addMixInfo($this->createMixInfo($path . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME));
+        $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*.mixin.php"));
+        $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*/mixin.php"), TRUE);
       }
       catch (CRM_Extension_Exception_ParseException $e) {
         error_log(sprintf('MixinScanner: Failed to read extension (%s)', $key));
       }
     }
 
-    $l->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*.mixin.php')));
-    $l->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*/mixin.php')), TRUE);
+    $this->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*.mixin.php')));
+    $this->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*/mixin.php')), TRUE);
+  }
+
+  /**
+   * Optimize the metadata, removing information that is not needed at runtime.
+   *
+   * Steps:
+   *
+   * - Remove any unnecessary $mixInfos (ie they have no mixins).
+   * - Given the available versions and expectations, pick the best $liveFuncFiles.
+   * - Drop $allFuncFiles.
+   */
+  protected function compile() {
+    $this->liveFuncFiles = [];
+    $allFuncs = $this->allFuncFiles ?? [];
+
+    $sortByVer = function ($a, $b) {
+      return version_compare($a, $b /* ignore third arg */);
+    };
+    foreach (array_keys($allFuncs) as $name) {
+      uksort($allFuncs[$name], $sortByVer);
+    }
+
+    $this->mixInfos = array_filter($this->mixInfos, function(CRM_Extension_MixInfo $mixInfo) {
+      return !empty($mixInfo->mixins);
+    });
+
+    foreach ($this->mixInfos as $ext) {
+      /** @var \CRM_Extension_MixInfo $ext */
+      foreach ($ext->mixins as $verExpr) {
+        list ($name, $expectVer) = explode('@', $verExpr);
+        $matchFile = NULL;
+        // NOTE: allFuncs[$name] is sorted by increasing version number. Choose highest satisfactory match.
+        foreach ($allFuncs[$name] ?? [] as $availVer => $availFile) {
+          if (static::satisfies($expectVer, $availVer)) {
+            $matchFile = $availFile;
+          }
+        }
+        if ($matchFile) {
+          $this->liveFuncFiles[$verExpr] = $matchFile;
+        }
+        else {
+          error_log(sprintf('MixinLoader: Failed to locate match for "%s"', $verExpr));
+        }
+      }
+    }
 
-    return $l->compile();
+    $this->allFuncFiles = NULL;
+
+    return [$this->liveFuncFiles, $this->mixInfos];
+  }
+
+  /**
+   * @param CRM_Extension_MixInfo $mix
+   * @return static
+   * @throws \CRM_Extension_Exception_ParseException
+   */
+  public function addMixInfo(CRM_Extension_MixInfo $mix) {
+    $this->mixInfos[$mix->longName] = $mix;
+    return $this;
+  }
+
+  /**
+   * @param array|string $files
+   *   Ex: 'path/to/some/file@1.0.0.mixin.php'
+   * @param bool $deepRead
+   *   If TRUE, then the file will be read to find metadata.
+   * @return $this
+   */
+  public function addFunctionFiles($files, $deepRead = FALSE) {
+    $files = (array) $files;
+    foreach ($files as $file) {
+      if (preg_match(';^([^@]+)@([^@]+)\.mixin\.php$;', basename($file), $m)) {
+        $this->allFuncFiles[$m[1]][$m[2]] = $file;
+        continue;
+      }
+
+      if ($deepRead) {
+        $header = $this->loadFunctionFileHeader($file);
+        if (isset($header['mixinName'], $header['mixinVersion'])) {
+          $this->allFuncFiles[$header['mixinName']][$header['mixinVersion']] = $file;
+          continue;
+        }
+        else {
+          error_log(sprintf('MixinLoader: Invalid mixin header for "%s". @mixinName and @mixinVersion required.', $file));
+          continue;
+        }
+      }
+
+      error_log(sprintf('MixinLoader: File \"%s\" cannot be parsed.', $file));
+    }
+    return $this;
+  }
+
+  private function loadFunctionFileHeader($file) {
+    $php = file_get_contents($file, TRUE);
+    foreach (token_get_all($php) as $token) {
+      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])) {
+        return \Civi\Api4\Utils\ReflectionUtils::parseDocBlock($token[1]);
+      }
+    }
+    return [];
   }
 
   /**
@@ -145,4 +269,15 @@ class CRM_Extension_MixinScanner {
     return $file;
   }
 
+  /**
+   * @param string $expectVer
+   * @param string $actualVer
+   * @return bool
+   */
+  private static function satisfies($expectVer, $actualVer) {
+    [$expectMajor] = explode('.', $expectVer);
+    [$actualMajor] = explode('.', $actualVer);
+    return ($expectMajor == $actualMajor) && version_compare($actualVer, $expectVer, '>=');
+  }
+
 }