Files
server/core/lib/Module/ModuleAutoloader.php
2026-02-10 18:46:11 -05:00

196 lines
6.3 KiB
PHP

<?php
namespace KTXC\Module;
use KTXC\Application;
/**
* 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 = \KTXC\Application::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;
}
}