New Autoloader that uses modular ClassPathMappers to map classes to filenames,
authorChuck Hagenbuch <chuck@horde.org>
Fri, 4 Jun 2010 19:37:13 +0000 (15:37 -0400)
committerChuck Hagenbuch <chuck@horde.org>
Fri, 4 Jun 2010 20:14:25 +0000 (16:14 -0400)
rather than hardcoding a lot of logic into the main Autoloader class. Also
allows more flexibility since mappers can have any logic they need in them.

framework/Autoloader/lib/Horde/Autoloader.php
framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper.php [new file with mode: 0644]
framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper/Default.php [new file with mode: 0644]
framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper/Prefix.php [new file with mode: 0644]
framework/Autoloader/package.xml
framework/Autoloader/test/Horde/Autoloader/AllTests.php [new file with mode: 0644]
framework/Autoloader/test/Horde/Autoloader/AutoloaderTest.php [new file with mode: 0644]
framework/Autoloader/test/Horde/Autoloader/ClassPathMapper/DefaultTest.php [new file with mode: 0644]
framework/Autoloader/test/Horde/Autoloader/ClassPathMapper/PrefixTest.php [new file with mode: 0644]

index 08e42cc..8eda39c 100644 (file)
 <?php
 /**
- * Horde Autoloader.
+ * Horde_Autoloader
  *
+ * Manages an application's class name to file name mapping conventions. One or
+ * more class-to-filename mappers are defined, and are searched in LIFO order.
+ *
+ * @author   Bob Mckee <bmckee@bywires.com>
+ * @author   Chuck Hagenbuch <chuck@horde.org>
  * @category Horde
  * @package  Horde_Autoloader
- * @license  http://www.gnu.org/copyleft/lesser.html
  */
 class Horde_Autoloader
 {
-    /**
-     * Patterns that match classes we can load.
-     *
-     * @var array
-     */
-    protected static $_classPatterns = array();
-
-    /**
-     * The include path cache.
-     *
-     * @var array
-     */
-    protected static $_includeCache = null;
-
-    /**
-     * Callbacks.
-     *
-     * @var array
-     */
-    protected static $_callbacks = array();
+    private $_mappers = array();
+    private $_callbacks = array();
 
-    /**
-     * Autoload implementation automatically registered with
-     * spl_autoload_register.
-     *
-     * We ignore E_WARNINGS when trying to include files so that if our
-     * autoloader doesn't find a file, we pass on to the next autoloader (if
-     * any) or to the PHP class not found error. We don't want to suppress all
-     * errors, though, or else we'll end up silencing parse errors or
-     * redefined class name errors, making debugging especially difficult.
-     *
-     * @param string $class  Class name to load (or interface).
-     */
-    public static function loadClass($class)
+    public function loadClass($className)
     {
-        /* Search in class patterns first. */
-        foreach (self::$_classPatterns as $classPattern) {
-            list($pattern, $replace) = $classPattern;
-
-            if (!is_null($replace) &&
-                preg_match($pattern, $class, $matches, PREG_OFFSET_CAPTURE)) {
-                if (strcasecmp($matches[0][0], $class) === 0) {
-                    $relativePath = $replace . '/' . $class;
-                } else {
-                    $relativePath = str_replace(array('\\', '_'), '/', substr($class, 0, $matches[0][1])) .
-                        $replace .
-                        str_replace(array('\\', '_'), '/', substr($class, $matches[0][1] + strlen($matches[0][0])));
-                }
-
-                if (self::_loadClass($class, $relativePath)) {
-                    return true;
+        if ($path = $this->mapToPath($className)) {
+            if ($this->_include($path)) {
+                $className = strtolower($className);
+                if (isset($this->_callbacks[$className])) {
+                    call_user_func($this->_callbacks[$className]);
                 }
+                return true;
             }
         }
 
-        /* Do a final search in the include path. */
-        $relativePath = str_replace(array('\\', '_'), '/', $class);
-        return self::_loadClass($class, $relativePath);
+        return false;
     }
 
-    /**
-     * Try to load the class from each path in the include_path.
-     *
-     * @param string $class         The loaded classname.
-     * @param string $relativePath  TODO
-     */
-    protected static function _loadClass($class, $relativePath)
+    public function addClassPathMapper(Horde_Autoloader_ClassPathMapper $mapper)
     {
-        $result = false;
-
-        if (substr($relativePath, 0, 1) == '/') {
-            $result = self::_loadClassUsingAbsolutePath($relativePath);
-        } else {
-            if (is_null(self::$_includeCache)) {
-                self::_cacheIncludePath();
-            }
-
-            foreach (self::$_includeCache as $includePath) {
-                if (self::_loadClassUsingAbsolutePath($includePath . DIRECTORY_SEPARATOR . $relativePath)) {
-                    $result = true;
-                    break;
-                }
-            }
-        }
-
-        if (!$result) {
-            return false;
-        }
-
-        $class = strtolower($class);
-        if (isset(self::$_callbacks[$class])) {
-            call_user_func(self::$_callbacks[$class]);
-        }
-
-        return true;
+        array_unshift($this->_mappers, $mapper);
+        return $this;
     }
 
     /**
-     * Include a PHP file using an absolute path, suppressing E_WARNING and
-     * E_DEPRECATED errors, but letting fatal/parse errors come through.
-     *
-     * @param string $absolutePath  TODO
+     * Add a callback to run when a class is loaded through loadClass().
      *
-     * @TODO Remove E_DEPRECATED masking
+     * @param string $class    The classname.
+     * @param mixed $callback  The callback to run when the class is loaded.
      */
-    protected static function _loadClassUsingAbsolutePath($absolutePath)
+    public function addCallback($class, $callback)
     {
-        $err_mask = E_ALL ^ E_WARNING;
-        if (defined('E_DEPRECATED')) {
-            $err_mask = $err_mask ^ E_DEPRECATED;
-        }
-        $oldErrorReporting = error_reporting($err_mask);
-        $included = include $absolutePath . '.php';
-        error_reporting($oldErrorReporting);
-        return $included;
+        $this->_callbacks[strtolower($class)] = $callback;
     }
 
-    /**
-     * Add a new path to the include_path we're loading from.
-     *
-     * @param string $path      The directory to add.
-     * @param boolean $prepend  Add to the beginning of the stack?
-     *
-     * @return string  The new include_path.
-     */
-    public static function addClassPath($path, $prepend = true)
+    public function registerAutoloader()
     {
-        if ($path == '.') {
-            throw new Exception('"." is not allowed in the include_path. Use an absolute path instead.');
-        }
-        $path = realpath($path);
-
-        if (is_null(self::$_includeCache)) {
-            self::_cacheIncludePath();
+        // Register the autoloader in a way to play well with as many
+        // configurations as possible.
+        spl_autoload_register(array($this, 'loadClass'));
+        if (function_exists('__autoload')) {
+            spl_autoload_register('__autoload');
         }
-
-        if (in_array($path, self::$_includeCache)) {
-            // The path is already present in our stack; don't re-add it.
-            return implode(PATH_SEPARATOR, self::$_includeCache);
-        }
-
-        if ($prepend) {
-            array_unshift(self::$_includeCache, $path);
-        } else {
-            self::$_includeCache[] = $path;
-        }
-
-        $include_path = implode(PATH_SEPARATOR, self::$_includeCache);
-        set_include_path($include_path);
-
-        return $include_path;
     }
 
     /**
-     * Add a new class pattern.
-     *
-     * @param string $pattern  The class pattern to add.
-     * @param string $replace  The substitution pattern. All '_' and '\'
-     *                         strings in a classname will be converted to
-     *                         directory separators.  If the entire pattern
-     *                         is matched, the matched text will be appended
-     *                         to the replacement string (allows for a single
-     *                         base class file to live within the include
-     *                         directory).
+     * Search registered mappers in LIFO order.
      */
-    public static function addClassPattern($pattern, $replace = null)
+    public function mapToPath($className)
     {
-        if (strlen($replace)) {
-            $replace = rtrim($replace, '/') . '/';
+        foreach ($this->_mappers as $mapper) {
+            if ($path = $mapper->mapToPath($className)) {
+                if ($this->_fileExists($path)) {
+                    return $path;
+                }
+            }
         }
-        self::$_classPatterns[] = array($pattern, $replace);
     }
 
-    /**
-     * Add a callback to run when a class is loaded through loadClass().
-     *
-     * @param string $class    The classname.
-     * @param mixed $callback  The callback to run when the class is loaded.
-     */
-    public static function addCallback($class, $callback)
+    protected function _include($path)
     {
-        self::$_callbacks[strtolower($class)] = $callback;
+        return (bool)include $path;
     }
 
-    /**
-     * TODO
-     */
-    protected static function _cacheIncludePath()
-    {
-        self::$_includeCache = array();
-        foreach (explode(PATH_SEPARATOR, get_include_path()) as $path) {
-            if ($path == '.') { continue; }
-            $path = realpath($path);
-            if ($path) {
-                self::$_includeCache[] = $path;
-            }
-        }
-    }
-
-}
-
-/* Register the autoloader in a way to play well with as many configurations
- * as possible. */
-if (function_exists('spl_autoload_register')) {
-    spl_autoload_register(array('Horde_Autoloader', 'loadClass'));
-    if (function_exists('__autoload')) {
-        spl_autoload_register('__autoload');
-    }
-} elseif (!function_exists('__autoload')) {
-    function __autoload($class)
+    protected function _fileExists($path)
     {
-        return Horde_Autoloader::loadClass($class);
+        return file_exists($path);
     }
 }
diff --git a/framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper.php b/framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper.php
new file mode 100644 (file)
index 0000000..b5735e4
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+/**
+ * Horde_Autoloader_ClassPathMapper
+ *
+ * Interface for class loaders
+ *
+ * @author   Bob Mckee <bmckee@bywires.com>
+ * @category Horde
+ * @package  Horde_Autoloader
+ */
+interface Horde_Autoloader_ClassPathMapper
+{
+    public function mapToPath($className);
+}
diff --git a/framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper/Default.php b/framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper/Default.php
new file mode 100644 (file)
index 0000000..4003f1d
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Maps classes to paths following the PHP Framework Interop Group PSR-0
+ * reference implementation. Under this guideline, the following rules apply:
+ *
+ *   Each namespace separator is converted to a DIRECTORY_SEPARATOR when loading from the file system.
+ *   Each "_" character in the CLASS NAME is converted to a DIRECTORY_SEPARATOR. The "_" character has no special meaning in the namespace.
+ *   The fully-qualified namespace and class is suffixed with ".php" when loading from the file system.
+ *
+ * Examples:
+ *
+ *   \Doctrine\Common\IsolatedClassLoader => /path/to/project/lib/vendor/Doctrine/Common/IsolatedClassLoader.php
+ *   \namespace\package\Class_Name => /path/to/project/lib/vendor/namespace/package/Class/Name.php
+ *   \namespace\package_name\Class_Name => /path/to/project/lib/vendor/namespace/package_name/Class/Name.php
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @category Horde
+ * @package  Horde_Autoloader
+ */
+class Horde_Autoloader_ClassPathMapper_Default implements Horde_Autoloader_ClassPathMapper
+{
+    private $_includePath;
+
+    public function __construct($includePath)
+    {
+        $this->_includePath = $includePath;
+    }
+
+    public function mapToPath($className)
+    {
+        // @FIXME: Follow reference implementation
+        $relativePath = str_replace(array('\\', '_'), DIRECTORY_SEPARATOR, $className) . '.php';
+        return $this->_includePath . DIRECTORY_SEPARATOR . $relativePath;
+    }
+}
diff --git a/framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper/Prefix.php b/framework/Autoloader/lib/Horde/Autoloader/ClassPathMapper/Prefix.php
new file mode 100644 (file)
index 0000000..7cd1ab5
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Load classes from a specific path matching a specific prefix.
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @category Horde
+ * @package  Horde_Autoloader
+ */
+class Horde_Autoloader_ClassPathMapper_Prefix implements Horde_Autoloader_ClassPathMapper
+{
+    private $_pattern;
+    private $_includePath;
+
+    public function __construct($pattern, $includePath)
+    {
+        $this->_pattern = $pattern;
+        $this->_includePath = $includePath;
+    }
+
+    public function mapToPath($className)
+    {
+        if (preg_match($this->_pattern, $className, $matches, PREG_OFFSET_CAPTURE)) {
+            if (strcasecmp($matches[0][0], $className) === 0) {
+                return "$this->_includePath/$className.php";
+            } else {
+                return str_replace(array('\\', '_'), '/', substr($className, 0, $matches[0][1])) .
+                    $this->_includePath . '/' .
+                    str_replace(array('\\', '_'), '/', substr($className, $matches[0][1] + strlen($matches[0][0]))) .
+                    '.php';
+            }
+        }
+
+        return false;
+    }
+}
index 8b4b0c9..66c12e0 100644 (file)
@@ -40,6 +40,14 @@ http://pear.php.net/dtd/package-2.0.xsd">
    <dir name="lib">
     <dir name="Horde">
      <file name="Autoloader.php" role="php" />
+     <dir name="Autoloader">
+      <file name="ClassPathMapper.php" role="php" />
+      <dir name="ClassPathMapper">
+       <file name="Application.php" role="php" />
+       <file name="Default.php" role="php" />
+       <file name="Prefix.php" role="php" />
+      </dir> <!-- /lib/Horde/Autoloader/ClassPathMapper -->
+     </dir> <!-- /lib/Horde/Autoloader -->
     </dir> <!-- /lib/Horde -->
    </dir> <!-- /lib -->
   </dir> <!-- / -->
@@ -56,6 +64,10 @@ http://pear.php.net/dtd/package-2.0.xsd">
  </dependencies>
  <phprelease>
   <filelist>
+   <install name="lib/Horde/Autoloader/ClassPathMapper/Application.php" as="Horde/Autoloader/ClassPathMapper/Application.php" />
+   <install name="lib/Horde/Autoloader/ClassPathMapper/Default.php" as="Horde/Autoloader/ClassPathMapper/Default.php" />
+   <install name="lib/Horde/Autoloader/ClassPathMapper/Prefix.php" as="Horde/Autoloader/ClassPathMapper/Prefix.php" />
+   <install name="lib/Horde/Autoloader/ClassPathMapper.php" as="Horde/Autoloader/ClassPathMapper.php" />
    <install name="lib/Horde/Autoloader.php" as="Horde/Autoloader.php" />
   </filelist>
  </phprelease>
diff --git a/framework/Autoloader/test/Horde/Autoloader/AllTests.php b/framework/Autoloader/test/Horde/Autoloader/AllTests.php
new file mode 100644 (file)
index 0000000..20af76d
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+/**
+ * @package    Horde_Autoloader
+ * @subpackage UnitTests
+ */
+
+/**
+ * Define the main method
+ */
+if (!defined('PHPUnit_MAIN_METHOD')) {
+    define('PHPUnit_MAIN_METHOD', 'Horde_Autoloader_AllTests::main');
+}
+
+/**
+ * Prepare the test setup.
+ */
+require_once 'Horde/Test/AllTests.php';
+
+/**
+ * @package    Horde_Autoloader
+ * @subpackage UnitTests
+ */
+class Horde_Autoloader_AllTests extends Horde_Test_AllTests
+{
+}
+
+Horde_Autoloader_AllTests::init('Horde_Autoloader', __FILE__);
+
+if (PHPUnit_MAIN_METHOD == 'Horde_Autoloader_AllTests::main') {
+    Horde_Autoloader_AllTests::main();
+}
diff --git a/framework/Autoloader/test/Horde/Autoloader/AutoloaderTest.php b/framework/Autoloader/test/Horde/Autoloader/AutoloaderTest.php
new file mode 100644 (file)
index 0000000..a708ab6
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+class Horde_Autoloader_AutoloaderTest extends PHPUnit_Framework_TestCase
+{
+    private $_autoloader;
+
+    public function setUp()
+    {
+        $this->_autoloader = new Horde_Autoloader_TestHarness();
+    }
+
+    public function testInitialStateShouldYeildNoMatches()
+    {
+        $this->assertNull($this->_autoloader->mapToPath('The_Class_Name'));
+    }
+
+    public function testInitialStateShouldNotLoadAnyFiles()
+    {
+        $this->assertFalse($this->_autoloader->loadClass('The_Class_Name'));
+    }
+
+    public function testShouldNotMapClassIfMapperDoesNotReturnAPath()
+    {
+        $this->_autoloader->addClassPathMapper($this->_getUnsuccessfulMapperMock());
+        $this->assertNull($this->_autoloader->mapToPath('The_Class_Name'));
+    }
+
+    public function testShouldLoadPathMapperDoesNotReturnAPath()
+    {
+        $this->_autoloader->addClassPathMapper($this->_getUnsuccessfulMapperMock());
+        $this->assertFalse($this->_autoloader->loadClass('The_Class_Name'));
+    }
+
+    public function testShouldMapClassIfAMapperReturnsAPath()
+    {
+        // trick the autoloader into thinking the returned path exists
+        $this->_autoloader->setFileExistsResponse(true);
+
+        $this->_autoloader->addClassPathMapper($this->_getSuccessfulMapperMock());
+
+        $this->assertEquals('The/Class/Name.php', $this->_autoloader->mapToPath('The_Class_Name'));
+    }
+
+    public function testShouldNotMapClassIfAMapperReturnsAPathThatDoesNotExist()
+    {
+        // trick the autoloader into thinking the returned path does not exist
+        $this->_autoloader->setFileExistsResponse(false);
+
+        $this->_autoloader->addClassPathMapper($this->_getSuccessfulMapperMock());
+
+        $this->assertNull($this->_autoloader->mapToPath('The_Class_Name'));
+    }
+
+    public function testShouldLoadFileIfMapperReturnsAValidPath()
+    {
+        // trick the autoloader into thinking the returned path exists and was included
+        $this->_autoloader->setFileExistsResponse(true);
+        $this->_autoloader->setIncludeResponse(true);
+
+        $this->_autoloader->addClassPathMapper($this->_getSuccessfulMapperMock());
+
+        $this->assertTrue($this->_autoloader->loadClass('The_Class_Name'));
+    }
+
+    public function testShouldLoadFileIfMapperReturnsAValidPathButIncludingItFails()
+    {
+        // trick the autoloader into thinking the returned path exists and was included
+        $this->_autoloader->setFileExistsResponse(true);
+        $this->_autoloader->setIncludeResponse(false);
+
+        $this->_autoloader->addClassPathMapper($this->_getSuccessfulMapperMock());
+
+        $this->assertFalse($this->_autoloader->loadClass('The_Class_Name'));
+    }
+
+    private function _getSuccessfulMapperMock()
+    {
+        $mapper = $this->getMock('Horde_Autoloader_ClassPathMapper', array('mapToPath'));
+        $mapper->expects($this->once())
+            ->method('mapToPath')
+            ->with($this->equalTo('The_Class_Name'))
+            ->will($this->returnValue('The/Class/Name.php'));
+
+        return $mapper;
+    }
+
+    private function _getUnsuccessfulMapperMock()
+    {
+        $mapper = $this->getMock('Horde_Autoloader_ClassPathMapper', array('mapToPath'));
+        $mapper->expects($this->once())
+            ->method('mapToPath')
+            ->with($this->equalTo('The_Class_Name'))
+            ->will($this->returnValue(null));
+
+        return $mapper;
+    }
+}
+
+class Horde_Autoloader_TestHarness extends Horde_Autoloader
+{
+    private $_includeResponse;
+    private $_fileExistsResponse;
+
+    public function setIncludeResponse($bool)
+    {
+        $this->_includeResponse = $bool;
+    }
+
+    public function setFileExistsResponse($bool)
+    {
+        $this->_fileExistsResponse = $bool;
+    }
+
+    protected function _include($path)
+    {
+        return $this->_includeResponse;
+    }
+
+    protected function _fileExists($path)
+    {
+        return $this->_fileExistsResponse;
+    }
+}
diff --git a/framework/Autoloader/test/Horde/Autoloader/ClassPathMapper/DefaultTest.php b/framework/Autoloader/test/Horde/Autoloader/ClassPathMapper/DefaultTest.php
new file mode 100644 (file)
index 0000000..6be850c
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+/**
+ * @category Horde
+ * @package  Horde_Autoloader
+ */
+class Horde_Autoloader_ClassPathMapper_DefaultTest extends PHPUnit_Framework_TestCase
+{
+    private $_mapper;
+
+    public function setUp()
+    {
+        $this->_mapper = new Horde_Autoloader_ClassPathMapper_Default('dir');
+    }
+
+    public function providerClassNames()
+    {
+        return array(
+            array('Module_Action_Suffix', 'dir/Module/Action/Suffix.php'),
+            array('MyModule_Action_Suffix', 'dir/MyModule/Action/Suffix.php'),
+            array('Module_MyAction_Suffix', 'dir/Module/MyAction/Suffix.php'),
+            array('MyModule_MyAction_Suffix', 'dir/MyModule/MyAction/Suffix.php'),
+            array('Module\Action\Suffix', 'dir/Module/Action/Suffix.php'),
+            array('MyModule\Action\Suffix', 'dir/MyModule/Action/Suffix.php'),
+            array('Module\MyAction\Suffix', 'dir/Module/MyAction/Suffix.php'),
+            array('MyModule\MyAction\Suffix', 'dir/MyModule/MyAction/Suffix.php'),
+        );
+    }
+
+    /**
+     * @dataProvider providerClassNames
+     */
+    public function testShouldMapClassToPath($className, $classPath)
+    {
+        $this->assertEquals(
+            $classPath,
+            $this->_mapper->mapToPath($className)
+        );
+    }
+}
diff --git a/framework/Autoloader/test/Horde/Autoloader/ClassPathMapper/PrefixTest.php b/framework/Autoloader/test/Horde/Autoloader/ClassPathMapper/PrefixTest.php
new file mode 100644 (file)
index 0000000..f3f6eb7
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * @category Horde
+ * @package  Horde_Autoloader
+ */
+class Horde_Autoloader_ClassPathMapper_PrefixTest extends PHPUnit_Framework_TestCase
+{
+    private $_mapper;
+
+    public function setUp()
+    {
+        $this->_mapper = new Horde_Autoloader_ClassPathMapper_Prefix('/^App(?:$|_)/i', 'dir');
+    }
+
+    public function providerClassNames()
+    {
+        return array(
+            array('App',         'dir/App.php'),
+            array('App_Foo',     'dir/Foo.php'),
+            array('App_Foo_Bar', 'dir/Foo/Bar.php'),
+        );
+    }
+
+    /**
+     * @dataProvider providerClassNames
+     */
+    public function testShouldMapClassToPath($className, $classPath)
+    {
+        $this->assertEquals(
+            $classPath,
+            $this->_mapper->mapToPath($className)
+        );
+    }
+}