196 lines
6.2 KiB
PHP
196 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace KTXC\Module;
|
|
|
|
use KTXC\Server;
|
|
|
|
/**
|
|
* Custom autoloader for modules that allows PascalCase namespaces
|
|
* with lowercase folder names.
|
|
*
|
|
* This breaks from PSR-4 convention to allow:
|
|
* - Folder: modules/contacts_manager/
|
|
* - Namespace: KTXM\ContactsManager\
|
|
*
|
|
* The autoloader scans lazily - only when a KTXM class is first requested.
|
|
*/
|
|
class ModuleAutoloader
|
|
{
|
|
private string $modulesRoot;
|
|
private array $namespaceMap = [];
|
|
private bool $scanned = false;
|
|
|
|
public function __construct(string $modulesRoot)
|
|
{
|
|
$this->modulesRoot = rtrim($modulesRoot, '/');
|
|
}
|
|
|
|
/**
|
|
* Register the autoloader
|
|
*/
|
|
public function register(): void
|
|
{
|
|
spl_autoload_register([$this, 'loadClass']);
|
|
}
|
|
|
|
/**
|
|
* Unregister the autoloader
|
|
*/
|
|
public function unregister(): void
|
|
{
|
|
spl_autoload_unregister([$this, 'loadClass']);
|
|
}
|
|
|
|
/**
|
|
* Scan the modules directory and build a map of namespaces to folder paths
|
|
* This is called lazily on the first KTXM class request
|
|
*/
|
|
private function scan(): void
|
|
{
|
|
if ($this->scanned) {
|
|
return;
|
|
}
|
|
|
|
$this->namespaceMap = [];
|
|
|
|
if (!is_dir($this->modulesRoot)) {
|
|
$this->scanned = true;
|
|
return;
|
|
}
|
|
|
|
$moduleDirs = glob($this->modulesRoot . '/*', GLOB_ONLYDIR);
|
|
foreach ($moduleDirs as $moduleDir) {
|
|
$moduleFile = $moduleDir . '/lib/Module.php';
|
|
if (!file_exists($moduleFile)) {
|
|
continue;
|
|
}
|
|
|
|
// Extract the namespace from Module.php
|
|
$namespace = $this->extractNamespace($moduleFile);
|
|
if ($namespace) {
|
|
$this->namespaceMap[$namespace] = basename($moduleDir);
|
|
}
|
|
}
|
|
|
|
// Register module namespaces with Composer ClassLoader
|
|
$composerLoader = Server::getComposerLoader();
|
|
if ($composerLoader !== null) {
|
|
foreach ($this->namespaceMap as $namespace => $folderName) {
|
|
$composerLoader->addPsr4(
|
|
'KTXM\\' . $namespace . '\\',
|
|
$this->modulesRoot . '/' . $folderName . '/lib/'
|
|
);
|
|
}
|
|
}
|
|
|
|
$this->scanned = true;
|
|
}
|
|
|
|
/**
|
|
* Load a class by its fully qualified name
|
|
*
|
|
* @param string $className Fully qualified class name (e.g., KTXM\ContactsManager\Module)
|
|
* @return bool True if the class was loaded, false otherwise
|
|
*/
|
|
public function loadClass(string $className): bool
|
|
{
|
|
try {
|
|
// Only handle classes in the KTXM namespace
|
|
if (!str_starts_with($className, 'KTXM\\')) {
|
|
return false;
|
|
}
|
|
|
|
// Extract the namespace segment (e.g., ContactsManager from KTXM\ContactsManager\Module)
|
|
$parts = explode('\\', $className);
|
|
if (count($parts) < 2) {
|
|
$this->logError("Invalid class name format: $className (expected at least 2 namespace parts)");
|
|
return false;
|
|
}
|
|
|
|
$namespaceSegment = $parts[1];
|
|
|
|
// Check if we already have a mapping for this namespace
|
|
if (!isset($this->namespaceMap[$namespaceSegment])) {
|
|
// Scan only if we haven't scanned yet (this happens once, on first module access)
|
|
if (!$this->scanned) {
|
|
$this->scan();
|
|
|
|
// Check again after scanning
|
|
if (!isset($this->namespaceMap[$namespaceSegment])) {
|
|
$this->logError("No module found for namespace segment: $namespaceSegment (class: $className)");
|
|
return false;
|
|
}
|
|
} else {
|
|
$this->logError("Module not found after scan: $namespaceSegment (class: $className)");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$folderName = $this->namespaceMap[$namespaceSegment];
|
|
|
|
// Reconstruct the relative path
|
|
// KTXM\ContactsManager\Module -> contacts_manager/lib/Module.php
|
|
// KTXM\ContactsManager\Something -> contacts_manager/lib/Something.php
|
|
$relativePath = 'lib/' . implode('/', array_slice($parts, 2)) . '.php';
|
|
$filePath = $this->modulesRoot . '/' . $folderName . '/' . $relativePath;
|
|
|
|
if (file_exists($filePath)) {
|
|
require_once $filePath;
|
|
return true;
|
|
}
|
|
|
|
$this->logError("File not found for class $className at path: $filePath");
|
|
return false;
|
|
|
|
} catch (\Throwable $e) {
|
|
$this->logError("Exception in ModuleAutoloader while loading $className: " . $e->getMessage(), [
|
|
'exception' => $e,
|
|
'file' => $e->getFile(),
|
|
'line' => $e->getLine(),
|
|
]);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log an error from the autoloader
|
|
*
|
|
* @param string $message Error message
|
|
* @param array $context Additional context
|
|
*/
|
|
private function logError(string $message, array $context = []): void
|
|
{
|
|
// Log to PHP error log
|
|
error_log('[ModuleAutoloader] ' . $message);
|
|
|
|
if (!empty($context)) {
|
|
error_log('[ModuleAutoloader Context] ' . json_encode($context));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract namespace from a Module.php file
|
|
*
|
|
* @param string $filePath Path to the Module.php file (at modules/{handle}/lib/Module.php)
|
|
* @return string|null The namespace segment (e.g., 'ContactsManager')
|
|
*/
|
|
private function extractNamespace(string $filePath): ?string
|
|
{
|
|
if (!file_exists($filePath)) {
|
|
return null;
|
|
}
|
|
|
|
$content = file_get_contents($filePath);
|
|
if ($content === false) {
|
|
return null;
|
|
}
|
|
|
|
// Match namespace declaration: namespace KTXM\<Namespace>;
|
|
if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|