*/ private static ?array $packages = null; private static array $before_stages = []; private static array $after_stages = []; /** @var array Track loaded classes to prevent duplicates */ private static array $loaded_classes = []; /** * Annotation metadata keyed by package name, capturing the defining class and its method-level attributes. * * @var array}>>}> */ private static array $annotation_map = []; /** * Source metadata for #[BeforeStage] hooks, keyed by target package name → stage name. * * @var array}>>> */ private static array $before_stage_meta = []; /** * Source metadata for #[AfterStage] hooks, keyed by target package name → stage name. * * @var array}>>> */ private static array $after_stage_meta = []; /** * Reverse index of #[BeforeStage] hooks, keyed by registering class → target package → stage. * Enables O(1) "outbound hook" lookup: what stages does a given class hook into on other packages? * * @var array>>> */ private static array $class_before_stage_meta = []; /** * Reverse index of #[AfterStage] hooks, keyed by registering class → target package → stage. * * @var array>>> */ private static array $class_after_stage_meta = []; public static function initPackageInstances(): void { if (self::$packages !== null) { return; } // init packages instance from config foreach (PackageConfig::getAll() as $name => $item) { $pkg = match ($item['type']) { 'target', 'virtual-target' => new TargetPackage($name, $item['type']), 'library' => new LibraryPackage($name, $item['type']), 'php-extension' => new PhpExtensionPackage($name, $item['type']), 'tool' => new ToolPackage($name, $item['type']), default => null, }; if ($pkg !== null) { self::$packages[$name] = $pkg; } else { throw new RegistryException("Package [{$name}] has unknown type [{$item['type']}]"); } } } /** * Load package definitions from PSR-4 directory. * * @param string $dir Directory path * @param string $base_namespace Base namespace for dir's PSR-4 mapping * @param bool $auto_require Whether to auto-require PHP files (for external plugins not in autoload) */ public static function loadFromPsr4Dir(string $dir, string $base_namespace, bool $auto_require = false): void { self::initPackageInstances(); $classes = FileSystem::getClassesPsr4($dir, $base_namespace, auto_require: $auto_require); foreach ($classes as $class) { self::loadFromClass($class); } } public static function hasPackage(string $name): bool { return isset(self::$packages[$name]); } /** * Get a Package instance by its name. * * @param string $name The name of the package * @return Package Returns the Package instance if found, otherwise null */ public static function getPackage(string $name): Package { if (!isset(self::$packages[$name])) { throw new WrongUsageException("Package [{$name}] not found."); } return self::$packages[$name]; } public static function getTargetPackage(string $name): TargetPackage { $pkg = self::getPackage($name); if ($pkg instanceof TargetPackage) { return $pkg; } throw new WrongUsageException("Package [{$name}] is not a TargetPackage."); } public static function getLibraryPackage(string $name): LibraryPackage { $pkg = self::getPackage($name); if ($pkg instanceof LibraryPackage) { return $pkg; } throw new WrongUsageException("Package [{$name}] is not a LibraryPackage."); } /** * Get all loaded Package instances. */ public static function getPackages(array|string|null $type_filter = null): iterable { foreach (self::$packages as $name => $package) { if ($type_filter === null) { yield $name => $package; } elseif ($package->getType() === $type_filter) { yield $name => $package; } elseif (is_array($type_filter) && in_array($package->getType(), $type_filter, true)) { yield $name => $package; } } } /** * Init package instance from defined classes and attributes. * * @internal */ public static function loadFromClass(mixed $class): void { $refClass = new \ReflectionClass($class); $class_name = $refClass->getName(); // Skip if already loaded to prevent duplicate registrations if (isset(self::$loaded_classes[$class_name])) { return; } self::$loaded_classes[$class_name] = true; $attributes = $refClass->getAttributes(); foreach ($attributes as $attribute) { $pkg = null; $attribute_instance = $attribute->newInstance(); if ($attribute_instance instanceof Target === false && $attribute_instance instanceof Library === false && $attribute_instance instanceof Extension === false && $attribute_instance instanceof Tool === false) { // not a package attribute continue; } $package_type = PackageConfig::get($attribute_instance->name, 'type'); if ($package_type === null) { throw new RegistryException("Package [{$attribute_instance->name}] not defined in config, but referenced from class {$class}, please check your config files."); } // if class has parent class and matches the attribute instance, use custom class if ($refClass->getParentClass() !== false) { if (is_a($class_name, Package::class, true)) { self::$packages[$attribute_instance->name] = new $class_name($attribute_instance->name, $package_type); } } $pkg = self::$packages[$attribute_instance->name] ?? null; // Use the package instance if it's a Package subclass, otherwise create a new instance $instance_class = is_a($class_name, Package::class, true) ? $pkg : $refClass->newInstance(); // validate package type matches $pkg_type_attr = match ($attribute->getName()) { Target::class => ['target', 'virtual-target'], Library::class => ['library'], Extension::class => ['php-extension'], Tool::class => ['tool'], default => null, }; if (!in_array($package_type, $pkg_type_attr, true)) { throw new RegistryException("Package [{$attribute_instance->name}] type mismatch: config type is [{$package_type}], but attribute type is [" . implode('|', $pkg_type_attr) . '].'); } if ($pkg instanceof Package && !PackageConfig::isPackageExists($pkg->getName())) { throw new RegistryException("Package [{$pkg->getName()}] config not found for class {$class}"); } // init method attributes $methods = $refClass->getMethods(\ReflectionMethod::IS_PUBLIC); foreach ($methods as $method) { $method_attributes = $method->getAttributes(); foreach ($method_attributes as $method_attribute) { $method_instance = $method_attribute->newInstance(); match ($method_attribute->getName()) { // #[BuildFor(PHP_OS_FAMILY)] BuildFor::class => self::addBuildFunction($pkg, $method_instance, [$instance_class, $method->getName()]), // #[BeforeBuild] PatchBeforeBuild::class => self::addPatchBeforeBuildFunction($pkg, [$instance_class, $method->getName()]), // #[CustomPhpConfigureArg(PHP_OS_FAMILY)] CustomPhpConfigureArg::class => self::bindCustomPhpConfigureArg($pkg, $method_attribute->newInstance(), [$instance_class, $method->getName()]), // #[Stage('stage_name')] Stage::class => self::addStage($method, $pkg, $instance_class, $method_instance), // #[InitPackage] (run now with package context) InitPackage::class => ApplicationContext::invoke([$instance_class, $method->getName()], ['package' => $pkg]), // #[InitBuild] ResolveBuild::class => $pkg instanceof TargetPackage ? $pkg->setResolveBuildCallback([$instance_class, $method->getName()]) : null, // #[Info] Info::class => $pkg->setInfoCallback([$instance_class, $method->getName()]), // #[Validate] Validate::class => $pkg->setValidateCallback([$instance_class, $method->getName()]), default => null, }; // Capture annotation metadata for inspection (dev:info, future event-trace commands) $meta_attr = self::annotationShortName($method_attribute->getName()); if ($meta_attr !== null) { self::$annotation_map[$pkg->getName()]['methods'][$method->getName()][] = [ 'attr' => $meta_attr, 'args' => self::annotationArgs($method_instance), ]; } } } // Record which class defines this package (set once; IS_REPEATABLE may loop more than once) self::$annotation_map[$pkg->getName()]['class'] ??= $class_name; // register package self::$packages[$pkg->getName()] = $pkg; } // For classes without package attributes, create a simple instance for non-package stage callbacks if (!isset($instance_class)) { $instance_class = $refClass->newInstance(); } // parse non-package available attributes foreach ($refClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { $method_attributes = $method->getAttributes(); foreach ($method_attributes as $method_attribute) { $method_instance = $method_attribute->newInstance(); match ($method_attribute->getName()) { // #[BeforeStage('package_name', 'stage')] and #[AfterStage('package_name', 'stage')] BeforeStage::class => self::addBeforeStage($method, $pkg ?? null, $instance_class, $method_instance), AfterStage::class => self::addAfterStage($method, $pkg ?? null, $instance_class, $method_instance), default => null, }; } } } /** * Get all registered before-stage callbacks (raw). * * @return array>> */ public static function getAllBeforeStages(): array { return self::$before_stages; } /** * Get all registered after-stage callbacks (raw). * * @return array>> */ public static function getAllAfterStages(): array { return self::$after_stages; } /** * Get annotation metadata for a specific package. * * Returns null if no annotation class was loaded for this package (config-only package). * The returned structure includes the defining class name, per-method attribute list, * inbound BeforeStage/AfterStage hooks targeting this package, and outbound hooks that * this package's class registers on other packages. * * @return null|array{ * class: string, * methods: array}>>, * before_stages: array>, * after_stages: array>, * outbound_before_stages: array>>, * outbound_after_stages: array>> * } */ public static function getPackageAnnotationInfo(string $name): ?array { $class_info = self::$annotation_map[$name] ?? null; if ($class_info === null) { return null; } $class = $class_info['class']; return [ 'class' => $class, 'methods' => $class_info['methods'], 'before_stages' => self::$before_stage_meta[$name] ?? [], 'after_stages' => self::$after_stage_meta[$name] ?? [], 'outbound_before_stages' => self::$class_before_stage_meta[$class] ?? [], 'outbound_after_stages' => self::$class_after_stage_meta[$class] ?? [], ]; } /** * Get all annotation metadata keyed by package name. * Useful for future event-trace commands or cross-package inspection. * * @return array */ public static function getAllAnnotations(): array { $result = []; foreach (self::$annotation_map as $name => $info) { $class = $info['class']; $result[$name] = [ 'class' => $class, 'methods' => $info['methods'], 'before_stages' => self::$before_stage_meta[$name] ?? [], 'after_stages' => self::$after_stage_meta[$name] ?? [], 'outbound_before_stages' => self::$class_before_stage_meta[$class] ?? [], 'outbound_after_stages' => self::$class_after_stage_meta[$class] ?? [], ]; } return $result; } public static function getBeforeStageCallbacks(string $package_name, string $stage): iterable { // match condition $installer = ApplicationContext::get(PackageInstaller::class); $stages = self::$before_stages[$package_name][$stage] ?? []; foreach ($stages as $entry) { $callback = $entry[0]; $only_when_package_resolved = $entry[1] ?? null; $conditionals = $entry[2] ?? []; if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) { continue; } foreach ($conditionals as $class) { if (!ApplicationContext::has($class)) { continue 2; } } yield $callback; } } public static function getAfterStageCallbacks(string $package_name, string $stage): array { // match condition $installer = ApplicationContext::get(PackageInstaller::class); $stages = self::$after_stages[$package_name][$stage] ?? []; $result = []; foreach ($stages as $entry) { $callback = $entry[0]; $only_when_package_resolved = $entry[1] ?? null; $conditionals = $entry[2] ?? []; if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) { continue; } foreach ($conditionals as $class) { if (!ApplicationContext::has($class)) { continue 2; } } $result[] = $callback; } return $result; } /** * Register default stages for all PhpExtensionPackage instances. * Should be called after all registries have been loaded. */ public static function registerAllDefaultStages(): void { foreach (self::$packages as $pkg) { if ($pkg instanceof PhpExtensionPackage) { $pkg->registerDefaultStages(); } elseif ($pkg instanceof LibraryPackage) { $pkg->registerDefaultStages(); } } } /** * Check loaded stage events for consistency. */ public static function checkLoadedStageEvents(): void { foreach (['BeforeStage' => self::$before_stages, 'AfterStage' => self::$after_stages] as $event_name => $ev_all) { foreach ($ev_all as $package_name => $stages) { // check package exists if (!self::hasPackage($package_name)) { throw new RegistryException( "{$event_name} event registered for unknown package [{$package_name}]." ); } $pkg = self::getPackage($package_name); foreach ($stages as $stage_name => $before_events) { foreach ($before_events as $entry) { $event_callable = $entry[0]; $only_when_package_resolved = $entry[1] ?? null; // check only_when_package_resolved package exists if ($only_when_package_resolved !== null && !self::hasPackage($only_when_package_resolved)) { throw new RegistryException("{$event_name} event in package [{$package_name}] for stage [{$stage_name}] has unknown only_when_package_resolved package [{$only_when_package_resolved}]."); } // check callable is valid if (!is_callable($event_callable)) { throw new RegistryException( "{$event_name} event in package [{$package_name}] for stage [{$stage_name}] has invalid callable.", ); } } // check stage exists // Skip validation if the package has no build function for current OS // (e.g., libedit has BeforeStage for 'build' but only BuildFor('Darwin'/'Linux')) if (!$pkg->hasStage($stage_name) && $pkg->hasBuildFunctionForCurrentOS()) { throw new RegistryException("Package stage [{$stage_name}] is not registered in package [{$package_name}]."); } } } } } /** * Bind a custom PHP configure argument callback to a php-extension package. */ private static function bindCustomPhpConfigureArg(Package $pkg, object $attr, callable $fn): void { if (!$pkg instanceof PhpExtensionPackage) { throw new RegistryException("Class [{$pkg->getName()}] must implement PhpExtensionPackage for CustomPhpConfigureArg attribute."); } $pkg->addCustomPhpConfigureArgCallback($attr->os, $fn); } private static function addBuildFunction(Package $pkg, object $attr, callable $fn): void { $pkg->addBuildFunction($attr->os, $fn); } private static function addPatchBeforeBuildFunction(Package $pkg, callable $fn): void { $pkg->addPatchBeforeBuildCallback($fn); } private static function addStage(\ReflectionMethod $method, Package $pkg, object $instance_class, object $method_instance): void { $name = $method_instance->function; if ($name === null) { $name = $method->getName(); } $pkg->addStage($name, [$instance_class, $method->getName()]); } private static function addBeforeStage(\ReflectionMethod $method, ?Package $pkg, mixed $instance_class, object $method_instance): void { /** @var BeforeStage $method_instance */ $stage = $method_instance->stage; $stage = match (true) { is_string($stage) => $stage, count($stage) === 2 => $stage[1], default => throw new RegistryException('Invalid stage definition in BeforeStage attribute.'), }; if ($method_instance->package_name === '' && $pkg === null) { throw new RegistryException('Package name must not be empty when no package context is available for BeforeStage attribute.'); } $package_name = $method_instance->package_name === '' ? $pkg->getName() : $method_instance->package_name; $conditionals = array_map( fn (\ReflectionAttribute $a) => $a->newInstance()->class, [...$method->getDeclaringClass()->getAttributes(ConditionalOn::class), ...$method->getAttributes(ConditionalOn::class)], ); self::$before_stages[$package_name][$stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved, $conditionals]; $registering_class = get_class($instance_class); self::$before_stage_meta[$package_name][$stage][] = [ 'class' => $registering_class, 'method' => $method->getName(), 'only_when' => $method_instance->only_when_package_resolved, 'conditionals' => $conditionals, ]; self::$class_before_stage_meta[$registering_class][$package_name][$stage][] = [ 'method' => $method->getName(), 'only_when' => $method_instance->only_when_package_resolved, ]; } private static function addAfterStage(\ReflectionMethod $method, ?Package $pkg, mixed $instance_class, object $method_instance): void { $stage = $method_instance->stage; $stage = match (true) { is_string($stage) => $stage, is_array($stage) && count($stage) === 2 => $stage[1], default => throw new RegistryException('Invalid stage definition in AfterStage attribute.'), }; if ($method_instance->package_name === '' && $pkg === null) { throw new RegistryException('Package name must not be empty when no package context is available for AfterStage attribute.'); } $package_name = $method_instance->package_name === '' ? $pkg->getName() : $method_instance->package_name; $conditionals = array_map( fn (\ReflectionAttribute $a) => $a->newInstance()->class, [...$method->getDeclaringClass()->getAttributes(ConditionalOn::class), ...$method->getAttributes(ConditionalOn::class)], ); self::$after_stages[$package_name][$stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved, $conditionals]; $registering_class = get_class($instance_class); self::$after_stage_meta[$package_name][$stage][] = [ 'class' => $registering_class, 'method' => $method->getName(), 'only_when' => $method_instance->only_when_package_resolved, 'conditionals' => $conditionals, ]; self::$class_after_stage_meta[$registering_class][$package_name][$stage][] = [ 'method' => $method->getName(), 'only_when' => $method_instance->only_when_package_resolved, ]; } /** * Map a fully-qualified attribute class name to a short display name for metadata storage. * Returns null for attributes that are not tracked in the annotation map. */ private static function annotationShortName(string $attr): ?string { return match ($attr) { Stage::class => 'Stage', BuildFor::class => 'BuildFor', PatchBeforeBuild::class => 'PatchBeforeBuild', CustomPhpConfigureArg::class => 'CustomPhpConfigureArg', InitPackage::class => 'InitPackage', ResolveBuild::class => 'ResolveBuild', Info::class => 'Info', Validate::class => 'Validate', ConditionalOn::class => 'ConditionalOn', default => null, }; } /** * Extract the meaningful constructor arguments from an attribute instance as a key-value array. * * @return array */ private static function annotationArgs(object $inst): array { return match (true) { $inst instanceof Stage => array_filter(['function' => $inst->function], fn ($v) => $v !== null), $inst instanceof BuildFor => ['os' => $inst->os], $inst instanceof CustomPhpConfigureArg => array_filter(['os' => $inst->os], fn ($v) => $v !== ''), default => [], }; } }