From d7afc2c4051dd3381bb426119979301266ee4b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 09:13:45 +0200 Subject: [PATCH 01/11] Add new classes for dependency injection: InvalidMethodArgumentException, MethodPlan, MethodPlanCache, ParameterPlanCompiler, and ParameterResolver --- src/Container.php | 48 ++ .../InvalidMethodArgumentException.php | 35 ++ src/Internal/Autowirer.php | 298 ++--------- src/Internal/DependencyPlanCache.php | 251 +-------- src/Internal/MethodInvoker.php | 488 ++++++++++++++++++ src/Internal/MethodInvoker2.php | 343 ++++++++++++ src/Internal/MethodPlan.php | 53 ++ src/Internal/MethodPlanCache.php | 160 ++++++ src/Internal/ParameterPlanCompiler.php | 257 +++++++++ src/Internal/ParameterResolver.php | 312 +++++++++++ src/Resolver.php | 127 ++++- src/Scope.php | 53 ++ 12 files changed, 1926 insertions(+), 499 deletions(-) create mode 100644 src/Exception/InvalidMethodArgumentException.php create mode 100644 src/Internal/MethodInvoker.php create mode 100644 src/Internal/MethodInvoker2.php create mode 100644 src/Internal/MethodPlan.php create mode 100644 src/Internal/MethodPlanCache.php create mode 100644 src/Internal/ParameterPlanCompiler.php create mode 100644 src/Internal/ParameterResolver.php diff --git a/src/Container.php b/src/Container.php index a7f9328..6e32645 100644 --- a/src/Container.php +++ b/src/Container.php @@ -843,6 +843,54 @@ public function tagged (string $tag):array { } + /** + * ### Invokes a callable using dependency injection + * + * Executes the specified callable by combining explicitly supplied arguments with dependencies automatically + * resolved from the container. + * + * The callable may be a closure, named function, instance method, static method, or invokable object. + * + * Scoped dependencies require invocation through an active resolution scope. + * + * + * $container->invoke( + * static function (LoggerInterface $logger, string $message):void { + * $logger->log($message); + * }, + * [ + * 'message' => 'Hello FireHub' + * ] + * ); + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection. + * + * @param callable $callback

+ * Callable to execute. + *

+ * @param array $arguments [optional]

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Value returned by the invoked callable. + */ + public function invoke (callable $callback, array $arguments = []):mixed { + + return $this->resolver->invoke( + $callback, + $arguments + ); + + } + /** * ### Resolves a registered service * diff --git a/src/Exception/InvalidMethodArgumentException.php b/src/Exception/InvalidMethodArgumentException.php new file mode 100644 index 0000000..29fae1c --- /dev/null +++ b/src/Exception/InvalidMethodArgumentException.php @@ -0,0 +1,35 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Indicates invalid explicit method invocation arguments + * + * Thrown when explicitly supplied callable arguments cannot be mapped to the declared parameter signature. + * + * This includes unknown parameter names, invalid positional indexes, duplicate parameter assignments, and + * invalid variadic argument collections. + * @since 1.0.0 + */ +final class InvalidMethodArgumentException extends InvalidArgumentException { + + /** + * ### Default exception message + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid method argument(s)'; + +} \ No newline at end of file diff --git a/src/Internal/Autowirer.php b/src/Internal/Autowirer.php index 6b8bf9b..d025509 100644 --- a/src/Internal/Autowirer.php +++ b/src/Internal/Autowirer.php @@ -14,17 +14,17 @@ namespace FireHub\Container\Internal; use FireHub\Container\Exception\ServiceInstantiationException; -use Closure, ReflectionIntersectionType, ReflectionUnionType; /** * ### Constructs services using compiled dependency plans * - * Executes cached dependency construction plans to instantiate services and resolve their constructor dependencies. + * Executes cached constructor dependency plans to instantiate services using the shared parameter resolver. * - * Static parameter analysis is performed by the dependency plan cache, while service availability, contextual - * bindings, and candidate selection are evaluated dynamically within the active resolution context. + * Static constructor analysis is performed by the dependency plan cache, while parameter bindings, contextual + * dependencies, service availability, and candidate selection are evaluated dynamically during construction. * - * Explicit parameter bindings take precedence over contextual bindings and automatic dependency resolution. + * Constructor dependencies are resolved within the active resolution context, preserving service lifetimes and + * circular dependency detection. * @since 1.0.0 * * @internal @@ -35,70 +35,54 @@ * ### Constructor * @since 1.0.0 * - * @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):object $resolve

- * Context-aware service resolver. - *

- * @param Closure(non-empty-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_tagged

- * Context-aware tagged service collection resolver. - *

- * @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_all

- * Context-aware service collection resolver. - *

- * @param Closure(class-string):bool $has

- * Explicit service registration checker. - *

- * @param \FireHub\Container\Internal\TypeMatcher $matcher

- * Dependency type matcher. - *

- * @param \FireHub\Container\Internal\CandidateSelector $selector

- * Dependency candidate selector. - *

* @param \FireHub\Container\Internal\DependencyPlanCache $plans

- * Shared dependency plan cache. + * Shared dependency plan cache containing compiled constructor dependency information. *

- * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

- * Shared parameter binding registry. - *

- * @param \FireHub\Container\Internal\ContextualCandidateSelector $contextual_selector

- * Shared contextual dependency selector. + * @param \FireHub\Container\Internal\ParameterResolver $parameters

+ * Shared parameter resolver responsible for resolving constructor dependencies. *

* * @return void */ public function __construct ( - private Closure $resolve, - private Closure $resolve_tagged, - private Closure $resolve_all, - private Closure $has, - private TypeMatcher $matcher, - private CandidateSelector $selector, private DependencyPlanCache $plans, - private ParameterBindingRegistry $bindings, - private ContextualCandidateSelector $contextual_selector + private ParameterResolver $parameters ) {} /** * ### Constructs a service using its compiled dependency plan * - * Resolves constructor dependencies dynamically while reusing previously compiled parameter information. + * Retrieves the compiled constructor dependency plan and creates a new instance of the specified implementation + * class. + * + * Classes without constructors are instantiated directly. For classes with constructors, each compiled parameter + * is resolved through the shared parameter resolver using the active dependency resolution context. + * + * The implementation class must be instantiable. Abstract classes and interfaces cannot be constructed. * @since 1.0.0 * - * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve the compiled construction plan. - * @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To get constructor metadata. + * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve the compiled constructor dependency + * plan. + * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve constructor parameter dependencies. * * @param class-string $class

- * Concrete service class. + * Concrete service implementation class to instantiate. *

* @param \FireHub\Container\Internal\ResolutionContext $context

- * Active dependency resolution context. + * Active dependency resolution context used to resolve constructor dependencies. *

* - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot - * be resolved. - * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not + * instantiable or a required constructor dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are + * eligible. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy + * its registered service type. * - * @return object Constructed service instance. + * @return object Newly constructed service instance. */ public function construct (string $class, ResolutionContext $context):object { @@ -115,7 +99,7 @@ public function construct (string $class, ResolutionContext $context):object { $arguments = []; foreach ($plan->parameters as $parameter) - $arguments[] = $this->resolveParameter( + $arguments[] = $this->parameters->resolve( $class, $parameter, $context @@ -125,222 +109,4 @@ public function construct (string $class, ResolutionContext $context):object { } - /** - * ### Resolves a compiled constructor parameter - * - * Resolves constructor arguments according to the following precedence: - * - * 1. Explicit parameter binding. - * 2. Contextual dependency binding. - * 3. Registered or implicitly constructible dependency. - * 4. Constructor default value. - * - * Contextual bindings apply only to named dependency types and the exact consumer implementation class. - * @since 1.0.0 - * - * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check explicit parameter bindings. - * @uses \FireHub\Container\Internal\ParameterBindingRegistry::get() To retrieve explicitly bound values. - * @uses \FireHub\Container\Internal\ContextualBindingRegistry::find() To find contextual dependency bindings. - * @uses \FireHub\Container\Internal\Autowirer::selectDependency() To select a dependency identifier. - * @uses \FireHub\Container\Internal\Autowirer::resolveDependency() To resolve selected dependencies. - * @uses \FireHub\Container\Internal\Autowirer::unresolvedParameter() To create resolution exceptions. - * - * @param class-string $class

- * Concrete service class. - *

- * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

- * @param \FireHub\Container\Internal\ResolutionContext $context

- * Active resolution context. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the dependency cannot be resolved. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. - * - * @return mixed Resolved argument. - */ - private function resolveParameter (string $class, ParameterPlan $plan, ResolutionContext $context):mixed { - - $name = $plan->parameter->getName(); - - if ($this->bindings->has($class, $name)) - return $this->bindings->get($class, $name); - - if ($plan->tag !== null) - return ($this->resolve_tagged)($plan->tag, $context); - - if ($plan->collection !== null) - return ($this->resolve_all)($plan->collection, $context); - - $id = $this->contextual_selector->select($class, $plan); - - if ($id !== null) - return $this->resolveDependency($id, $plan, $context, true); - - $id = $this->selectDependency($plan); - - if ($id !== null && ( - ($this->has)($id) - || $this->matcher->isConstructible($id) - ) - ) return $this->resolveDependency($id, $plan, $context); - - if ($plan->hasDefault) - return $plan->default; - - throw $this->unresolvedParameter($plan); - - } - - /** - * ### Selects a dependency identifier - * - * Determines the service identifier required by a constructor parameter. - * - * Named dependency types use their precompiled identifiers. Union and intersection dependency types are - * evaluated dynamically against the currently registered service implementations. - * @since 1.0.0 - * - * @uses \FireHub\Container\Internal\CandidateSelector::union() To select a union dependency. - * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select an intersection dependency. - * - * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

- * - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the union contains an unsupported - * intersection member. - * - * @return null|class-string Selected dependency identifier. - */ - private function selectDependency (ParameterPlan $plan):?string { - - return match (true) { - $plan->composite instanceof ReflectionUnionType => - $this->selector->union( - $plan->parameter, - $plan->composite - ), - $plan->composite instanceof ReflectionIntersectionType => - $this->selector->intersection( - $plan->parameter, - $plan->composite - ), - default => $plan->id - }; - - } - - /** - * ### Resolves a selected dependency - * - * Resolves the selected service identifier within the active resolution context. - * - * For union and intersection dependency types, validates that the resolved instance satisfies the complete - * declared parameter type. - * @since 1.0.0 - * - * @uses \FireHub\Container\Resolver::resolve() To resolve the selected dependency. - * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To validate composite dependency types. - * - * @param class-string $id

- * Selected dependency identifier. - *

- * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

- * @param \FireHub\Container\Internal\ResolutionContext $context

- * Active resolution context. - *

- * @param bool $contextual [optional]

- * Whether the dependency was selected through contextual binding. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the resolved dependency does not satisfy - * its declared composite type. - * - * @return object Resolved dependency. - */ - private function resolveDependency (string $id, ParameterPlan $plan, ResolutionContext $context, bool $contextual = false):object { - - $instance = ($this->resolve)($id, $context); - - if ( - $plan->composite !== null - && !$this->selector->matchesType( - $instance::class, - $plan->parameter, - $plan->composite - ) - ) throw new ServiceInstantiationException( - context: [ - 'class' => $plan->parameter->getDeclaringClass()?->getName(), - 'parameter' => $plan->parameter->getName(), - 'type' => (string)$plan->composite, - 'instance' => $instance::class, - ] - ); - - if ( - $contextual - && $plan->composite === null - && $plan->id !== null - ) { - - $expected = $plan->id; - - if (!$instance instanceof $expected) - throw new ServiceInstantiationException( - context: [ - 'class' => $plan->parameter->getDeclaringClass()?->getName(), - 'parameter' => $plan->parameter->getName(), - 'type' => $expected, - 'implementation' => $id, - 'instance' => $instance::class, - ] - ); - - } - - return $instance; - - } - - /** - * ### Creates an unresolved parameter exception - * - * Creates an exception containing diagnostic information about a constructor parameter that could not be - * resolved through explicit bindings, contextual bindings, registered services, implicit construction, or - * default values. - * @since 1.0.0 - * - * @param \FireHub\Container\Internal\ParameterPlan $plan

- * Compiled constructor parameter plan. - *

- * - * @return \FireHub\Container\Exception\ServiceInstantiationException Parameter resolution exception. - */ - private function unresolvedParameter (ParameterPlan $plan):ServiceInstantiationException { - - $parameter = $plan->parameter; - - $type = $parameter->getType(); - - return new ServiceInstantiationException( - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - 'position' => $parameter->getPosition(), - 'type' => $type?->__toString(), - 'nullable' => $type?->allowsNull(), - 'optional' => $parameter->isOptional(), - 'has_default' => $plan->hasDefault - ] - ); - - } - } \ No newline at end of file diff --git a/src/Internal/DependencyPlanCache.php b/src/Internal/DependencyPlanCache.php index 962b6d0..9f2a022 100644 --- a/src/Internal/DependencyPlanCache.php +++ b/src/Internal/DependencyPlanCache.php @@ -7,26 +7,21 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.2 + * @php-version >=8.1 * @package Container */ namespace FireHub\Container\Internal; -use FireHub\Container\Attribute\ { - InjectAll, Tagged -}; -use FireHub\Container\Exception\ServiceInstantiationException; -use FireHub\Runtime; -use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType; - /** - * ### Compiles and caches dependency construction plans + * ### Compiles and caches constructor dependency plans + * + * Analyzes constructor parameters once per implementation class and caches the resulting dependency plans. * - * Analyzes constructor parameters once per implementation class and stores the resulting dependency plans. + * Parameter compilation is delegated to the shared parameter plan compiler, allowing constructor and method + * injection to use identical parameter metadata and validation rules. * - * Compiled plans contain only static constructor information. Registered service availability, union and - * intersection candidate selection, and dependency instantiation remain dynamic. + * Registered service availability, contextual bindings, and dependency candidate selection remain dynamic. * @since 1.0.0 * * @internal @@ -48,35 +43,38 @@ final class DependencyPlanCache { * @param \FireHub\Container\Internal\ConstructorMetadataCache $metadata

* Shared constructor metadata cache. *

- * @param \FireHub\Container\Internal\TypeMatcher $matcher

- * Dependency type matcher. + * @param \FireHub\Container\Internal\ParameterPlanCompiler $compiler

+ * Shared parameter plan compiler responsible for analyzing dependency parameters. *

* * @return void */ - public function __construct( + public function __construct ( private readonly ConstructorMetadataCache $metadata, - private readonly TypeMatcher $matcher + private readonly ParameterPlanCompiler $compiler ) {} /** - * ### Gets a compiled dependency plan + * ### Gets a compiled constructor dependency plan + * + * Returns a previously compiled dependency plan or creates one by analyzing the constructor parameters of the + * specified implementation class. * - * Returns the cached dependency plan or compiles one from constructor reflection metadata. + * Variadic constructor parameters are excluded from dependency injection, preserving existing constructor + * autowiring behavior. * @since 1.0.0 * - * @uses \FireHub\Container\Internal\DependencyPlanCache::compileParameter() To compile constructor parameter - * metadata. * @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To retrieve constructor metadata. + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::compile() To compile constructor parameter metadata. * * @param class-string $class

- * Service implementation class. + * Service implementation class whose constructor dependency plan should be retrieved. *

* - * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared, - * the parameter type is unsupported, or the tag name is empty. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a constructor parameter contains invalid + * injection attributes or unsupported dependency metadata. * - * @return \FireHub\Container\Internal\DependencyPlan Compiled dependency plan. + * @return \FireHub\Container\Internal\DependencyPlan Compiled constructor dependency plan. */ public function get (string $class):DependencyPlan { @@ -92,7 +90,7 @@ public function get (string $class):DependencyPlan { if ($parameter->isVariadic()) continue; - $parameters[] = $this->compileParameter($parameter); + $parameters[] = $this->compiler->compile($parameter); } @@ -103,207 +101,4 @@ public function get (string $class):DependencyPlan { } - /** - * ### Compiles constructor parameter metadata - * - * Extracts dependency type information, default values, and tagged service injection metadata. - * @since 1.0.0 - * - * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers. - * @uses \FireHub\Container\Internal\DependencyPlanCache::resolveTag() To resolve tagged injection metadata. - * - * @param ReflectionParameter $parameter

- * Constructor parameter reflection. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared, - * the parameter type is unsupported, or the tag name is empty. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If tagged and collection injection are - * combined. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection attributes are - * declared, the parameter type is unsupported, or the requested service type does not exist. - * - * @return \FireHub\Container\Internal\ParameterPlan Compiled parameter plan. - */ - private function compileParameter (ReflectionParameter $parameter):ParameterPlan { - - $type = $parameter->getType(); - - $id = $type instanceof ReflectionNamedType - ? $this->matcher->classId($parameter, $type) : null; - - $composite = $type instanceof ReflectionUnionType - || $type instanceof ReflectionIntersectionType - ? $type : null; - - $has_default = $parameter->isDefaultValueAvailable(); - - $tag = $this->resolveTag($parameter); - $collection = $this->resolveCollection($parameter); - - if ($tag !== null && $collection !== null) - throw new ServiceInstantiationException( - 'Tagged and collection injection cannot be combined.', - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - ] - ); - - return new ParameterPlan( - $parameter, - $id, - $composite, - $has_default, - $has_default - ? $parameter->getDefaultValue() - : null, - $tag, - $collection - ); - - } - - /** - * ### Resolves tagged injection metadata - * - * Extracts and validates the tagged service injection attribute associated with a constructor parameter. - * - * Tagged injection requires a non-nullable array parameter and a non-empty tag name. - * @since 1.0.0 - * - * @uses \FireHub\Runtime\Arr\Inspection::count() To count the number of declared tagged attributes. - * @uses \FireHub\Runtime\Str\SB\Transform::trim() To trim whitespace from tag names. - * - * @param \ReflectionParameter $parameter

- * Constructor parameter reflection. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared, - * the parameter type is unsupported, or the tag name is empty. - * - * @return null|non-empty-string Tag name, or null if the parameter is not tagged. - */ - private function resolveTag (ReflectionParameter $parameter):?string { - - $attributes = $parameter->getAttributes(Tagged::class); - - if ($attributes === []) - return null; - - if (Runtime\Arr\Inspection::count($attributes) > 1) - throw new ServiceInstantiationException( - 'Multiple tagged attributes are not supported', - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - ] - ); - - $type = $parameter->getType(); - - if ( - !$type instanceof ReflectionNamedType - || $type->getName() !== 'array' - || $type->allowsNull() - ) throw new ServiceInstantiationException( - 'Tagged injection requires a non-nullable array parameter.', - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - ] - ); - - /** @var \FireHub\Container\Attribute\Tagged $attribute */ - $attribute = $attributes[0]->newInstance(); - - if (Runtime\Str\SB\Transform::trim($attribute->tag) === '') - throw new ServiceInstantiationException( - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - 'reason' => 'Tagged injection requires a non-empty tag name.' - ] - ); - - return $attribute->tag; - - } - - /** - * ### Resolves collection injection metadata - * - * Extracts and validates the collection injection attribute associated with a constructor parameter. - * - * Collection injection requires a non-nullable array parameter and an existing class or interface as its element - * type. - * @since 1.0.0 - * - * @uses \FireHub\Runtime\Arr\Inspection::count() To count the number of declared collection attributes. - * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check if the collection type is a class. - * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check if the collection type is an interface. - * - * @param \ReflectionParameter $parameter

- * Constructor parameter reflection. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection attributes are - * declared, the parameter type is unsupported, or the requested service type does not exist. - * - * @return null|class-string Requested collection element type. - */ - private function resolveCollection (ReflectionParameter $parameter):?string { - - $attributes = $parameter->getAttributes(InjectAll::class); - - if ($attributes === []) - return null; - - if (Runtime\Arr\Inspection::count($attributes) > 1) - throw new ServiceInstantiationException( - 'Multiple collection injection attributes are not supported.', - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - ] - ); - - $type = $parameter->getType(); - - if ( - !$type instanceof ReflectionNamedType - || $type->getName() !== 'array' - || $type->allowsNull() - ) throw new ServiceInstantiationException( - 'Collection injection requires a non-nullable array parameter.', - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - ] - ); - - /** @var \FireHub\Container\Attribute\InjectAll $attribute */ - $attribute = $attributes[0]->newInstance(); - - $collection = $attribute->type; - - if ( - $collection === '' - || ( - !Runtime\ObjectModel\Inspection::isClass($collection) - && !Runtime\ObjectModel\Inspection::isInterface($collection) - ) - ) throw new ServiceInstantiationException( - 'Collection injection requires an existing class or interface.', - context: [ - 'class' => $parameter->getDeclaringClass()?->getName(), - 'parameter' => $parameter->getName(), - 'type' => $collection, - ] - ); - - return $collection; - - } - } \ No newline at end of file diff --git a/src/Internal/MethodInvoker.php b/src/Internal/MethodInvoker.php new file mode 100644 index 0000000..e60cc02 --- /dev/null +++ b/src/Internal/MethodInvoker.php @@ -0,0 +1,488 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Exception\InvalidMethodArgumentException; +use FireHub\Runtime; +use Closure, ReflectionFunction, ReflectionMethod, ReflectionParameter; + +/** + * ### Invokes callables using dependency injection + * + * Executes closures, named functions, instance methods, static methods, and invokable objects by combining + * explicitly supplied arguments with dependencies resolved through the shared parameter resolver. + * + * Positional arguments are associated with their declared parameter positions, while named arguments are + * associated with their corresponding parameter names. + * + * Explicit arguments take precedence over dependency injection. Missing arguments are resolved within the active + * resolution context, preserving service lifetimes and circular dependency detection. + * + * Constructor parameter bindings are excluded from method invocation. + * @since 1.0.0 + * + * @internal + */ +final readonly class MethodInvoker { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\MethodPlanCache $plans

+ * Shared cache containing compiled callable invocation plans. + *

+ * @param \FireHub\Container\Internal\ParameterResolver $parameters

+ * Shared parameter resolver responsible for resolving missing callable arguments. + *

+ * + * @return void + */ + public function __construct ( + private MethodPlanCache $plans, + private ParameterResolver $parameters + ) {} + + /** + * ### Invokes a callable using dependency injection + * + * Retrieves the compiled invocation plan, normalizes explicit arguments, resolves missing dependencies, and + * executes the callable. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::plan() To retrieve the callable invocation plan. + * @uses \FireHub\Container\Internal\MethodInvoker::normalizeArguments() To normalize explicit arguments. + * @uses \FireHub\Container\Internal\MethodInvoker::resolveArguments() To resolve callable arguments. + * + * @param callable $callback

+ * Callable to execute using dependency injection. + *

+ * @param array $arguments

+ * Explicit positional and named arguments. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active dependency resolution context. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Value returned by the callable. + */ + public function invoke (callable $callback, array $arguments, ResolutionContext $context):mixed { + + $plan = $this->plan($callback); + + [$positional, $named] = $this->normalizeArguments($plan, $arguments); + + return $callback( + ...$this->resolveArguments($plan, $positional, $named, $context) + ); + + } + + /** + * ### Resolves callable arguments + * + * Combines explicit positional and named arguments with automatically resolved dependencies. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::resolveParameter() To resolve individual parameters. + * @uses \FireHub\Container\Internal\MethodInvoker::resolveVariadic() To resolve variadic parameters. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active dependency resolution context. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return list Resolved callable arguments. + */ + private function resolveArguments (MethodPlan $plan, array $positional, array $named, ResolutionContext $context):array { + + $resolved = []; + + foreach ($plan->parameters as $parameter) { + + if ($parameter->parameter->isVariadic()) { + + foreach ($this->resolveVariadic($parameter, $positional, $named) as $value) + $resolved[] = $value; + + continue; + + } + + $resolved[] = $this->resolveParameter( + $plan, + $parameter, + $positional, + $named, + $context + ); + + } + + return $resolved; + + } + + /** + * ### Resolves an individual callable parameter + * + * Returns an explicitly supplied named or positional argument when available. Otherwise, resolves the parameter + * through the shared dependency resolver. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve missing dependencies. + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled parameter metadata. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active dependency resolution context. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Resolved parameter value. + */ + private function resolveParameter (MethodPlan $plan, ParameterPlan $parameter, array $positional, array $named, ResolutionContext $context):mixed { + + $reflection = $parameter->parameter; + + $name = $reflection->getName(); + $position = $reflection->getPosition(); + + if (Runtime\Arr\Access::keyExists($named, $name)) + return $named[$name]; // @phpstan-ignore offsetAccess.notFound + + if (Runtime\Arr\Access::keyExists($positional, $position)) + return $positional[$position]; // @phpstan-ignore offsetAccess.notFound + + return $this->parameters->resolve( + $plan->consumer, + $parameter, + $context, + use_bindings: false + ); + + } + + /** + * ### Resolves variadic callable arguments + * + * Retrieves variadic values from an explicitly supplied named list or from remaining positional arguments. + * + * Variadic parameters are not automatically resolved through dependency injection. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * + * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled variadic parameter metadata. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

+ * + * @return list Explicit variadic argument values. + */ + private function resolveVariadic (ParameterPlan $parameter, array $positional, array $named):array { + + $reflection = $parameter->parameter; + + $name = $reflection->getName(); + + if (Runtime\Arr\Access::keyExists($named, $name)) { + + /** @var list $values */ + $values = $named[$name]; // @phpstan-ignore offsetAccess.notFound + + return $values; + + } + + $values = []; + + for ( + $index = $reflection->getPosition(); + Runtime\Arr\Access::keyExists($positional, $index); + $index++ + ) $values[] = $positional[$index]; // @phpstan-ignore offsetAccess.notFound + + return $values; + + } + + /** + * ### Normalizes explicit callable arguments + * + * Separates positional and named arguments and delegates signature validation to the argument validator. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::validateArguments() To validate argument assignments. + * @uses \FireHub\Runtime\DataIs::int() To check if the argument is an integer. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param array $arguments

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If arguments are invalid. + * + * @return array{ + * 0: array, + * 1: array + * } Normalized argument collections. + */ + private function normalizeArguments (MethodPlan $plan, array $arguments):array { + + $positional = []; + $named = []; + + $next = 0; + $named_started = false; + + foreach ($arguments as $key => $value) { + + if (Runtime\DataIs::int($key)) { + + if ($named_started || $key !== $next) + throw new InvalidMethodArgumentException( + 'Positional arguments must be consecutive and precede named arguments.' + ); + + $positional[$key] = $value; + + $next++; + + continue; + + } + + $named_started = true; + + $named[$key] = $value; + + } + + $this->validateArguments($plan, $positional, $named); + + return [$positional, $named]; + + } + + /** + * ### Validates explicit callable arguments + * + * Ensures positional arguments do not exceed the callable signature and validates each named argument. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::validateNamedArgument() To validate named arguments. + * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param array $named

+ * Explicit named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If argument assignments are invalid. + * + * @return void + */ + private function validateArguments (MethodPlan $plan, array $positional, array $named):void { + + $names = []; + $variadic = null; + + foreach ($plan->parameters as $parameter) { + + $reflection = $parameter->parameter; + + $names[$reflection->getName()] = $reflection->getPosition(); + + if ($reflection->isVariadic()) + $variadic = $reflection; + + } + + if ( + $variadic === null + && Runtime\Arr\Inspection::count($positional) > Runtime\Arr\Inspection::count($plan->parameters) + ) throw new InvalidMethodArgumentException( + 'Too many positional arguments were supplied.' + ); + + foreach ($named as $name => $value) + $this->validateNamedArgument( + $name, + $value, + $names, + $positional, + $variadic + ); + + } + + /** + * ### Validates an explicitly named argument + * + * Checks that the argument references an existing parameter, has not yet been supplied positionally, and + * satisfies variadic argument requirements. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters. + * @uses \FireHub\Runtime\Arr\Inspection::isList() To check if a value is a list. + * @uses \FireHub\Runtime\DataIs::array() To check if the value is an array. + * + * @param string $name

+ * Explicit argument name. + *

+ * @param mixed $value

+ * Explicit argument value. + *

+ * @param array $names

+ * Declared parameter names mapped to their positions. + *

+ * @param array $positional

+ * Explicit positional arguments. + *

+ * @param null|\ReflectionParameter $variadic

+ * Variadic parameter metadata, or null when the callable has no variadic parameter. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If the named argument is invalid. + * + * @return void + */ + private function validateNamedArgument (string $name, mixed $value, array $names, array $positional, ?ReflectionParameter $variadic):void { + + if (!Runtime\Arr\Access::keyExists($names, $name)) + throw new InvalidMethodArgumentException( + "Unknown callable argument: {$name}." + ); + + $position = $names[$name]; // @phpstan-ignore offsetAccess.notFound + + if (Runtime\Arr\Access::keyExists($positional, $position)) + throw new InvalidMethodArgumentException( + "Callable argument '{$name}' was supplied more than once." + ); + + if ($variadic === null || $name !== $variadic->getName()) + return; + + if (!Runtime\DataIs::array($value) || !Runtime\Arr\Inspection::isList($value)) + throw new InvalidMethodArgumentException( + "Named variadic argument '{$name}' must be a list." + ); + + if (Runtime\Arr\Inspection::count($positional) > $position) + throw new InvalidMethodArgumentException( + "Variadic argument '{$name}' cannot be supplied both positionally and by name." + ); + + } + + /** + * ### Retrieves a callable invocation plan + * + * Determines the callable category and retrieves its compiled invocation plan. + * + * Method callables retain their declaring class for contextual dependency resolution. Standalone functions and + * closures do not have a consumer class. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodPlanCache::method() To retrieve compiled method plans. + * @uses \FireHub\Container\Internal\MethodPlanCache::function() To retrieve compiled function plans. + * @uses \FireHub\Runtime\Str\SB\Search::contains() To check for class method syntax. + * @uses \FireHub\Runtime\DataIs::array() To check if the callable is an array. + * @uses \FireHub\Runtime\DataIs::object() To check if the callable is an object. + * @uses \FireHub\Runtime\DataIs::string() To check if the callable is a string. + * + * @param callable $callback

+ * Callable whose invocation metadata should be retrieved. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the callable cannot be compiled. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan. + */ + private function plan (callable $callback):MethodPlan { + + if (Runtime\DataIs::array($callback)) + return $this->plans->method( + new ReflectionMethod($callback[0], $callback[1]) + ); + + if (Runtime\DataIs::string($callback) && Runtime\Str\SB\Search::contains('::', $callback)) + return $this->plans->method( + new ReflectionMethod($callback) + ); + + if (Runtime\DataIs::object($callback) && !$callback instanceof Closure) + return $this->plans->method( + new ReflectionMethod($callback, '__invoke') + ); + + if (Runtime\DataIs::string($callback)) + return $this->plans->function( + new ReflectionFunction($callback) + ); + + return $this->plans->function( + new ReflectionFunction(Closure::fromCallable($callback)) + ); + + } + +} \ No newline at end of file diff --git a/src/Internal/MethodInvoker2.php b/src/Internal/MethodInvoker2.php new file mode 100644 index 0000000..a0633bf --- /dev/null +++ b/src/Internal/MethodInvoker2.php @@ -0,0 +1,343 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Runtime; +use Closure, InvalidArgumentException, ReflectionFunction, ReflectionMethod; + +/** + * ### Invokes callables using dependency injection + * + * Executes closures, named functions, instance methods, static methods, and invokable objects by combining + * explicitly supplied arguments with dependencies resolved through the shared parameter resolver. + * + * Positional arguments are associated with their declared parameter positions, while named arguments are + * associated with their corresponding parameter names. + * + * Explicit arguments take precedence over dependency injection. Missing arguments are resolved within the active + * resolution context, preserving service lifetimes and circular dependency detection. + * + * Constructor parameter bindings are excluded from method invocation. + * @since 1.0.0 + * + * @internal + */ +final readonly class MethodInvoker2 { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\MethodPlanCache $plans

+ * Shared cache containing compiled callable invocation plans. + *

+ * @param \FireHub\Container\Internal\ParameterResolver $parameters

+ * Shared parameter resolver responsible for resolving missing callable arguments. + *

+ * + * @return void + */ + public function __construct ( + private MethodPlanCache $plans, + private ParameterResolver $parameters + ) {} + + /** + * ### Invokes a callable using dependency injection + * + * Retrieves or compiles the callable invocation plan, validates explicit arguments, resolves missing + * dependencies, and executes the callable. + * + * Positional arguments are mapped to declared parameter positions. Named arguments are mapped to their + * corresponding parameter names. Variadic parameters accept additional positional arguments or an explicitly + * supplied list of values. + * + * Constructor parameter bindings are not applied during invocation. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::plan() To retrieve the callable invocation plan. + * @uses \FireHub\Container\Internal\MethodInvoker::normalizeArguments() To validate explicit arguments. + * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve missing parameter dependencies. + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * + * @param callable $callback

+ * Closure, named function, instance method, static method, or invokable object to execute. + *

+ * @param array $arguments

+ * Explicit positional and named arguments supplied to the callable. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active dependency resolution context. + *

+ * + * @throws \InvalidArgumentException If explicit arguments contain unknown names, invalid positional indexes, + * duplicate assignments, or invalid variadic values. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a required dependency cannot be + * resolved. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are + * eligible. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. + * @throws \FireHub\Container\Exception\ScopeClosedException If the active scope has been closed. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the callable cannot be compiled. + * + * @return mixed Value returned by the invoked callable. + */ + public function invoke (callable $callback, array $arguments, ResolutionContext $context):mixed { + + $plan = $this->plan($callback); + + [$positional, $named] = $this->normalizeArguments( + $plan, + $arguments + ); + + $resolved = []; + + foreach ($plan->parameters as $parameter) { + + $reflection = $parameter->parameter; + + $name = $reflection->getName(); + $position = $reflection->getPosition(); + + if ($reflection->isVariadic()) { + + if (Runtime\Arr\Access::keyExists($named, $name)) { + + /** @var list $values */ + $values = $named[$name]; // @phpstan-ignore offsetAccess.notFound + + foreach ($values as $value) + $resolved[] = $value; + + } else { + + for ($index = $position; Runtime\Arr\Access::keyExists($positional, $index); $index++) + $resolved[] = $positional[$index]; // @phpstan-ignore offsetAccess.notFound + + } + + continue; + + } + + if (Runtime\Arr\Access::keyExists($named, $name)) { + + $resolved[] = $named[$name]; // @phpstan-ignore offsetAccess.notFound + + continue; + + } + + if (Runtime\Arr\Access::keyExists($positional, $position)) { + + $resolved[] = $positional[$position]; // @phpstan-ignore offsetAccess.notFound + + continue; + + } + + $resolved[] = $this->parameters->resolve( + $plan->consumer, + $parameter, + $context, + use_bindings: false + ); + + } + + return $callback(...$resolved); + + } + + /** + * ### Retrieves a callable invocation plan + * + * Determines the callable category and retrieves its compiled invocation plan. + * + * Method callables retain their declaring class for contextual dependency resolution. Standalone functions and + * closures do not have a consumer class. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodPlanCache::method() To retrieve compiled method plans. + * @uses \FireHub\Container\Internal\MethodPlanCache::function() To retrieve compiled function plans. + * @uses \FireHub\Runtime\Str\SB\Search::contains() To check for class method syntax. + * @uses \FireHub\Runtime\DataIs::array() To check if the callable is an array. + * @uses \FireHub\Runtime\DataIs::object() To check if the callable is an object. + * @uses \FireHub\Runtime\DataIs::string() To check if the callable is a string. + * + * @param callable $callback

+ * Callable whose invocation metadata should be retrieved. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the callable cannot be compiled. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan. + */ + private function plan (callable $callback):MethodPlan { + + if (Runtime\DataIs::array($callback)) + return $this->plans->method( + new ReflectionMethod($callback[0], $callback[1]) + ); + + if (Runtime\DataIs::string($callback) && Runtime\Str\SB\Search::contains('::', $callback)) + return $this->plans->method( + new ReflectionMethod($callback) + ); + + if (Runtime\DataIs::object($callback) && !$callback instanceof Closure) + return $this->plans->method( + new ReflectionMethod($callback, '__invoke') + ); + + if (Runtime\DataIs::string($callback)) + return $this->plans->function( + new ReflectionFunction($callback) + ); + + return $this->plans->function( + new ReflectionFunction(Closure::fromCallable($callback)) + ); + + } + + /** + * ### Normalizes explicit callable arguments + * + * Separates positional and named arguments while validating their correspondence to the callable signature. + * + * Positional arguments must use consecutive integer indexes beginning at zero. Named arguments must reference + * declared parameter names. Assigning the same parameter through both positional and named arguments is + * prohibited. + * + * A named variadic argument must contain a list of values. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\DataIs::int() To check if the argument is an integer. + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. + * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters. + * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if a value is in a list. + * @uses \FireHub\Runtime\Arr\Inspection::isList() To check if a value is a list. + * + * @param \FireHub\Container\Internal\MethodPlan $plan

+ * Compiled callable invocation plan used to validate parameter names and positions. + *

+ * @param array $arguments

+ * Explicit positional and named arguments to normalize. + *

+ * + * @throws \InvalidArgumentException If an argument name or position is invalid, a parameter is assigned more + * than once, or a named variadic argument does not contain a list. + * + * @return array{ + * 0: array, + * 1: array + * } Normalized positional and named argument collections. + */ + private function normalizeArguments (MethodPlan $plan, array $arguments):array { + + $positional = []; + $named = []; + + $names = []; + $variadic = null; + + foreach ($plan->parameters as $parameter) { + + $reflection = $parameter->parameter; + + $names[$reflection->getName()] = $reflection->getPosition(); + + if ($reflection->isVariadic()) + $variadic = $reflection; + + } + + $next = 0; + $named_started = false; + + foreach ($arguments as $key => $value) { + + if (Runtime\DataIs::int($key)) { + + if ($named_started) + throw new InvalidArgumentException( + 'Positional arguments cannot follow named arguments.' + ); + + if ($key !== $next) + throw new InvalidArgumentException( + 'Positional argument indexes must be consecutive and start at zero.' + ); + + $positional[$key] = $value; + + $next++; + + continue; + + } + + $named_started = true; + + if (!Runtime\Arr\Access::keyExists($names, $key)) + throw new InvalidArgumentException( + "Unknown callable argument: {$key}." + ); + + $named[$key] = $value; + + } + + $count = Runtime\Arr\Inspection::count($plan->parameters); + + if ($variadic === null && Runtime\Arr\Inspection::count($positional) > $count) + throw new InvalidArgumentException( + 'Too many positional arguments were supplied.' + ); + + foreach ($named as $name => $value) { + + $position = $names[$name]; // @phpstan-ignore offsetAccess.notFound + + if (Runtime\Arr\Access::keyExists($positional, $position)) + throw new InvalidArgumentException( + "Callable argument '{$name}' was supplied more than once." + ); + + if ( + $variadic !== null + && $name === $variadic->getName() + ) { + + if (!Runtime\DataIs::array($value) || !Runtime\Arr\Inspection::isList($value)) + throw new InvalidArgumentException( + "Named variadic argument '{$name}' must be a list." + ); + + if (Runtime\Arr\Inspection::count($positional) > $position) + throw new InvalidArgumentException( + "Variadic argument '{$name}' cannot be supplied both positionally and by name." + ); + + } + + } + + return [$positional, $named]; + + } + +} \ No newline at end of file diff --git a/src/Internal/MethodPlan.php b/src/Internal/MethodPlan.php new file mode 100644 index 0000000..94032f6 --- /dev/null +++ b/src/Internal/MethodPlan.php @@ -0,0 +1,53 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use ReflectionFunctionAbstract; + +/** + * ### Represents a compiled callable invocation plan + * + * Stores the reflection metadata and compiled dependency parameters required to invoke a method or closure. + * + * The plan contains only static callable information. Explicit arguments, contextual bindings, dependency + * availability, and service resolution are evaluated dynamically during invocation. + * @since 1.0.0 + * + * @internal + */ +final readonly class MethodPlan { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \ReflectionFunctionAbstract $reflection

+ * Reflection metadata describing the callable associated with this invocation plan. + *

+ * @param list<\FireHub\Container\Internal\ParameterPlan> $parameters

+ * Compiled dependency parameter plans in their original declaration order. + *

+ * @param null|class-string $consumer

+ * Declaring class used for contextual dependency resolution, or null for standalone functions and closures. + *

+ * + * @return void + */ + public function __construct ( + public ReflectionFunctionAbstract $reflection, + public array $parameters, + public ?string $consumer + ) {} + +} \ No newline at end of file diff --git a/src/Internal/MethodPlanCache.php b/src/Internal/MethodPlanCache.php new file mode 100644 index 0000000..f82550c --- /dev/null +++ b/src/Internal/MethodPlanCache.php @@ -0,0 +1,160 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use ReflectionFunction, ReflectionFunctionAbstract, ReflectionMethod; + +/** + * ### Compiles and caches callable invocation plans + * + * Analyzes callable reflection metadata and compiles dependency parameter plans using the shared parameter + * compiler. + * + * Method plans are cached by their declaring class and method name. Named function caches by function name, while + * closures are compiled independently to avoid retaining closure instances. + * + * Registered service availability, contextual bindings, and dependency candidate selection remain dynamic. + * @since 1.0.0 + * + * @internal + */ +final class MethodPlanCache { + + /** + * ### Cached method invocation plans + * @since 1.0.0 + * + * @var array + */ + private array $plans = []; + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\ParameterPlanCompiler $compiler

+ * Shared parameter plan compiler used to analyze callable parameters. + *

+ * + * @return void + */ + public function __construct ( + private readonly ParameterPlanCompiler $compiler + ) {} + + /** + * ### Gets a compiled method invocation plan + * + * Retrieves a cached invocation plan for the specified method or compiles one when no cached plan exists. + * + * The cache key uses the declaring class and canonical method name, allowing inherited methods to reuse their + * compiled metadata. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodPlanCache::compile() To compile callable reflection metadata. + * + * @param \ReflectionMethod $method

+ * Reflected instance or static method to analyze. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported + * injection metadata. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled method invocation plan. + */ + public function method (ReflectionMethod $method):MethodPlan { + + $key = $method->getDeclaringClass()->getName() + . '::' + . $method->getName(); + + return $this->plans[$key] ??= $this->compile( + $method, + $method->getDeclaringClass()->getName() + ); + + } + + /** + * ### Gets a compiled function invocation plan + * + * Retrieves a cached plan for a named function or compiles a new plan for a closure. + * + * Closure invocation plans are not stored in the shared cache. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodPlanCache::compile() To compile callable reflection metadata. + * + * @param \ReflectionFunction $function

+ * Reflected named function or closure to analyze. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported + * injection metadata. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled function invocation plan. + */ + public function function (ReflectionFunction $function):MethodPlan { + + if ($function->isClosure()) + return $this->compile($function, null); + + $key = 'function:' . $function->getName(); + + return $this->plans[$key] ??= $this->compile( + $function, + null + ); + + } + + /** + * ### Compiles callable invocation metadata + * + * Analyzes callable parameters and creates an immutable invocation plan. + * + * All parameters are retained in their declaration order, including variadic parameters. Variadic argument + * expansion is handled by the method invoker rather than the parameter compiler. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::compile() To compile callable parameter metadata. + * + * @param \ReflectionFunctionAbstract $reflection

+ * Reflected callable whose parameters should be compiled. + *

+ * @param null|class-string $consumer

+ * Declaring class used for contextual dependency resolution, or null for standalone functions and closures. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported + * injection metadata. + * + * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan. + */ + private function compile (ReflectionFunctionAbstract $reflection, ?string $consumer):MethodPlan { + + $parameters = []; + + foreach ($reflection->getParameters() as $parameter) + $parameters[] = $this->compiler->compile($parameter); + + return new MethodPlan( + $reflection, + $parameters, + $consumer + ); + + } + +} \ No newline at end of file diff --git a/src/Internal/ParameterPlanCompiler.php b/src/Internal/ParameterPlanCompiler.php new file mode 100644 index 0000000..381f242 --- /dev/null +++ b/src/Internal/ParameterPlanCompiler.php @@ -0,0 +1,257 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.0 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Attribute\ { + InjectAll, Tagged +}; +use FireHub\Container\Exception\ServiceInstantiationException; +use FireHub\Runtime; +use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType; + +/** + * ### Compiles dependency parameter metadata + * + * Analyzes reflected parameters and produces immutable parameter plans used by constructor and method injection. + * + * Compiled plans contain statically determined dependency identifiers, composite dependency types, default values, + * tagged service injection metadata, and collection injection metadata. + * + * Service availability, contextual bindings, and dependency candidate selection are evaluated dynamically during + * parameter resolution rather than compilation. + * @since 1.0.0 + * + * @internal + */ +final readonly class ParameterPlanCompiler { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\TypeMatcher $matcher

+ * Shared dependency type matcher used to resolve named dependency identifiers. + *

+ * + * @return void + */ + public function __construct ( + private TypeMatcher $matcher + ) {} + + /** + * ### Compiles dependency parameter metadata + * + * Extracts the declared parameter type, default value, tagged injection metadata, and collection injection + * metadata into an immutable parameter plan. + * + * Named dependency types are converted into service identifiers. Union and intersection types retain their + * reflection metadata for dynamic dependency candidate selection. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers. + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::resolveTag() To resolve tagged injection metadata. + * @uses \FireHub\Container\Internal\ParameterPlanCompiler::resolveCollection() To resolve collection injection + * metadata. + * + * @param \ReflectionParameter $parameter

+ * Reflected constructor or method parameter to compile. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If tagged and collection injection + * attributes are combined or an injection attribute is invalid. + * + * @return \FireHub\Container\Internal\ParameterPlan Compiled dependency parameter plan. + */ + public function compile (ReflectionParameter $parameter):ParameterPlan { + + $type = $parameter->getType(); + + $id = $type instanceof ReflectionNamedType + ? $this->matcher->classId($parameter, $type) : null; + + $composite = $type instanceof ReflectionUnionType + || $type instanceof ReflectionIntersectionType + ? $type : null; + + $has_default = $parameter->isDefaultValueAvailable(); + + $tag = $this->resolveTag($parameter); + $collection = $this->resolveCollection($parameter); + + if ($tag !== null && $collection !== null) + throw new ServiceInstantiationException( + 'Tagged and collection injection cannot be combined.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + return new ParameterPlan( + $parameter, + $id, + $composite, + $has_default, + $has_default ? $parameter->getDefaultValue() : null, + $tag, + $collection + ); + + } + + /** + * ### Resolves tagged injection metadata + * + * Extracts and validates the tagged injection attribute associated with a reflected parameter. + * + * Tagged injection requires a non-nullable array parameter and a non-empty tag name. Multiple tagged injection + * attributes on the same parameter are not supported. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To count declared tagged injection attributes. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To validate the tag name. + * + * @param \ReflectionParameter $parameter

+ * Reflected parameter to inspect for tagged injection metadata. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged injection attributes + * are declared, the parameter type is unsupported, or the tag name is empty. + * + * @return null|non-empty-string Tag name, or null when tagged injection is not declared. + */ + private function resolveTag (ReflectionParameter $parameter):?string { + + $attributes = $parameter->getAttributes(Tagged::class); + + if ($attributes === []) + return null; + + if (Runtime\Arr\Inspection::count($attributes) > 1) + throw new ServiceInstantiationException( + 'Multiple tagged attributes are not supported', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + $type = $parameter->getType(); + + if ( + !$type instanceof ReflectionNamedType + || $type->getName() !== 'array' + || $type->allowsNull() + ) throw new ServiceInstantiationException( + 'Tagged injection requires a non-nullable array parameter.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + /** @var \FireHub\Container\Attribute\Tagged $attribute */ + $attribute = $attributes[0]->newInstance(); + + if (Runtime\Str\SB\Transform::trim($attribute->tag) === '') + throw new ServiceInstantiationException( + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + 'reason' => 'Tagged injection requires a non-empty tag name.' + ] + ); + + return $attribute->tag; + + } + + /** + * ### Resolves collection injection metadata + * + * Extracts and validates the collection injection attribute associated with a reflected parameter. + * + * Collection injection requires a non-nullable array parameter and an existing class or interface as its element + * type. Multiple collection injection attributes on the same parameter are not supported. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To count declared collection injection attributes. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check whether the element type is a class. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check whether the element type is an interface. + * + * @param \ReflectionParameter $parameter

+ * Reflected parameter to inspect for collection injection metadata. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection injection + * attributes are declared, the parameter type is unsupported, or the requested element type does not exist. + * + * @return null|class-string Requested collection element type, or null when collection injection is not declared. + */ + private function resolveCollection (ReflectionParameter $parameter):?string { + + $attributes = $parameter->getAttributes(InjectAll::class); + + if ($attributes === []) + return null; + + if (Runtime\Arr\Inspection::count($attributes) > 1) + throw new ServiceInstantiationException( + 'Multiple collection injection attributes are not supported.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + $type = $parameter->getType(); + + if ( + !$type instanceof ReflectionNamedType + || $type->getName() !== 'array' + || $type->allowsNull() + ) throw new ServiceInstantiationException( + 'Collection injection requires a non-nullable array parameter.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + ] + ); + + /** @var \FireHub\Container\Attribute\InjectAll $attribute */ + $attribute = $attributes[0]->newInstance(); + + $collection = $attribute->type; + + if ( + $collection === '' + || ( + !Runtime\ObjectModel\Inspection::isClass($collection) + && !Runtime\ObjectModel\Inspection::isInterface($collection) + ) + ) throw new ServiceInstantiationException( + 'Collection injection requires an existing class or interface.', + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + 'type' => $collection, + ] + ); + + return $collection; + + } + +} \ No newline at end of file diff --git a/src/Internal/ParameterResolver.php b/src/Internal/ParameterResolver.php new file mode 100644 index 0000000..003369e --- /dev/null +++ b/src/Internal/ParameterResolver.php @@ -0,0 +1,312 @@ +=8.0 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Exception\ServiceInstantiationException; +use Closure, ReflectionIntersectionType, ReflectionUnionType; + +/** + * ### Resolves compiled dependency parameters + * + * Resolves parameter dependencies using explicit bindings, contextual bindings, tagged services, collection + * injection, and automatic dependency selection. + * + * The resolver operates within an existing dependency resolution context and does not manage service lifetimes + * directly. + * @since 1.0.0 + * + * @internal + */ +final readonly class ParameterResolver { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):object $resolve

+ * Context-aware service resolver. + *

+ * @param Closure(non-empty-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_tagged

+ * Context-aware tagged service collection resolver. + *

+ * @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_all

+ * Context-aware service collection resolver. + *

+ * @param Closure(class-string):bool $has

+ * Explicit service registration checker. + *

+ * @param \FireHub\Container\Internal\TypeMatcher $matcher

+ * Dependency type matcher. + *

+ * @param \FireHub\Container\Internal\CandidateSelector $selector

+ * Dependency candidate selector. + *

+ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

+ * Shared parameter binding registry. + *

+ * @param \FireHub\Container\Internal\ContextualCandidateSelector $contextual_selector

+ * Contextual dependency candidate selector. + *

+ * + * @return void + */ + public function __construct ( + private Closure $resolve, + private Closure $resolve_tagged, + private Closure $resolve_all, + private Closure $has, + private TypeMatcher $matcher, + private CandidateSelector $selector, + private ParameterBindingRegistry $bindings, + private ContextualCandidateSelector $contextual_selector + ) {} + + /** + * ### Resolves a compiled dependency parameter + * + * Resolves a parameter using explicit values, configured parameter bindings, injection attributes, contextual + * bindings, and automatic dependency resolution. + * + * Explicit values take precedence over all other resolution mechanisms. Constructor parameter bindings can be + * disabled for method invocation while preserving contextual dependency resolution. + * + * Dependency resolution is performed within the supplied context, preserving service lifetimes and circular + * dependency detection. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check explicit parameter bindings. + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::get() To retrieve explicitly bound values. + * @uses \FireHub\Container\Internal\ContextualCandidateSelector::select() To select contextual dependencies. + * @uses \FireHub\Container\Internal\ParameterResolver::selectDependency() To select dependency identifiers. + * @uses \FireHub\Container\Internal\ParameterResolver::resolveDependency() To resolve selected dependencies. + * @uses \FireHub\Container\Internal\ParameterResolver::unresolvedParameter() To create resolution exceptions. + * + * @param null|class-string $consumer

+ * Consumer implementation class used for contextual and parameter bindings, or null when no consumer is available. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active dependency resolution context. + *

+ * @param bool $has_explicit [optional]

+ * Whether an explicit argument value has been supplied for the parameter. + *

+ * @param mixed $explicit [optional]

+ * Explicit argument value to use when provided. + *

+ * @param bool $use_bindings [optional]

+ * Whether configured parameter bindings should be considered during resolution. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Resolved parameter value. + */ + public function resolve (?string $consumer, ParameterPlan $plan, ResolutionContext $context, bool $has_explicit = false, mixed $explicit = null, bool $use_bindings = true):mixed { + + if ($has_explicit) return $explicit; + + $name = $plan->parameter->getName(); + + if ( + $use_bindings + && $consumer !== null + && $this->bindings->has($consumer, $name) + ) return $this->bindings->get($consumer, $name); + + if ($plan->tag !== null) + return ($this->resolve_tagged)($plan->tag, $context); + + if ($plan->collection !== null) + return ($this->resolve_all)($plan->collection, $context); + + if ($consumer !== null) { + + $id = $this->contextual_selector->select($consumer, $plan); + + if ($id !== null) + return $this->resolveDependency($id, $plan, $context, true); + + } + + $id = $this->selectDependency($plan); + + if ( + $id !== null + && ( + ($this->has)($id) + || $this->matcher->isConstructible($id) + ) + ) return $this->resolveDependency($id, $plan, $context); + + if ($plan->hasDefault) + return $plan->default; + + throw $this->unresolvedParameter($plan); + + } + + /** + * ### Selects an eligible dependency + * + * Determines the service identifier required by a compiled dependency parameter. + * + * Named dependency types use their precompiled identifiers. Union and intersection dependency types are evaluated + * dynamically against currently registered service implementations. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\CandidateSelector::union() To select a union dependency. + * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select an intersection dependency. + * + * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the dependency type contains unsupported + * members. + * + * @return null|class-string Selected dependency identifier, or null if no eligible dependency exists. + */ + private function selectDependency (ParameterPlan $plan):?string { + + return match (true) { + $plan->composite instanceof ReflectionUnionType => + $this->selector->union( + $plan->parameter, + $plan->composite + ), + $plan->composite instanceof ReflectionIntersectionType => + $this->selector->intersection( + $plan->parameter, + $plan->composite + ), + default => $plan->id + }; + + } + + /** + * ### Resolves and validates a selected dependency + * + * Resolves the selected service identifier within the active dependency resolution context. + * + * Union and intersection dependencies are validated against their complete declared parameter types. + * Contextually selected named dependencies are additionally validated against their expected contracts. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To validate composite dependency types. + * + * @param class-string $id

+ * Selected service identifier. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan. + *

+ * @param \FireHub\Container\Internal\ResolutionContext $context

+ * Active dependency resolution context. + *

+ * @param bool $contextual [optional]

+ * Whether the dependency was selected through contextual binding. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the resolved dependency does not satisfy + * its declared parameter type. + * + * @return object Resolved dependency instance. + */ + private function resolveDependency (string $id, ParameterPlan $plan, ResolutionContext $context, bool $contextual = false):object { + + $instance = ($this->resolve)($id, $context); + + if ( + $plan->composite !== null + && !$this->selector->matchesType( + $instance::class, + $plan->parameter, + $plan->composite + ) + ) throw new ServiceInstantiationException( + context: [ + 'class' => $plan->parameter->getDeclaringClass()?->getName(), + 'parameter' => $plan->parameter->getName(), + 'type' => (string)$plan->composite, + 'instance' => $instance::class, + ] + ); + + if ( + $contextual + && $plan->composite === null + && $plan->id !== null + ) { + + $expected = $plan->id; + + if (!$instance instanceof $expected) + throw new ServiceInstantiationException( + context: [ + 'class' => $plan->parameter->getDeclaringClass()?->getName(), + 'parameter' => $plan->parameter->getName(), + 'type' => $expected, + 'implementation' => $id, + 'instance' => $instance::class, + ] + ); + + } + + return $instance; + + } + + /** + * ### Creates an unresolved parameter exception + * + * Creates an exception containing diagnostic information about a dependency parameter that could not be resolved + * through explicit bindings, contextual bindings, registered services, implicit construction, or default values. + * + * The diagnostic context includes the declaring class, parameter name, position, declared type, nullability, + * optionality, and default value availability. + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\ParameterPlan $plan

+ * Compiled dependency parameter plan that could not be resolved. + *

+ * + * @return \FireHub\Container\Exception\ServiceInstantiationException Parameter resolution exception. + */ + private function unresolvedParameter (ParameterPlan $plan):ServiceInstantiationException { + + $parameter = $plan->parameter; + $type = $parameter->getType(); + + return new ServiceInstantiationException( + context: [ + 'class' => $parameter->getDeclaringClass()?->getName(), + 'parameter' => $parameter->getName(), + 'position' => $parameter->getPosition(), + 'type' => $type?->__toString(), + 'nullable' => $type?->allowsNull(), + 'optional' => $parameter->isOptional(), + 'has_default' => $plan->hasDefault + ] + ); + + } + +} \ No newline at end of file diff --git a/src/Resolver.php b/src/Resolver.php index 7d2a1f8..f2c6f55 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -20,8 +20,8 @@ }; use FireHub\Container\Internal\ { Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, - ContextualResolver, DependencyPlanCache, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeState, - TagRegistry, TypeMatcher + ContextualResolver, DependencyPlanCache, InstanceStore, MethodInvoker, MethodPlanCache, ParameterBindingRegistry, + ParameterPlanCompiler, ParameterResolver, ResolutionContext, ScopeState, TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, @@ -68,12 +68,23 @@ */ private Autowirer $autowirer; + /** + * ### Reusable callable invoker + * + * Executes callable invocation plans using the shared dependency resolution infrastructure. + * @since 1.0.0 + */ + private MethodInvoker $invoker; + /** * ### Constructor * @since 1.0.0 * * @uses \FireHub\Container\Resolver::has() To check for service existence. * @uses \FireHub\Container\Resolver::get() To retrieve a service definition. + * @uses \FireHub\Container\Resolver::resolve() To resolve a service. + * @uses \FireHub\Container\Resolver::tagged() To retrieve tagged service definitions. + * @uses \FireHub\Container\Resolver::all() To retrieve all service definitions. * @uses \FireHub\Container\Registry::implementations() To get all registered implementations. * * @param \FireHub\Container\Registry $registry

@@ -114,23 +125,36 @@ public function __construct ( fn ():iterable => $this->registry->implementations() ); + $compiler = new ParameterPlanCompiler( + $this->matcher + ); + $plans = new DependencyPlanCache( $this->metadata, - $this->matcher + $compiler ); - $this->autowirer = new Autowirer( + $parameters_resolver = new ParameterResolver( fn (string $id, ResolutionContext $context):object => $this->resolve($id, $context), fn (string $tag, ResolutionContext $context):array => $this->tagged($tag, $context), fn (string $type, ResolutionContext $context):array => $this->all($type, $context), fn (string $id):bool => $this->has($id), $this->matcher, $selector, - $plans, $parameters, new ContextualCandidateSelector($contextual) ); + $this->autowirer = new Autowirer( + $plans, + $parameters_resolver + ); + + $this->invoker = new MethodInvoker( + new MethodPlanCache($compiler), + $parameters_resolver + ); + } /** @@ -472,6 +496,99 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc } + /** + * ### Invokes a callable using dependency injection + * + * Executes the specified callable using explicit arguments and automatically resolved dependencies. + * + * Service dependencies are resolved using a root resolution context. Scoped services cannot be injected unless + * invocation is performed through an active scope. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\MethodInvoker::invoke() To execute the callable. + * + * @param callable $callback

+ * Callable to execute using dependency injection. + *

+ * @param array $arguments [optional]

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * + * @return mixed Value returned by the invoked callable. + */ + public function invoke (callable $callback, array $arguments = []):mixed { + + return $this->invoker->invoke( + $callback, + $arguments, + new ResolutionContext + ); + + } + + /** + * ### Invokes a callable within a resolution scope + * + * Executes the specified callable using the provided scoped instance store and scope lifecycle state. + * + * Scoped dependencies are resolved and reused within the supplied scope. The scope remains open after invocation. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the active scope. + * @uses \FireHub\Container\Internal\MethodInvoker::invoke() To execute the callable. + * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the active scope. + * + * @param callable $callback

+ * Callable to execute using scoped dependency injection. + *

+ * @param array $arguments

+ * Explicit positional and named arguments. + *

+ * @param \FireHub\Container\Internal\InstanceStore $scoped

+ * Scoped service instance storage. + *

+ * @param \FireHub\Container\Internal\ScopeState $scope

+ * Active scope lifecycle state. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * + * @return mixed Value returned by the invoked callable. + */ + public function invokeScoped (callable $callback, array $arguments, InstanceStore $scoped, ScopeState $scope):mixed { + + $scope->enter(); + + try { + + return $this->invoker->invoke( + $callback, + $arguments, + new ResolutionContext( + scoped: $scoped, + scope: $scope + ) + ); + + } finally { + + $scope->leave(); + + } + + } + /** * ### Resolves a service within the specified context * @since 1.0.0 diff --git a/src/Scope.php b/src/Scope.php index f1be407..cc5b0f8 100644 --- a/src/Scope.php +++ b/src/Scope.php @@ -142,6 +142,59 @@ public function tagged (string $tag):array { } + /** + * ### Invokes a callable within the current scope + * + * Executes the specified callable by combining explicitly supplied arguments with dependencies resolved from the + * current scope. + * + * Singleton services are shared with the parent container, scoped services are reused within this scope, and + * transient services are constructed for each resolution. + * + * The scope remains open after invocation and may be reused until explicitly closed. + * + * + * $scope = $container->createScope(); + * + * $scope->invoke( + * static function (DatabaseConnection $connection):void { + * $connection->connect(); + * } + * ); + * + * $scope->close(); + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Resolver::invokeScoped() To execute the callable within the current scope. + * + * @param callable $callback

+ * Callable to execute. + *

+ * @param array $arguments [optional]

+ * Explicit positional and named arguments. + *

+ * + * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the + * resolved dependency does not satisfy its declared type. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * + * @return mixed Value returned by the invoked callable. + */ + public function invoke (callable $callback, array $arguments = []):mixed { + + return $this->resolver->invokeScoped( + $callback, + $arguments, + $this->instances, + $this->state + ); + + } + /** * ### Resolves a registered service * From ee8129aa502727b3c2e6629ba420380d2c170ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 09:15:03 +0200 Subject: [PATCH 02/11] Remove MethodInvoker2 class to streamline dependency injection handling --- src/Internal/MethodInvoker2.php | 343 -------------------------------- 1 file changed, 343 deletions(-) delete mode 100644 src/Internal/MethodInvoker2.php diff --git a/src/Internal/MethodInvoker2.php b/src/Internal/MethodInvoker2.php deleted file mode 100644 index a0633bf..0000000 --- a/src/Internal/MethodInvoker2.php +++ /dev/null @@ -1,343 +0,0 @@ - - * @copyright 2026-present The FireHub Project - All rights reserved - * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 - * - * @php-version >=8.2 - * @package Container - */ - -namespace FireHub\Container\Internal; - -use FireHub\Runtime; -use Closure, InvalidArgumentException, ReflectionFunction, ReflectionMethod; - -/** - * ### Invokes callables using dependency injection - * - * Executes closures, named functions, instance methods, static methods, and invokable objects by combining - * explicitly supplied arguments with dependencies resolved through the shared parameter resolver. - * - * Positional arguments are associated with their declared parameter positions, while named arguments are - * associated with their corresponding parameter names. - * - * Explicit arguments take precedence over dependency injection. Missing arguments are resolved within the active - * resolution context, preserving service lifetimes and circular dependency detection. - * - * Constructor parameter bindings are excluded from method invocation. - * @since 1.0.0 - * - * @internal - */ -final readonly class MethodInvoker2 { - - /** - * ### Constructor - * @since 1.0.0 - * - * @param \FireHub\Container\Internal\MethodPlanCache $plans

- * Shared cache containing compiled callable invocation plans. - *

- * @param \FireHub\Container\Internal\ParameterResolver $parameters

- * Shared parameter resolver responsible for resolving missing callable arguments. - *

- * - * @return void - */ - public function __construct ( - private MethodPlanCache $plans, - private ParameterResolver $parameters - ) {} - - /** - * ### Invokes a callable using dependency injection - * - * Retrieves or compiles the callable invocation plan, validates explicit arguments, resolves missing - * dependencies, and executes the callable. - * - * Positional arguments are mapped to declared parameter positions. Named arguments are mapped to their - * corresponding parameter names. Variadic parameters accept additional positional arguments or an explicitly - * supplied list of values. - * - * Constructor parameter bindings are not applied during invocation. - * @since 1.0.0 - * - * @uses \FireHub\Container\Internal\MethodInvoker::plan() To retrieve the callable invocation plan. - * @uses \FireHub\Container\Internal\MethodInvoker::normalizeArguments() To validate explicit arguments. - * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve missing parameter dependencies. - * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. - * - * @param callable $callback

- * Closure, named function, instance method, static method, or invokable object to execute. - *

- * @param array $arguments

- * Explicit positional and named arguments supplied to the callable. - *

- * @param \FireHub\Container\Internal\ResolutionContext $context

- * Active dependency resolution context. - *

- * - * @throws \InvalidArgumentException If explicit arguments contain unknown names, invalid positional indexes, - * duplicate assignments, or invalid variadic values. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If a required dependency cannot be - * resolved. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are - * eligible. - * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. - * @throws \FireHub\Container\Exception\ScopeClosedException If the active scope has been closed. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the callable cannot be compiled. - * - * @return mixed Value returned by the invoked callable. - */ - public function invoke (callable $callback, array $arguments, ResolutionContext $context):mixed { - - $plan = $this->plan($callback); - - [$positional, $named] = $this->normalizeArguments( - $plan, - $arguments - ); - - $resolved = []; - - foreach ($plan->parameters as $parameter) { - - $reflection = $parameter->parameter; - - $name = $reflection->getName(); - $position = $reflection->getPosition(); - - if ($reflection->isVariadic()) { - - if (Runtime\Arr\Access::keyExists($named, $name)) { - - /** @var list $values */ - $values = $named[$name]; // @phpstan-ignore offsetAccess.notFound - - foreach ($values as $value) - $resolved[] = $value; - - } else { - - for ($index = $position; Runtime\Arr\Access::keyExists($positional, $index); $index++) - $resolved[] = $positional[$index]; // @phpstan-ignore offsetAccess.notFound - - } - - continue; - - } - - if (Runtime\Arr\Access::keyExists($named, $name)) { - - $resolved[] = $named[$name]; // @phpstan-ignore offsetAccess.notFound - - continue; - - } - - if (Runtime\Arr\Access::keyExists($positional, $position)) { - - $resolved[] = $positional[$position]; // @phpstan-ignore offsetAccess.notFound - - continue; - - } - - $resolved[] = $this->parameters->resolve( - $plan->consumer, - $parameter, - $context, - use_bindings: false - ); - - } - - return $callback(...$resolved); - - } - - /** - * ### Retrieves a callable invocation plan - * - * Determines the callable category and retrieves its compiled invocation plan. - * - * Method callables retain their declaring class for contextual dependency resolution. Standalone functions and - * closures do not have a consumer class. - * @since 1.0.0 - * - * @uses \FireHub\Container\Internal\MethodPlanCache::method() To retrieve compiled method plans. - * @uses \FireHub\Container\Internal\MethodPlanCache::function() To retrieve compiled function plans. - * @uses \FireHub\Runtime\Str\SB\Search::contains() To check for class method syntax. - * @uses \FireHub\Runtime\DataIs::array() To check if the callable is an array. - * @uses \FireHub\Runtime\DataIs::object() To check if the callable is an object. - * @uses \FireHub\Runtime\DataIs::string() To check if the callable is a string. - * - * @param callable $callback

- * Callable whose invocation metadata should be retrieved. - *

- * - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the callable cannot be compiled. - * - * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan. - */ - private function plan (callable $callback):MethodPlan { - - if (Runtime\DataIs::array($callback)) - return $this->plans->method( - new ReflectionMethod($callback[0], $callback[1]) - ); - - if (Runtime\DataIs::string($callback) && Runtime\Str\SB\Search::contains('::', $callback)) - return $this->plans->method( - new ReflectionMethod($callback) - ); - - if (Runtime\DataIs::object($callback) && !$callback instanceof Closure) - return $this->plans->method( - new ReflectionMethod($callback, '__invoke') - ); - - if (Runtime\DataIs::string($callback)) - return $this->plans->function( - new ReflectionFunction($callback) - ); - - return $this->plans->function( - new ReflectionFunction(Closure::fromCallable($callback)) - ); - - } - - /** - * ### Normalizes explicit callable arguments - * - * Separates positional and named arguments while validating their correspondence to the callable signature. - * - * Positional arguments must use consecutive integer indexes beginning at zero. Named arguments must reference - * declared parameter names. Assigning the same parameter through both positional and named arguments is - * prohibited. - * - * A named variadic argument must contain a list of values. - * @since 1.0.0 - * - * @uses \FireHub\Runtime\DataIs::int() To check if the argument is an integer. - * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence. - * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters. - * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if a value is in a list. - * @uses \FireHub\Runtime\Arr\Inspection::isList() To check if a value is a list. - * - * @param \FireHub\Container\Internal\MethodPlan $plan

- * Compiled callable invocation plan used to validate parameter names and positions. - *

- * @param array $arguments

- * Explicit positional and named arguments to normalize. - *

- * - * @throws \InvalidArgumentException If an argument name or position is invalid, a parameter is assigned more - * than once, or a named variadic argument does not contain a list. - * - * @return array{ - * 0: array, - * 1: array - * } Normalized positional and named argument collections. - */ - private function normalizeArguments (MethodPlan $plan, array $arguments):array { - - $positional = []; - $named = []; - - $names = []; - $variadic = null; - - foreach ($plan->parameters as $parameter) { - - $reflection = $parameter->parameter; - - $names[$reflection->getName()] = $reflection->getPosition(); - - if ($reflection->isVariadic()) - $variadic = $reflection; - - } - - $next = 0; - $named_started = false; - - foreach ($arguments as $key => $value) { - - if (Runtime\DataIs::int($key)) { - - if ($named_started) - throw new InvalidArgumentException( - 'Positional arguments cannot follow named arguments.' - ); - - if ($key !== $next) - throw new InvalidArgumentException( - 'Positional argument indexes must be consecutive and start at zero.' - ); - - $positional[$key] = $value; - - $next++; - - continue; - - } - - $named_started = true; - - if (!Runtime\Arr\Access::keyExists($names, $key)) - throw new InvalidArgumentException( - "Unknown callable argument: {$key}." - ); - - $named[$key] = $value; - - } - - $count = Runtime\Arr\Inspection::count($plan->parameters); - - if ($variadic === null && Runtime\Arr\Inspection::count($positional) > $count) - throw new InvalidArgumentException( - 'Too many positional arguments were supplied.' - ); - - foreach ($named as $name => $value) { - - $position = $names[$name]; // @phpstan-ignore offsetAccess.notFound - - if (Runtime\Arr\Access::keyExists($positional, $position)) - throw new InvalidArgumentException( - "Callable argument '{$name}' was supplied more than once." - ); - - if ( - $variadic !== null - && $name === $variadic->getName() - ) { - - if (!Runtime\DataIs::array($value) || !Runtime\Arr\Inspection::isList($value)) - throw new InvalidArgumentException( - "Named variadic argument '{$name}' must be a list." - ); - - if (Runtime\Arr\Inspection::count($positional) > $position) - throw new InvalidArgumentException( - "Variadic argument '{$name}' cannot be supplied both positionally and by name." - ); - - } - - } - - return [$positional, $named]; - - } - -} \ No newline at end of file From 1403a146b7c17d40e9606a14ef8961c8ee3d5a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 09:27:48 +0200 Subject: [PATCH 03/11] Remove redundant exception documentation and enhance code examples in container classes --- src/Container.php | 20 +- src/Internal/MethodInvoker.php | 1 - src/Resolver.php | 2 - src/Scope.php | 27 +- tests/Unit/MethodInjectionTest.php | 864 ++++++++++++++++++++++++++++ tests/Unit/ParameterBindingTest.php | 3 + 6 files changed, 899 insertions(+), 18 deletions(-) create mode 100644 tests/Unit/MethodInjectionTest.php diff --git a/src/Container.php b/src/Container.php index 6e32645..831137c 100644 --- a/src/Container.php +++ b/src/Container.php @@ -854,15 +854,20 @@ public function tagged (string $tag):array { * Scoped dependencies require invocation through an active resolution scope. * * - * $container->invoke( - * static function (LoggerInterface $logger, string $message):void { - * $logger->log($message); - * }, - * [ - * 'message' => 'Hello FireHub' - * ] + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(LoggerInterface::class, FileLogger::class); + * + * $service = new UserService; + * + * $result = $container->invoke( + * [$service, 'create'], + * ['username' => 'Danijel'] * ); * + * * @since 1.0.0 * * @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection. @@ -875,7 +880,6 @@ public function tagged (string $tag):array { *

* * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the * resolved dependency does not satisfy its declared type. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. diff --git a/src/Internal/MethodInvoker.php b/src/Internal/MethodInvoker.php index e60cc02..c88ff17 100644 --- a/src/Internal/MethodInvoker.php +++ b/src/Internal/MethodInvoker.php @@ -76,7 +76,6 @@ public function __construct ( *

* * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the * resolved dependency does not satisfy its declared type. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. diff --git a/src/Resolver.php b/src/Resolver.php index f2c6f55..f0d3d47 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -515,7 +515,6 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc *

* * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the * resolved dependency does not satisfy its declared type. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. @@ -558,7 +557,6 @@ public function invoke (callable $callback, array $arguments = []):mixed { *

* * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the * resolved dependency does not satisfy its declared type. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. diff --git a/src/Scope.php b/src/Scope.php index cc5b0f8..3225918 100644 --- a/src/Scope.php +++ b/src/Scope.php @@ -154,16 +154,30 @@ public function tagged (string $tag):array { * The scope remains open after invocation and may be reused until explicitly closed. * * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->scoped(DatabaseConnection::class); + * * $scope = $container->createScope(); * - * $scope->invoke( - * static function (DatabaseConnection $connection):void { - * $connection->connect(); - * } - * ); + * try { * - * $scope->close(); + * $service = new UserService; + * + * $result = $scope->invoke( + * [$service, 'create'], + * ['username' => 'Danijel'] + * ); + * + * } finally { + * + * $scope->close(); + * + * } * + * * @since 1.0.0 * * @uses \FireHub\Container\Resolver::invokeScoped() To execute the callable within the current scope. @@ -176,7 +190,6 @@ public function tagged (string $tag):array { *

* * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If a dependency cannot be resolved. * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the * resolved dependency does not satisfy its declared type. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. diff --git a/tests/Unit/MethodInjectionTest.php b/tests/Unit/MethodInjectionTest.php new file mode 100644 index 0000000..f79f0aa --- /dev/null +++ b/tests/Unit/MethodInjectionTest.php @@ -0,0 +1,864 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Container\Attribute\ { + InjectAll, Tagged +}; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + InvalidMethodArgumentException, ScopeClosedException, ScopeRequiredException, ServiceInstantiationException +}; +use FireHub\Testing\FireHubTestCase; +use FireHub\Tests\Container\Stubs\ { + CollectionConsoleLogger, CollectionFileLogger, CollectionLogger, ContextualFileLogger, ContextualLogger, Logger, + ScopedDependency, TaggedConsoleLogger, TaggedFileLogger +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use LogicException; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests callable dependency injection and invocation arguments + * + * Covers supported callback forms, explicit argument precedence, signature validation, contextual bindings, + * collection attributes, scopes, and exception propagation. + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[CoversClass(Container::class)] +final class MethodInjectionTest extends FireHubTestCase { + + /** + * @since 1.0.0 + */ + use ExceptionAssertions; + + /** + * ### Closure receives an implicitly constructed dependency + * @since 1.0.0 + * + * @return void + */ + public function testClosureReceivesImplicitDependency ():void { + + $result = (new Container)->invoke( + static fn (Logger $logger):object => $logger + ); + + self::assertInstanceOf(Logger::class, $result); + + } + + /** + * ### Singleton dependency is reused across invocations + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDependencyIsReused ():void { + + $container = new Container; + $container->singleton(Logger::class); + + $first = $container->invoke(static fn (Logger $logger):Logger => $logger); + $second = $container->invoke(static fn (Logger $logger):Logger => $logger); + + self::assertSame($first, $second); + self::assertSame($first, $container->get(Logger::class)); + + } + + /** + * ### Explicit named value overrides dependency injection + * @since 1.0.0 + * + * @return void + */ + public function testExplicitNamedDependencyOverridesAutowiring ():void { + + $explicit = new Logger; + + $result = (new Container)->invoke( + static fn (Logger $logger):Logger => $logger, + ['logger' => $explicit] + ); + + self::assertSame($explicit, $result); + + } + + /** + * ### Explicit positional value overrides dependency injection + * @since 1.0.0 + * + * @return void + */ + public function testExplicitPositionalDependencyOverridesAutowiring ():void { + + $explicit = new Logger; + + $result = (new Container)->invoke( + static fn (Logger $logger):Logger => $logger, + [$explicit] + ); + + self::assertSame($explicit, $result); + + } + + /** + * ### Named scalar argument is combined with an injected service + * @since 1.0.0 + * + * @return void + */ + public function testNamedScalarArgumentWithInjectedDependency ():void { + + $result = (new Container)->invoke( + static fn (Logger $logger, string $message):array => [$logger, $message], + ['message' => 'FireHub'] + ); + + self::assertInstanceOf(Logger::class, $result[0]); + self::assertSame('FireHub', $result[1]); + + } + + /** + * ### Multiple named arguments may be supplied out of declaration order + * @since 1.0.0 + * + * @return void + */ + public function testNamedArgumentsCanBeOutOfOrder ():void { + + $result = (new Container)->invoke( + static fn (string $first, string $second):array => [$first, $second], + ['second' => 'B', 'first' => 'A'] + ); + + self::assertSame(['A', 'B'], $result); + + } + + /** + * ### Positional and named arguments can target different parameters + * @since 1.0.0 + * + * @return void + */ + public function testPositionalAndNamedArgumentsCanBeCombined ():void { + + $result = (new Container)->invoke( + static fn (string $first, string $second, string $third):array => [$first, $second, $third], + [0 => 'A', 'third' => 'C', 'second' => 'B'] + ); + + self::assertSame(['A', 'B', 'C'], $result); + + } + + /** + * ### Missing optional argument uses its declared default + * @since 1.0.0 + * + * @return void + */ + public function testDefaultArgumentIsApplied ():void { + + self::assertSame( + 'default', + (new Container)->invoke(static fn (string $value = 'default'):string => $value) + ); + + } + + /** + * ### Explicit null is not mistaken for an omitted argument + * @since 1.0.0 + * + * @return void + */ + public function testExplicitNullOverridesDefault ():void { + + self::assertNull( + (new Container)->invoke( + static fn (?string $value = 'default'):?string => $value, + ['value' => null] + ) + ); + + } + + /** + * ### Required unbound scalar dependency fails + * @since 1.0.0 + * + * @return void + */ + public function testMissingRequiredScalarThrows ():void { + + $this->assertThrows( + ServiceInstantiationException::class, + static function ():void { + (new Container)->invoke(static fn (string $required):string => $required); + } + ); + + } + + /** + * ### Instance method receives dependencies and explicit arguments + * @since 1.0.0 + * + * @return void + */ + public function testInstanceMethodInjection ():void { + + $fixture = new MethodInvocationFixture; + + $result = (new Container)->invoke( + [$fixture, 'execute'], + ['message' => 'instance'] + ); + + self::assertSame('instance', $result); + + } + + /** + * ### Static method can be invoked through array notation + * @since 1.0.0 + * + * @return void + */ + public function testStaticMethodArrayCallable ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke([MethodInvocationFixture::class, 'staticExecute']) + ); + + } + + /** + * ### Static method can be invoked through string notation + * @since 1.0.0 + * + * @return void + */ + public function testStaticMethodStringCallable ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke(MethodInvocationFixture::class . '::staticExecute') + ); + + } + + /** + * ### Invokable object receives dependencies + * @since 1.0.0 + * + * @return void + */ + public function testInvokableObjectInjection ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke(new MethodInvocationFixture) + ); + + } + + /** + * ### Named function can receive dependencies + * @since 1.0.0 + * + * @return void + */ + public function testNamedFunctionInjection ():void { + + self::assertSame( + Logger::class, + (new Container)->invoke(__NAMESPACE__ . '\\methodInjectionFunction') + ); + + } + + /** + * ### Empty variadic parameter produces an empty list + * @since 1.0.0 + * + * @return void + */ + public function testEmptyVariadicArgumentList ():void { + + self::assertSame( + [], + (new Container)->invoke(static fn (Logger $logger, string ...$messages):array => $messages) + ); + + } + + /** + * ### Named variadic list is expanded into individual values + * @since 1.0.0 + * + * @return void + */ + public function testNamedVariadicArguments ():void { + + self::assertSame( + ['one', 'two', 'three'], + (new Container)->invoke( + static fn (Logger $logger, string ...$messages):array => $messages, + ['messages' => ['one', 'two', 'three']] + ) + ); + + } + + /** + * ### Remaining positional arguments are assigned to variadic parameter + * @since 1.0.0 + * + * @return void + */ + public function testPositionalVariadicArguments ():void { + + self::assertSame( + ['one', 'two'], + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + ['one', 'two'] + ) + ); + + } + + /** + * ### Unknown named argument is rejected + * @since 1.0.0 + * + * @return void + */ + public function testUnknownNamedArgumentThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke(static fn ():bool => true, ['unknown' => 1]); + } + ); + + } + + /** + * ### Duplicate positional and named assignment is rejected + * @since 1.0.0 + * + * @return void + */ + public function testDuplicateArgumentAssignmentThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string $name):string => $name, + [0 => 'first', 'name' => 'second'] + ); + } + ); + + } + + /** + * ### Named arguments cannot precede positional arguments + * @since 1.0.0 + * + * @return void + */ + public function testPositionalAfterNamedThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string $first, string $second):array => [$first, $second], + ['second' => 'B', 0 => 'A'] + ); + } + ); + + } + + /** + * ### Nonconsecutive positional indexes are rejected + * @since 1.0.0 + * + * @return void + */ + public function testNonconsecutivePositionalIndexesThrow ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string $first, string $second):array => [$first, $second], + [1 => 'B'] + ); + } + ); + + } + + /** + * ### Excess positional arguments are rejected + * @since 1.0.0 + * + * @return void + */ + public function testTooManyPositionalArgumentsThrow ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke(static fn ():bool => true, ['extra']); + } + ); + + } + + /** + * ### Named variadic argument requires a list + * @since 1.0.0 + * + * @return void + */ + public function testNamedVariadicScalarThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + ['messages' => 'not-a-list'] + ); + } + ); + + } + + /** + * ### Associative array is not accepted as a named variadic list + * @since 1.0.0 + * + * @return void + */ + public function testNamedVariadicAssociativeArrayThrows ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + ['messages' => ['label' => 'value']] + ); + } + ); + + } + + /** + * ### Named and positional variadic values cannot be combined + * @since 1.0.0 + * + * @return void + */ + public function testDuplicateVariadicSourcesThrow ():void { + + $this->assertThrows( + InvalidMethodArgumentException::class, + static function ():void { + (new Container)->invoke( + static fn (string ...$messages):array => $messages, + [0 => 'first', 'messages' => ['second']] + ); + } + ); + + } + + /** + * ### Explicit constructor bindings do not apply to method injection + * @since 1.0.0 + * + * @return void + */ + public function testConstructorParameterBindingsDoNotApplyToMethods ():void { + + $container = new Container; + $container->bindParameters(MethodInvocationFixture::class, ['message' => 'constructor-only']); + + $fixture = new MethodInvocationFixture; + + self::assertSame('default', $container->invoke([$fixture, 'execute'])); + + } + + /** + * ### Contextual binding is applied to declaring a method class + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingOnInstanceMethod ():void { + + $container = new Container; + $container->bindContextual( + MethodInvocationFixture::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + self::assertInstanceOf( + ContextualFileLogger::class, + $container->invoke([new MethodInvocationFixture, 'contextual']) + ); + + } + + /** + * ### Tagged injection returns services in tag registration order + * @since 1.0.0 + * + * @return void + */ + public function testTaggedMethodInjection ():void { + + $container = new Container; + $container->singleton(TaggedFileLogger::class); + $container->singleton(TaggedConsoleLogger::class); + $container->tag('logging', TaggedConsoleLogger::class, TaggedFileLogger::class); + + $result = $container->invoke( + static fn (#[Tagged('logging')] array $loggers):array => $loggers + ); + + self::assertCount(2, $result); + self::assertInstanceOf(TaggedConsoleLogger::class, $result[0]); + self::assertInstanceOf(TaggedFileLogger::class, $result[1]); + + } + + /** + * ### Collection injection uses the registered matching services + * @since 1.0.0 + * + * @return void + */ + public function testCollectionMethodInjection ():void { + + $container = new Container; + $container->singleton(CollectionFileLogger::class); + $container->singleton(CollectionConsoleLogger::class); + + $result = $container->invoke( + static fn (#[InjectAll(CollectionLogger::class)] array $loggers):array => $loggers + ); + + self::assertCount(2, $result); + self::assertInstanceOf(CollectionFileLogger::class, $result[0]); + self::assertInstanceOf(CollectionConsoleLogger::class, $result[1]); + + } + + /** + * ### Scoped dependency cannot be resolved directly from the container + * @since 1.0.0 + * + * @return void + */ + public function testScopedDependencyRequiresScope ():void { + + $container = new Container; + $container->scoped(ScopedDependency::class); + + $this->assertThrows( + ScopeRequiredException::class, + static function () use ($container):void { + $container->invoke(static fn (ScopedDependency $service):object => $service); + } + ); + + } + + /** + * ### Scoped service instance is reused across invocations in the same scope + * @since 1.0.0 + * + * @return void + */ + public function testScopedInvocationReusesScopedDependency ():void { + + $container = new Container; + $container->scoped(ScopedDependency::class); + $scope = $container->createScope(); + + try { + + $first = $scope->invoke(static fn (ScopedDependency $service):object => $service); + $second = $scope->invoke(static fn (ScopedDependency $service):object => $service); + + self::assertSame($first, $second); + self::assertFalse($scope->isClosed()); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Different scopes receive different scoped instances + * @since 1.0.0 + * + * @return void + */ + public function testScopedInstancesAreIsolated ():void { + + $container = new Container; + $container->scoped(ScopedDependency::class); + $first_scope = $container->createScope(); + $second_scope = $container->createScope(); + + try { + + $first = $first_scope->invoke(static fn (ScopedDependency $service):object => $service); + $second = $second_scope->invoke(static fn (ScopedDependency $service):object => $service); + + self::assertNotSame($first, $second); + + } finally { + + $first_scope->close(); + $second_scope->close(); + + } + + } + + /** + * ### Invocation cannot use a closed scope + * @since 1.0.0 + * + * @return void + */ + public function testInvokingClosedScopeThrows ():void { + + $scope = (new Container)->createScope(); + $scope->close(); + + $this->assertThrows( + ScopeClosedException::class, + static function () use ($scope):void { + $scope->invoke(static fn ():bool => true); + } + ); + + } + + /** + * ### Callback failure propagates without being wrapped + * @since 1.0.0 + * + * @return void + */ + public function testCallbackExceptionIsNotWrapped ():void { + + $expected = new LogicException('callback failure'); + + try { + + (new Container)->invoke(static function () use ($expected):never { + throw $expected; + }); + + self::fail('Expected callback exception was not thrown.'); + + } catch (LogicException $exception) { + + self::assertSame($expected, $exception); + + } + + } + + /** + * ### Scoped callback failure does not close the scope + * @since 1.0.0 + * + * @return void + */ + public function testScopedCallbackExceptionLeavesScopeUsable ():void { + + $scope = (new Container)->createScope(); + $expected = new LogicException('callback failure'); + + try { + + try { + + $scope->invoke(static function () use ($expected):never { + throw $expected; + }); + + self::fail('Expected callback exception was not thrown.'); + + } catch (LogicException $exception) { + + self::assertSame($expected, $exception); + + } + + self::assertFalse($scope->isClosed()); + self::assertTrue($scope->invoke(static fn ():bool => true)); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### A frozen container still supports invocation + * @since 1.0.0 + * + * @return void + */ + public function testFrozenContainerCanInvoke ():void { + + $container = new Container; + $container->singleton(Logger::class); + $container->freeze(); + + self::assertInstanceOf( + Logger::class, + $container->invoke(static fn (Logger $logger):Logger => $logger) + ); + + } + +} + +/** + * ### Provides callable fixtures for method injection tests + * @since 1.0.0 + */ +final class MethodInvocationFixture { + + /** + * ### Executes an instance method with an injected dependency + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * @param string $message [optional]

+ * Message to return. + *

+ * + * @return string Explicit message. + */ + public function execute (Logger $logger, string $message = 'default'):string { + + return $message; + + } + + /** + * ### Resolves a dependency in a static method + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * + * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name. + */ + public static function staticExecute (Logger $logger):string { + + return $logger::class; + + } + + /** + * ### Resolves a contextual dependency + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\ContextualLogger $logger

+ * Contextually selected logger. + *

+ * + * @return \FireHub\Tests\Container\Stubs\ContextualLogger Selected logger. + */ + public function contextual (ContextualLogger $logger):ContextualLogger { + + return $logger; + + } + + /** + * ### Invokes an object with an injected logger + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * + * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name. + */ + public function __invoke (Logger $logger):string { + + return $logger::class; + + } + +} + +/** + * ### Named function fixture for callable injection + * @since 1.0.0 + * + * @param \FireHub\Tests\Container\Stubs\Logger $logger

+ * Logger dependency. + *

+ * + * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name. + */ +function methodInjectionFunction (Logger $logger):string { + + return $logger::class; + +} diff --git a/tests/Unit/ParameterBindingTest.php b/tests/Unit/ParameterBindingTest.php index d71a5a5..1b3833b 100644 --- a/tests/Unit/ParameterBindingTest.php +++ b/tests/Unit/ParameterBindingTest.php @@ -34,6 +34,9 @@ #[CoversClass(Container::class)] final class ParameterBindingTest extends FireHubTestCase { + /** + * @since 1.0.0 + */ use ExceptionAssertions; /** From 703a68b44b8bb1f7bd573ef80ad6e7a3dcb6cf7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 10:19:32 +0200 Subject: [PATCH 04/11] Add @inheritDoc annotations to exception classes for improved documentation --- .../AmbiguousDependencyException.php | 5 + src/Exception/CircularDependencyException.php | 5 + src/Exception/ContainerFrozenException.php | 5 + .../DisposalTrackerClosedException.php | 36 +++++ .../DuplicateContextualBindingException.php | 5 + src/Exception/DuplicateServiceException.php | 5 + .../InvalidMethodArgumentException.php | 3 +- src/Exception/InvalidTagNameException.php | 3 +- src/Exception/ScopeActiveException.php | 5 + src/Exception/ScopeClosedException.php | 5 + src/Exception/ScopeRequiredException.php | 5 + src/Exception/ServiceDisposalException.php | 36 +++++ .../ServiceInstantiationException.php | 5 + src/Exception/ServiceNotFoundException.php | 5 + .../ServiceTypeMismatchException.php | 5 + .../UnsupportedDefinitionException.php | 5 + src/Internal/DisposalTracker.php | 141 ++++++++++++++++++ 17 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 src/Exception/DisposalTrackerClosedException.php create mode 100644 src/Exception/ServiceDisposalException.php create mode 100644 src/Internal/DisposalTracker.php diff --git a/src/Exception/AmbiguousDependencyException.php b/src/Exception/AmbiguousDependencyException.php index 24a9093..95f83fa 100644 --- a/src/Exception/AmbiguousDependencyException.php +++ b/src/Exception/AmbiguousDependencyException.php @@ -24,6 +24,11 @@ */ final class AmbiguousDependencyException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Multiple services match the requested dependency.'; } \ No newline at end of file diff --git a/src/Exception/CircularDependencyException.php b/src/Exception/CircularDependencyException.php index 183e044..406a556 100644 --- a/src/Exception/CircularDependencyException.php +++ b/src/Exception/CircularDependencyException.php @@ -21,6 +21,11 @@ */ final class CircularDependencyException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Circular dependency detected'; } \ No newline at end of file diff --git a/src/Exception/ContainerFrozenException.php b/src/Exception/ContainerFrozenException.php index 703317e..5a840c1 100644 --- a/src/Exception/ContainerFrozenException.php +++ b/src/Exception/ContainerFrozenException.php @@ -21,6 +21,11 @@ */ final class ContainerFrozenException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Cannot register services in a frozen container'; } \ No newline at end of file diff --git a/src/Exception/DisposalTrackerClosedException.php b/src/Exception/DisposalTrackerClosedException.php new file mode 100644 index 0000000..c190945 --- /dev/null +++ b/src/Exception/DisposalTrackerClosedException.php @@ -0,0 +1,36 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\Runtime\System\InvariantException; + +/** + * ### Indicates that a disposal tracker is closed + * + * Thrown when a disposable instance is registered after the disposal process has started. + * + * A closed disposal tracker cannot accept additional instances, regardless of whether resource disposal is completed + * successfully or failed. + * @since 1.0.0 + */ +final class DisposalTrackerClosedException extends InvariantException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The disposal tracker is closed'; + +} \ No newline at end of file diff --git a/src/Exception/DuplicateContextualBindingException.php b/src/Exception/DuplicateContextualBindingException.php index 0309a6d..6ee477e 100644 --- a/src/Exception/DuplicateContextualBindingException.php +++ b/src/Exception/DuplicateContextualBindingException.php @@ -24,6 +24,11 @@ */ final class DuplicateContextualBindingException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'A contextual binding for the specified consumer and dependency is already registered'; } \ No newline at end of file diff --git a/src/Exception/DuplicateServiceException.php b/src/Exception/DuplicateServiceException.php index 282a8eb..233cb78 100644 --- a/src/Exception/DuplicateServiceException.php +++ b/src/Exception/DuplicateServiceException.php @@ -24,6 +24,11 @@ */ final class DuplicateServiceException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'A service with the specified identifier is already registered'; } \ No newline at end of file diff --git a/src/Exception/InvalidMethodArgumentException.php b/src/Exception/InvalidMethodArgumentException.php index 29fae1c..a77517b 100644 --- a/src/Exception/InvalidMethodArgumentException.php +++ b/src/Exception/InvalidMethodArgumentException.php @@ -27,7 +27,8 @@ final class InvalidMethodArgumentException extends InvalidArgumentException { /** - * ### Default exception message + * @inheritDoc + * * @since 1.0.0 */ protected const string DEFAULT_MESSAGE = 'Invalid method argument(s)'; diff --git a/src/Exception/InvalidTagNameException.php b/src/Exception/InvalidTagNameException.php index e1431bd..9356488 100644 --- a/src/Exception/InvalidTagNameException.php +++ b/src/Exception/InvalidTagNameException.php @@ -24,7 +24,8 @@ final class InvalidTagNameException extends InvariantException { /** - * ### Default exception message + * @inheritDoc + * * @since 1.0.0 */ protected const string DEFAULT_MESSAGE = 'Service tag name must not be empty or contain only whitespace'; diff --git a/src/Exception/ScopeActiveException.php b/src/Exception/ScopeActiveException.php index 9ed842c..2fa5997 100644 --- a/src/Exception/ScopeActiveException.php +++ b/src/Exception/ScopeActiveException.php @@ -24,6 +24,11 @@ */ final class ScopeActiveException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'The dependency injection scope is currently active.'; } \ No newline at end of file diff --git a/src/Exception/ScopeClosedException.php b/src/Exception/ScopeClosedException.php index 95482c7..1b82b24 100644 --- a/src/Exception/ScopeClosedException.php +++ b/src/Exception/ScopeClosedException.php @@ -24,6 +24,11 @@ */ final class ScopeClosedException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Cannot access a closed dependency injection scope.'; } \ No newline at end of file diff --git a/src/Exception/ScopeRequiredException.php b/src/Exception/ScopeRequiredException.php index 083c50e..1c5e859 100644 --- a/src/Exception/ScopeRequiredException.php +++ b/src/Exception/ScopeRequiredException.php @@ -24,6 +24,11 @@ */ final class ScopeRequiredException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Scoped service requires an active scope'; } \ No newline at end of file diff --git a/src/Exception/ServiceDisposalException.php b/src/Exception/ServiceDisposalException.php new file mode 100644 index 0000000..deafdd3 --- /dev/null +++ b/src/Exception/ServiceDisposalException.php @@ -0,0 +1,36 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\RuntimeException; + +/** + * ### Indicates failure during service resource disposal + * + * Thrown when one or more disposable service instances fail to release their resources. + * + * The exception preserves all encountered disposal failures while exposing the first failure as the previous + * exception. + * @since 1.0.0 + */ +final class ServiceDisposalException extends RuntimeException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Failed to dispose service'; + +} \ No newline at end of file diff --git a/src/Exception/ServiceInstantiationException.php b/src/Exception/ServiceInstantiationException.php index bd23fe3..5e0c98d 100644 --- a/src/Exception/ServiceInstantiationException.php +++ b/src/Exception/ServiceInstantiationException.php @@ -24,6 +24,11 @@ */ final class ServiceInstantiationException extends RuntimeException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Failed to instantiate service'; } \ No newline at end of file diff --git a/src/Exception/ServiceNotFoundException.php b/src/Exception/ServiceNotFoundException.php index 3f9d4c4..c543c25 100644 --- a/src/Exception/ServiceNotFoundException.php +++ b/src/Exception/ServiceNotFoundException.php @@ -23,6 +23,11 @@ */ final class ServiceNotFoundException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'No service definition found for the specified identifier'; } \ No newline at end of file diff --git a/src/Exception/ServiceTypeMismatchException.php b/src/Exception/ServiceTypeMismatchException.php index 1dbc31b..f1d3d8f 100644 --- a/src/Exception/ServiceTypeMismatchException.php +++ b/src/Exception/ServiceTypeMismatchException.php @@ -24,6 +24,11 @@ */ final class ServiceTypeMismatchException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Resolved service is not compatible with its registered identifier'; } \ No newline at end of file diff --git a/src/Exception/UnsupportedDefinitionException.php b/src/Exception/UnsupportedDefinitionException.php index c40dbfa..8fd9723 100644 --- a/src/Exception/UnsupportedDefinitionException.php +++ b/src/Exception/UnsupportedDefinitionException.php @@ -24,6 +24,11 @@ */ final class UnsupportedDefinitionException extends InvariantException { + /** + * @inheritDoc + * + * @since 1.0.0 + */ protected const string DEFAULT_MESSAGE = 'Unsupported service definition'; } \ No newline at end of file diff --git a/src/Internal/DisposalTracker.php b/src/Internal/DisposalTracker.php new file mode 100644 index 0000000..738565e --- /dev/null +++ b/src/Internal/DisposalTracker.php @@ -0,0 +1,141 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Core\Boundary\Lifecycle\Disposable; +use Throwable; + +/** + * ### Tracks and disposes of owned resources + * + * Maintains a registry of disposable service instances owned by a dependency injection lifecycle. + * + * Each instance is registered only once using object identity. Resources are disposed of in reverse registration + * order, allowing dependent services to release their resources before the services they depend on. + * + * Disposal attempts to continue even when individual instances fail to release their resources. The first encountered + * exception is propagated after all registered instances have been processed. + * + * The tracker does not determine service ownership or lifetime. These responsibilities belong to the container or + * scope managing the tracker. + * + * Once disposal begins, the tracker cannot accept additional instances. + * @since 1.0.0 + * + * @internal + */ +final class DisposalTracker { + + /** + * ### Registered disposable instances + * @since 1.0.0 + * + * @var list<\FireHub\Core\Boundary\Lifecycle\Disposable> + */ + private array $instances = []; + + /** + * ### Registered instance identities + * @since 1.0.0 + * + * @var array + */ + private array $identities = []; + + /** + * ### Whether disposal has started + * @since 1.0.0 + */ + private bool $disposed = false; + + /** + * ### Registers a disposable instance + * + * Adds an instance to the disposal registry unless the same object has already been registered. + * + * Registration is rejected after disposal has started. + * @since 1.0.0 + * + * @param \FireHub\Core\Boundary\Lifecycle\Disposable $instance

+ * Disposable instance to register. + *

+ * + * @throws \LogicException If registration is attempted after disposal has started. + * + * @return void + */ + public function track (Disposable $instance):void { + + if ($this->disposed) + throw new \LogicException( + 'Cannot track disposable instances after disposal has started.' + ); + + $id = spl_object_id($instance); + + if (isset($this->identities[$id])) return; + + $this->identities[$id] = true; + + $this->instances[] = $instance; + + } + + /** + * ### Disposes of all tracked instances + * + * Releases registered resources in reverse registration order. + * + * Every instance is attempted even when disposal of another instance fails. + * + * Repeated calls have no effect. + * @since 1.0.0 + * + * @uses \FireHub\Core\Boundary\Lifecycle\Disposable::dispose() To release instance resources. + * + * @throws Throwable If one or more instances fail during disposal. + * + * @return void + */ + public function dispose ():void { + + if ($this->disposed) return; + + $this->disposed = true; + + $failure = null; + + for ($index = count($this->instances) - 1; $index >= 0; $index--) { + + try { + + $this->instances[$index]->dispose(); + + } catch (Throwable $exception) { + + $failure ??= $exception; + + } + + } + + $this->instances = []; + $this->identities = []; + + if ($failure !== null) + throw $failure; + + } + +} \ No newline at end of file From a5e3bfbfd5885866ff844ece425c17747ba97eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 10:25:56 +0200 Subject: [PATCH 05/11] Refactor DisposalTracker to improve exception handling and utilize runtime utilities --- src/Internal/DisposalTracker.php | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Internal/DisposalTracker.php b/src/Internal/DisposalTracker.php index 738565e..e7cf52e 100644 --- a/src/Internal/DisposalTracker.php +++ b/src/Internal/DisposalTracker.php @@ -14,6 +14,10 @@ namespace FireHub\Container\Internal; use FireHub\Core\Boundary\Lifecycle\Disposable; +use FireHub\Container\Exception\ { + DisposalTrackerClosedException, ServiceDisposalException +}; +use FireHub\Runtime; use Throwable; /** @@ -67,22 +71,22 @@ final class DisposalTracker { * Registration is rejected after disposal has started. * @since 1.0.0 * + * @uses \FireHub\Runtime\ObjectModel\Identity::id() To get the object identity. + * * @param \FireHub\Core\Boundary\Lifecycle\Disposable $instance

* Disposable instance to register. *

* - * @throws \LogicException If registration is attempted after disposal has started. + * @throws \FireHub\Container\Exception\DisposalTrackerClosedException If disposal has already started. * * @return void */ public function track (Disposable $instance):void { if ($this->disposed) - throw new \LogicException( - 'Cannot track disposable instances after disposal has started.' - ); + throw new DisposalTrackerClosedException; - $id = spl_object_id($instance); + $id = Runtime\ObjectModel\Identity::id($instance); if (isset($this->identities[$id])) return; @@ -102,9 +106,11 @@ public function track (Disposable $instance):void { * Repeated calls have no effect. * @since 1.0.0 * + * @uses \FireHub\Runtime\Arr\Inspection::count() To get the number of instances. + * * @uses \FireHub\Core\Boundary\Lifecycle\Disposable::dispose() To release instance resources. * - * @throws Throwable If one or more instances fail during disposal. + * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources. * * @return void */ @@ -116,11 +122,11 @@ public function dispose ():void { $failure = null; - for ($index = count($this->instances) - 1; $index >= 0; $index--) { + for ($index = Runtime\Arr\Inspection::count($this->instances) - 1; $index >= 0; $index--) { try { - $this->instances[$index]->dispose(); + $this->instances[$index]->dispose(); // @phpstan-ignore offsetAccess.notFound } catch (Throwable $exception) { @@ -134,7 +140,7 @@ public function dispose ():void { $this->identities = []; if ($failure !== null) - throw $failure; + throw new ServiceDisposalException(previous: $failure); } From fbd7709d25d81584eb4049403b36002f148653ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 12:16:06 +0200 Subject: [PATCH 06/11] Add ContainerActiveScopesException and ContainerClosedException classes; refactor container to manage active scopes and closure state --- src/Container.php | 205 +++++++++++++++++- .../ContainerActiveScopesException.php | 31 +++ src/Exception/ContainerClosedException.php | 31 +++ src/Internal/ResolutionContext.php | 8 +- src/Internal/ScopeRegistry.php | 111 ++++++++++ src/Resolver.php | 60 ++++- src/Scope.php | 63 ++++-- 7 files changed, 477 insertions(+), 32 deletions(-) create mode 100644 src/Exception/ContainerActiveScopesException.php create mode 100644 src/Exception/ContainerClosedException.php create mode 100644 src/Internal/ScopeRegistry.php diff --git a/src/Container.php b/src/Container.php index 831137c..58bfb97 100644 --- a/src/Container.php +++ b/src/Container.php @@ -21,10 +21,12 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ContextualBindingRegistry, InstanceStore, ParameterBindingRegistry, ResolutionContext, TagRegistry + ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeRegistry, + TagRegistry }; use FireHub\Container\Exception\ { - ContainerFrozenException, InvalidTagNameException, ServiceNotFoundException + ContainerActiveScopesException, ContainerClosedException, ContainerFrozenException, InvalidTagNameException, + ServiceNotFoundException }; use FireHub\Runtime; use Closure; @@ -71,12 +73,36 @@ final class Container implements ContainerBoundary, Registrar { */ private readonly ContextualBindingRegistry $contextual; + /** + * ### Singleton instance store + * @since 1.0.0 + */ + private readonly InstanceStore $singletons; + /** * ### Service tag registry * @since 1.0.0 */ private readonly TagRegistry $tags; + /** + * ### Active scope registry + * @since 1.0.0 + */ + private readonly ScopeRegistry $scopes; + + /** + * ### Singleton disposable resource tracker + * @since 1.0.0 + */ + private readonly DisposalTracker $disposables; + + /** + * ### Container shutdown state + * @since 1.0.0 + */ + private bool $closed = false; + /** * ### Container freeze state * @var bool @@ -94,14 +120,18 @@ public function __construct () { $this->registry = new Registry; $this->parameters = new ParameterBindingRegistry; $this->contextual = new ContextualBindingRegistry; + $this->singletons = new InstanceStore; $this->tags = new TagRegistry; + $this->scopes = new ScopeRegistry; + $this->disposables = new DisposalTracker; $this->resolver = new Resolver( $this->registry, - new InstanceStore, + $this->singletons, $this->parameters, $this->contextual, - $this->tags + $this->tags, + $this->disposables ); } @@ -118,10 +148,24 @@ public function __construct () { * * * @since 1.0.0 + * + * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. + * @uses \FireHub\Container\Internal\ScopeRegistry::register() To register a new scope. + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function createScope ():ScopeBoundary { - return new Scope($this->resolver); + $this->assertOpen(); + + $scope = new Scope( + $this->resolver, + $this->scopes + ); + + $this->scopes->register($scope); + + return $scope; } @@ -156,6 +200,7 @@ public function createScope ():ScopeBoundary { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -209,6 +254,7 @@ public function register (string $id, Definition $definition):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateContextualBindingException If the binding already exists. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -243,6 +289,7 @@ public function bindContextual (string $consumer, string $dependency, string $im * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function bind (string $id, string $class, Lifetime $lifetime = Lifetime::TRANSIENT):self { @@ -288,6 +335,7 @@ public function bind (string $id, string $class, Lifetime $lifetime = Lifetime:: * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -342,6 +390,7 @@ public function singleton (string $id, ?string $class = null):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -387,6 +436,7 @@ public function scoped (string $id, ?string $class = null):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -436,6 +486,7 @@ public function transient (string $id, ?string $class = null):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function factory (string $id, Closure $factory, Lifetime $lifetime = Lifetime::TRANSIENT):self { @@ -469,6 +520,7 @@ public function factory (string $id, Closure $factory, Lifetime $lifetime = Life * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function instance (string $id, object $instance):self { @@ -499,6 +551,7 @@ public function instance (string $id, object $instance):self { * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. * @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function alias (string $id, string $target):self { @@ -548,6 +601,7 @@ public function alias (string $id, string $target):self { *

* * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return $this Container instance. */ @@ -583,6 +637,7 @@ public function bindParameters (string $class, array $parameters):self { * * @since 1.0.0 * + * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. * @uses \FireHub\Container\Resolver::all() To resolve matching services. * * @template TService of object @@ -598,11 +653,14 @@ public function bindParameters (string $class, array $parameters):self { * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return list Matching service instances. */ public function all (string $type):array { + $this->assertOpen(); + return $this->resolver->all( $type, new ResolutionContext @@ -677,6 +735,7 @@ public function all (string $type):array { *

* * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * @throws \FireHub\Container\Exception\ServiceNotFoundException If a service is not explicitly registered. * @throws \FireHub\Container\Exception\InvalidTagNameException If the tag name is empty. * @@ -775,15 +834,20 @@ public function hasTag (string $tag):bool { * @since 1.0.0 * * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. + * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. * * @param non-empty-string $tag

* Tag name. *

* + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return list Tagged service identifiers. */ public function taggedIds (string $tag):array { + $this->assertOpen(); + return $this->tags->get($tag); } @@ -817,6 +881,7 @@ public function taggedIds (string $tag):array { * @since 1.0.0 * * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services. + * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. * * @param non-empty-string $tag

* Service tag name. @@ -831,11 +896,14 @@ public function taggedIds (string $tag):array { * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return list Resolved service instances. */ public function tagged (string $tag):array { + $this->assertOpen(); + return $this->resolver->tagged( $tag, new ResolutionContext @@ -871,6 +939,7 @@ public function tagged (string $tag):array { * @since 1.0.0 * * @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection. + * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. * * @param callable $callback

* Callable to execute. @@ -883,11 +952,14 @@ public function tagged (string $tag):array { * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the * resolved dependency does not satisfy its declared type. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return mixed Value returned by the invoked callable. */ public function invoke (callable $callback, array $arguments = []):mixed { + $this->assertOpen(); + return $this->resolver->invoke( $callback, $arguments @@ -916,6 +988,9 @@ public function invoke (callable $callback, array $arguments = []):mixed { * * @since 1.0.0 * + * @uses \FireHub\Container\Resolver::get() To resolve the service. + * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open. + * * @template TService of object * * @param class-string $id

@@ -930,11 +1005,14 @@ public function invoke (callable $callback, array $arguments = []):mixed { * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return TService Resolved service. */ public function get (string $id):object { + $this->assertOpen(); + return $this->resolver->get($id); } @@ -960,18 +1038,115 @@ public function get (string $id):object { * * @since 1.0.0 * + * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open. + * @uses \FireHub\Container\Internal\ServiceRegistry::has() To check service registration. + * * @param class-string $id

* Service identifier. *

* + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return bool True if registered, false otherwise. */ public function has (string $id):bool { + $this->assertOpen(); + return $this->registry->has($id); } + /** + * ### Closes the dependency injection container + * + * Permanently closes the container and releases all owned singleton disposable + * service instances. + * + * The container cannot be closed while active scopes exist. + * + * Disposable services are released in reverse registration order. + * + * Singleton instances are cleared even if resource disposal fails. + * + * Closing an already closed container has no additional effect. + * + * + * use FireHub\Container\Container; + * + * $container = new Container; + * + * $container->singleton(DatabaseConnection::class); + * + * $connection = $container->get(DatabaseConnection::class); + * + * try { + * + * $connection->connect(); + * + * } finally { + * + * $container->close(); + * + * } + * + * $container->isClosed(); // true + * + * + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\DisposalTracker::dispose() To release owned singleton resources. + * @uses \FireHub\Container\Internal\DisposableRegistry::clear() To clear owned singleton instances. + * @uses \FireHub\Container\Internal\ScopeRegistry::hasActive() To check for active scopes. + * + * @throws \FireHub\Container\Exception\ServiceDisposalException If one or more services fail during disposal. + * @throws \FireHub\Container\Exception\ContainerActiveScopesException If a scope is still active. + * + * @return void + */ + public function close ():void { + + if ($this->closed) return; + + if ($this->scopes->hasActive()) + throw new ContainerActiveScopesException; + + $this->closed = true; + + try { + + $this->disposables->dispose(); + + } finally { + + $this->singletons->clear(); + + } + + } + + /** + * ### Checks whether the container is closed + * + * + * $container = new Container; + * + * $container->isClosed(); // false + * + * $container->close(); + * + * $container->isClosed(); // true + * + * @since 1.0.0 + * + * @return bool True if the container has been shut down. + */ + public function isClosed ():bool { + + return $this->closed; + + } + /** * ### Freezes the container * @@ -1032,6 +1207,23 @@ public function isFrozen ():bool { } + /** + * ### Ensures the container is open + * + * Prevents operations requiring an active container after shutdown. + * @since 1.0.0 + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * + * @return void + */ + private function assertOpen ():void { + + if ($this->closed) + throw new ContainerClosedException; + + } + /** * ### Ensures the container accepts registrations * @@ -1039,11 +1231,14 @@ public function isFrozen ():bool { * @since 1.0.0 * * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return void */ private function assertMutable ():void { + $this->assertOpen(); + if ($this->frozen) throw new ContainerFrozenException; diff --git a/src/Exception/ContainerActiveScopesException.php b/src/Exception/ContainerActiveScopesException.php new file mode 100644 index 0000000..31a793f --- /dev/null +++ b/src/Exception/ContainerActiveScopesException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\Runtime\System\InvariantException; + +/** + * ### Thrown when a container is attempted to be modified while it is active + * @since 1.0.0 + */ +final class ContainerActiveScopesException extends InvariantException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The dependency injection container is currently active.'; + +} \ No newline at end of file diff --git a/src/Exception/ContainerClosedException.php b/src/Exception/ContainerClosedException.php new file mode 100644 index 0000000..0147dcc --- /dev/null +++ b/src/Exception/ContainerClosedException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Exception; + +use FireHub\Core\Exception\Runtime\System\InvariantException; + +/** + * ### Indicates that a container is closed + * @since 1.0.0 + */ +final class ContainerClosedException extends InvariantException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The container is closed'; + +} \ No newline at end of file diff --git a/src/Internal/ResolutionContext.php b/src/Internal/ResolutionContext.php index d71208b..2866a64 100644 --- a/src/Internal/ResolutionContext.php +++ b/src/Internal/ResolutionContext.php @@ -41,13 +41,17 @@ * @param null|\FireHub\Container\Internal\ScopeState $scope

* Associated scope lifecycle state. *

+ * @param null|\FireHub\Container\Internal\DisposalTracker $disposables

+ * Scoped disposable resource tracker. + *

* * @return void */ public function __construct ( public ResolutionStack $stack = new ResolutionStack, public ?InstanceStore $scoped = null, - public ?ScopeState $scope = null + public ?ScopeState $scope = null, + public ?DisposalTracker $disposables = null ) {} /** @@ -61,7 +65,7 @@ public function __construct ( */ public function root ():self { - if ($this->scoped === null) + if ($this->scoped === null && $this->disposables === null) return $this; return new self( diff --git a/src/Internal/ScopeRegistry.php b/src/Internal/ScopeRegistry.php new file mode 100644 index 0000000..7ca35de --- /dev/null +++ b/src/Internal/ScopeRegistry.php @@ -0,0 +1,111 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Scope; +use SplObjectStorage; + +/** + * ### Tracks active dependency injection scopes + * + * Maintains a registry of scopes belonging to a dependency injection container. + * + * Scopes are registered when created and removed when closed. + * + * Weak references prevent the registry from retaining scopes solely because they + * were registered. + * + * The registry does not control the scope lifecycle or dispose of scoped services. + * @since 1.0.0 + */ +final class ScopeRegistry { + + /** + * ### Registered scopes + * @since 1.0.0 + * + * @var SplObjectStorage<\FireHub\Container\Scope, null> + */ + private SplObjectStorage $scopes; + + /** + * ### Constructor + * @since 1.0.0 + * + * @return void + */ + public function __construct () { + + $this->scopes = new SplObjectStorage; + + } + + /** + * ### Registers a scope + * @since 1.0.0 + * + * @param \FireHub\Container\Scope $scope

+ * Scope to register. + *

+ * + * @return void + */ + public function register (Scope $scope):void { + + $this->scopes->offsetSet($scope); + + } + + /** + * ### Removes a scope from the registry + * + * @since 1.0.0 + * + * @param \FireHub\Container\Scope $scope

+ * Scope to remove. + *

+ * + * @return void + */ + public function unregister (Scope $scope):void { + + $this->scopes->offsetUnset($scope); + + } + + /** + * ### Counts registered scopes + * @since 1.0.0 + * + * @return int Number of registered scopes. + */ + public function count ():int { + + return $this->scopes->count(); + + } + + /** + * ### Checks whether active scopes exist + * @since 1.0.0 + * + * @return bool True if at least one scope is registered. + */ + public function hasActive ():bool { + + return $this->scopes->count() > 0; + + } + +} \ No newline at end of file diff --git a/src/Resolver.php b/src/Resolver.php index f0d3d47..80e6d3a 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -14,14 +14,16 @@ namespace FireHub\Container; use FireHub\Core\Boundary\Resolver as ResolverBoundary; +use FireHub\Core\Boundary\Lifecycle\Disposable; use FireHub\Core\Meta\Enum\Container\Lifetime; use FireHub\Container\Definition\ { Alias, Cls, Factory, Instance, Service }; use FireHub\Container\Internal\ { Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, - ContextualResolver, DependencyPlanCache, InstanceStore, MethodInvoker, MethodPlanCache, ParameterBindingRegistry, - ParameterPlanCompiler, ParameterResolver, ResolutionContext, ScopeState, TagRegistry, TypeMatcher + ContextualResolver, DependencyPlanCache, DisposalTracker, InstanceStore, MethodInvoker, MethodPlanCache, + ParameterBindingRegistry, ParameterPlanCompiler, ParameterResolver, ResolutionContext, ScopeState, TagRegistry, + TypeMatcher }; use FireHub\Container\Exception\ { CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, @@ -102,6 +104,9 @@ * @param \FireHub\Container\Internal\TagRegistry $tags

* Service tag registry. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + *

* * @return void */ @@ -110,7 +115,8 @@ public function __construct ( private InstanceStore $singletons, ParameterBindingRegistry $parameters, ContextualBindingRegistry $contextual, - private TagRegistry $tags + private TagRegistry $tags, + private DisposalTracker $disposables ) { $this->metadata = new ConstructorMetadataCache; @@ -239,6 +245,9 @@ public function get (string $id):object { * @param \FireHub\Container\Internal\ScopeState $scope

* Scope lifecycle state. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + * * * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible with its registered identifier. @@ -250,7 +259,7 @@ public function get (string $id):object { * * @return TService Resolved service. */ - public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope):object { + public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):object { $scope->enter(); @@ -260,7 +269,8 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope) $id, new ResolutionContext( scoped: $scoped, - scope: $scope + scope: $scope, + disposables: $disposables ) ); @@ -360,6 +370,9 @@ public function all (string $type, ResolutionContext $context):array { * @param \FireHub\Container\Internal\ScopeState $scope

* Scope state. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + * * * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered. * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible @@ -372,7 +385,7 @@ public function all (string $type, ResolutionContext $context):array { * * @return list Matching service instances. */ - public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope):array { + public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):array { $scope->enter(); @@ -382,7 +395,8 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop $type, new ResolutionContext( scoped: $scoped, - scope: $scope + scope: $scope, + disposables: $disposables ) ); @@ -460,6 +474,9 @@ public function tagged (string $tag, ResolutionContext $context):array { * @param \FireHub\Container\Internal\ScopeState $scope

* Scope lifecycle state. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + * * * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered. @@ -474,7 +491,7 @@ public function tagged (string $tag, ResolutionContext $context):array { * * @return list Resolved service instances. */ - public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope):array { + public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):array { $scope->enter(); @@ -484,7 +501,8 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc $tag, new ResolutionContext( scoped: $scoped, - scope: $scope + scope: $scope, + disposables: $disposables ) ); @@ -555,6 +573,9 @@ public function invoke (callable $callback, array $arguments = []):mixed { * @param \FireHub\Container\Internal\ScopeState $scope

* Active scope lifecycle state. *

+ * @param \FireHub\Container\Internal\DisposalTracker $disposables

+ * Disposable service tracker. + *

* * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid. * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the @@ -564,7 +585,8 @@ public function invoke (callable $callback, array $arguments = []):mixed { * * @return mixed Value returned by the invoked callable. */ - public function invokeScoped (callable $callback, array $arguments, InstanceStore $scoped, ScopeState $scope):mixed { + public function invokeScoped (callable $callback, array $arguments, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables) + :mixed { $scope->enter(); @@ -575,7 +597,8 @@ public function invokeScoped (callable $callback, array $arguments, InstanceStor $arguments, new ResolutionContext( scoped: $scoped, - scope: $scope + scope: $scope, + disposables: $disposables ) ); @@ -666,6 +689,7 @@ private function resolve (string $id, ResolutionContext $context):object { * @uses \FireHub\Container\Internal\ResolutionStack::enter() To mark the service as being currently being resolved. * @uses \FireHub\Container\Internal\ResolutionStack::leave() To remove the service from the resolution stack. * @uses \FireHub\Container\Resolver::create() To construct a service instance. + * @uses \FireHub\Container\Internal\DisposalTracker::track() To track a disposable service. * * @template TService of object * @@ -696,7 +720,6 @@ private function resolveService (string $id, Service $definition, ResolutionCont Lifetime::SCOPED => $context->scoped ?? throw new ScopeRequiredException, Lifetime::TRANSIENT => null - }; if ($store !== null) { @@ -720,6 +743,19 @@ private function resolveService (string $id, Service $definition, ResolutionCont $store?->set($id, $instance); + if ( + $instance instanceof Disposable + && !$definition instanceof Instance + ) { + + match ($lifetime) { + Lifetime::SINGLETON => $this->disposables->track($instance), + Lifetime::SCOPED => $context->disposables?->track($instance), + Lifetime::TRANSIENT => null + }; + + } + return $instance; } finally { diff --git a/src/Scope.php b/src/Scope.php index 3225918..07b0adb 100644 --- a/src/Scope.php +++ b/src/Scope.php @@ -15,7 +15,7 @@ use FireHub\Core\Boundary\Container\Scope as ScopeBoundary; use FireHub\Container\Internal\ { - InstanceStore, ScopeState + DisposalTracker, InstanceStore, ScopeRegistry, ScopeState }; use Closure; @@ -50,6 +50,12 @@ */ private ScopeState $state; + /** + * ### Scoped disposable resource tracker + * @since 1.0.0 + */ + private DisposalTracker $disposables; + /** * ### Constructor * @since 1.0.0 @@ -57,17 +63,23 @@ * @param \FireHub\Container\Resolver $resolver

* Parent container's service resolver. *

+ * @param \FireHub\Container\Internal\ScopeRegistry $registry

+ * Parent container's scope registry. + *

* * @return void */ public function __construct ( - private Resolver $resolver + private Resolver $resolver, + private ScopeRegistry $registry ) { $this->instances = new InstanceStore; $this->state = new ScopeState; + $this->disposables = new DisposalTracker; + } /** @@ -137,7 +149,8 @@ public function tagged (string $tag):array { return $this->resolver->taggedScoped( $tag, $this->instances, - $this->state + $this->state, + $this->disposables ); } @@ -203,7 +216,8 @@ public function invoke (callable $callback, array $arguments = []):mixed { $callback, $arguments, $this->instances, - $this->state + $this->state, + $this->disposables ); } @@ -261,7 +275,8 @@ public function get (string $id):object { return $this->resolver->getScoped( $id, $this->instances, - $this->state + $this->state, + $this->disposables ); } @@ -362,7 +377,8 @@ public function all (string $type):array { return $this->resolver->allScoped( $type, $this->instances, - $this->state + $this->state, + $this->disposables ); } @@ -413,7 +429,8 @@ public function all (string $type):array { *

* * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. - * @throws \FireHub\Container\Exception\ScopeActiveException If the scope is already executing. + * @throws \FireHub\Container\Exception\ScopeActiveException If service resolution is active. + * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources. * * @return TResult Callback result. */ @@ -473,12 +490,15 @@ public function isClosed ():bool { /** * ### Closes the scope * - * Releases all cached scoped service instances and prevents further service resolution through this scope. + * Prevents further service resolution and disposes of all owned scoped service instances. + * + * Disposable services are released in reverse registration order. + * + * The instance store is cleared even when resource disposal fails. * * Closing an already closed scope has no additional effect. * - * Closing a scope does not affect singleton instances maintained by the parent container or instances stored in - * other scopes. + * Singleton instances and resources owned by other scopes remain unaffected. * * * use FireHub\Container\Container; @@ -491,10 +511,8 @@ public function isClosed ():bool { * * $connection = $scope->get(DatabaseConnection::class); * - * // Release cached scoped instances. * $scope->close(); * - * // The scope can no longer resolve services. * $scope->isClosed(); // true * * @@ -502,8 +520,11 @@ public function isClosed ():bool { * * @uses \FireHub\Container\Internal\ScopeState::close() To close the scope. * @uses \FireHub\Container\Internal\InstanceStore::clear() To clear the instance store. + * @uses \FireHub\Container\Internal\DisposalTracker::dispose() To dispose of disposable resources. + * @uses \FireHub\Container\Internal\ScopeRegistry::unregister() To unregister the scope from the registry. * * @throws \FireHub\Container\Exception\ScopeActiveException If service resolution is active. + * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources. * * @return void */ @@ -511,7 +532,23 @@ public function close ():void { $this->state->close(); - $this->instances->clear(); + try { + + $this->disposables->dispose(); + + } finally { + + try { + + $this->instances->clear(); + + } finally { + + $this->registry->unregister($this); + + } + + } } From 7ab7da34edb6d47f03578aebf04a929d88c6289a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 12:46:20 +0200 Subject: [PATCH 07/11] Add ContainerState class to manage dependency injection lifecycle states --- src/Container.php | 143 +++++++++++------------------ src/Internal/ContainerState.php | 158 ++++++++++++++++++++++++++++++++ src/Internal/ScopeRegistry.php | 4 +- src/Resolver.php | 68 +++++++++++--- 4 files changed, 271 insertions(+), 102 deletions(-) create mode 100644 src/Internal/ContainerState.php diff --git a/src/Container.php b/src/Container.php index 58bfb97..ba31a08 100644 --- a/src/Container.php +++ b/src/Container.php @@ -21,12 +21,11 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeRegistry, - TagRegistry + ContainerState, ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry, + ResolutionContext, ScopeRegistry, TagRegistry }; use FireHub\Container\Exception\ { - ContainerActiveScopesException, ContainerClosedException, ContainerFrozenException, InvalidTagNameException, - ServiceNotFoundException + ContainerActiveScopesException, InvalidTagNameException, ServiceNotFoundException }; use FireHub\Runtime; use Closure; @@ -47,67 +46,61 @@ * resolution scope. * @since 1.0.0 */ -final class Container implements ContainerBoundary, Registrar { +final readonly class Container implements ContainerBoundary, Registrar { /** * ### Service registry * @since 1.0.0 */ - private readonly Registry $registry; + private Registry $registry; /** * ### Service resolver * @since 1.0.0 */ - private readonly Resolver $resolver; + private Resolver $resolver; /** * ### Constructor parameter binding registry * @since 1.0.0 */ - private readonly ParameterBindingRegistry $parameters; + private ParameterBindingRegistry $parameters; /** * ### Contextual dependency binding registry * @since 1.0.0 */ - private readonly ContextualBindingRegistry $contextual; + private ContextualBindingRegistry $contextual; /** * ### Singleton instance store * @since 1.0.0 */ - private readonly InstanceStore $singletons; + private InstanceStore $singletons; /** * ### Service tag registry * @since 1.0.0 */ - private readonly TagRegistry $tags; + private TagRegistry $tags; /** * ### Active scope registry * @since 1.0.0 */ - private readonly ScopeRegistry $scopes; + private ScopeRegistry $scopes; /** * ### Singleton disposable resource tracker * @since 1.0.0 */ - private readonly DisposalTracker $disposables; + private DisposalTracker $disposables; /** - * ### Container shutdown state + * ### Container lifecycle state * @since 1.0.0 */ - private bool $closed = false; - - /** - * ### Container freeze state - * @var bool - */ - private bool $frozen = false; + private ContainerState $state; /** * ### Constructor @@ -124,6 +117,7 @@ public function __construct () { $this->tags = new TagRegistry; $this->scopes = new ScopeRegistry; $this->disposables = new DisposalTracker; + $this->state = new ContainerState; $this->resolver = new Resolver( $this->registry, @@ -131,7 +125,8 @@ public function __construct () { $this->parameters, $this->contextual, $this->tags, - $this->disposables + $this->disposables, + $this->state ); } @@ -149,14 +144,14 @@ public function __construct () { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * @uses \FireHub\Container\Internal\ScopeRegistry::register() To register a new scope. * * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. */ public function createScope ():ScopeBoundary { - $this->assertOpen(); + $this->state->assertOpen(); $scope = new Scope( $this->resolver, @@ -186,7 +181,7 @@ public function createScope ():ScopeBoundary { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() To check if the container is frozen. + * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To check if the container is frozen. * @uses \FireHub\Container\Registry::register() To register a service definition. * * @template TService of object @@ -206,7 +201,7 @@ public function createScope ():ScopeBoundary { */ public function register (string $id, Definition $definition):self { - $this->assertMutable(); + $this->state->assertMutable(); $this->registry->register($id, $definition); @@ -239,8 +234,8 @@ public function register (string $id, Definition $definition):self { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() - * @uses \FireHub\Container\Internal\ContextualBindingRegistry::register() + * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To check if the container is frozen. + * @uses \FireHub\Container\Internal\ContextualBindingRegistry::register() To register a contextual binding. * * @param class-string $consumer

* Consumer implementation class. @@ -260,7 +255,7 @@ public function register (string $id, Definition $definition):self { */ public function bindContextual (string $consumer, string $dependency, string $implementation):self { - $this->assertMutable(); + $this->state->assertMutable(); $this->contextual->register( $consumer, @@ -590,7 +585,8 @@ public function alias (string $id, string $target):self { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() To ensure the container accepts configuration changes. + * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To ensure the container accepts + * configuration changes. * @uses \FireHub\Container\Internal\ParameterBindingRegistry::register() To register parameter bindings. * * @param class-string $class

@@ -607,7 +603,7 @@ public function alias (string $id, string $target):self { */ public function bindParameters (string $class, array $parameters):self { - $this->assertMutable(); + $this->state->assertMutable(); $this->parameters->register($class, $parameters); @@ -637,7 +633,7 @@ public function bindParameters (string $class, array $parameters):self { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * @uses \FireHub\Container\Resolver::all() To resolve matching services. * * @template TService of object @@ -659,7 +655,7 @@ public function bindParameters (string $class, array $parameters):self { */ public function all (string $type):array { - $this->assertOpen(); + $this->state->assertOpen(); return $this->resolver->all( $type, @@ -722,7 +718,7 @@ public function all (string $type):array { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertMutable() To ensure the container is not frozen. + * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To ensure the container is not frozen. * @uses \FireHub\Container\Container::has() To verify explicit service registrations. * @uses \FireHub\Container\Internal\TagRegistry::register() To register service tag associations. * @uses \FireHub\Runtime\Str\SB\Transform::trim() To validate tag names. @@ -743,7 +739,7 @@ public function all (string $type):array { */ public function tag (string $tag, string ...$services):self { - $this->assertMutable(); + $this->state->assertMutable(); if (Runtime\Str\SB\Transform::trim($tag) === '') throw new InvalidTagNameException; @@ -834,7 +830,7 @@ public function hasTag (string $tag):bool { * @since 1.0.0 * * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. - * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * * @param non-empty-string $tag

* Tag name. @@ -846,7 +842,7 @@ public function hasTag (string $tag):bool { */ public function taggedIds (string $tag):array { - $this->assertOpen(); + $this->state->assertOpen(); return $this->tags->get($tag); @@ -881,7 +877,7 @@ public function taggedIds (string $tag):array { * @since 1.0.0 * * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services. - * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * * @param non-empty-string $tag

* Service tag name. @@ -902,7 +898,7 @@ public function taggedIds (string $tag):array { */ public function tagged (string $tag):array { - $this->assertOpen(); + $this->state->assertOpen(); return $this->resolver->tagged( $tag, @@ -939,7 +935,7 @@ public function tagged (string $tag):array { * @since 1.0.0 * * @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection. - * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution. * * @param callable $callback

* Callable to execute. @@ -958,7 +954,7 @@ public function tagged (string $tag):array { */ public function invoke (callable $callback, array $arguments = []):mixed { - $this->assertOpen(); + $this->state->assertOpen(); return $this->resolver->invoke( $callback, @@ -989,7 +985,7 @@ public function invoke (callable $callback, array $arguments = []):mixed { * @since 1.0.0 * * @uses \FireHub\Container\Resolver::get() To resolve the service. - * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * * @template TService of object * @@ -1011,7 +1007,7 @@ public function invoke (callable $callback, array $arguments = []):mixed { */ public function get (string $id):object { - $this->assertOpen(); + $this->state->assertOpen(); return $this->resolver->get($id); @@ -1038,7 +1034,7 @@ public function get (string $id):object { * * @since 1.0.0 * - * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open. + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Internal\ServiceRegistry::has() To check service registration. * * @param class-string $id

@@ -1051,7 +1047,7 @@ public function get (string $id):object { */ public function has (string $id):bool { - $this->assertOpen(); + $this->state->assertOpen(); return $this->registry->has($id); @@ -1098,6 +1094,8 @@ public function has (string $id):bool { * @uses \FireHub\Container\Internal\DisposalTracker::dispose() To release owned singleton resources. * @uses \FireHub\Container\Internal\DisposableRegistry::clear() To clear owned singleton instances. * @uses \FireHub\Container\Internal\ScopeRegistry::hasActive() To check for active scopes. + * @uses \FireHub\Container\Internal\ContainerState::close() To mark the container as closed. + * @uses \FireHub\Container\Internal\ContainerState::isClosed() To check if the container is closed. * * @throws \FireHub\Container\Exception\ServiceDisposalException If one or more services fail during disposal. * @throws \FireHub\Container\Exception\ContainerActiveScopesException If a scope is still active. @@ -1106,12 +1104,12 @@ public function has (string $id):bool { */ public function close ():void { - if ($this->closed) return; + if ($this->state->isClosed()) return; if ($this->scopes->hasActive()) throw new ContainerActiveScopesException; - $this->closed = true; + $this->state->close(); try { @@ -1139,11 +1137,13 @@ public function close ():void { * * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::isClosed() To check if the container is closed. + * * @return bool True if the container has been shut down. */ public function isClosed ():bool { - return $this->closed; + return $this->state->isClosed(); } @@ -1174,11 +1174,15 @@ public function isClosed ():bool { * * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::freeze() To mark the container as frozen. + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return void */ public function freeze ():void { - $this->frozen = true; + $this->state->freeze(); } @@ -1199,48 +1203,13 @@ public function freeze ():void { * * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::isFrozen() To check if the container is frozen. + * * @return bool True if frozen, false otherwise. */ public function isFrozen ():bool { - return $this->frozen; - - } - - /** - * ### Ensures the container is open - * - * Prevents operations requiring an active container after shutdown. - * @since 1.0.0 - * - * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. - * - * @return void - */ - private function assertOpen ():void { - - if ($this->closed) - throw new ContainerClosedException; - - } - - /** - * ### Ensures the container accepts registrations - * - * Prevents modification of service definitions after the container has been frozen. - * @since 1.0.0 - * - * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. - * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. - * - * @return void - */ - private function assertMutable ():void { - - $this->assertOpen(); - - if ($this->frozen) - throw new ContainerFrozenException; + return $this->state->isFrozen(); } diff --git a/src/Internal/ContainerState.php b/src/Internal/ContainerState.php new file mode 100644 index 0000000..02ff9d7 --- /dev/null +++ b/src/Internal/ContainerState.php @@ -0,0 +1,158 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Exception\ { + ContainerClosedException, ContainerFrozenException +}; + +/** + * ### Manages dependency injection container lifecycle state + * + * Maintains the shared lifecycle state of a dependency injection container. + * + * The container supports three logical states: + * - Open: Registration and resolution are permitted. + * - Frozen: Registration is prohibited, but resolution remains permitted. + * - Closed: Registration and resolution are permanently prohibited. + * + * Freezing is irreversible and does not affect existing service instances. + * + * Closing permanently terminates the container lifecycle regardless of whether the container was previously frozen. + * + * The state is shared between the container and its resolver to enforce consistent lifecycle restrictions. + * @since 1.0.0 + * + * @internal + */ +final class ContainerState { + + /** + * ### Container freeze state + * @since 1.0.0 + */ + private bool $frozen = false; + + /** + * ### Container closure state + * @since 1.0.0 + */ + private bool $closed = false; + + /** + * ### Ensures the container is open + * + * Prevents operations requiring an active container after closure. + * @since 1.0.0 + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * + * @return void + */ + public function assertOpen ():void { + + if ($this->closed) + throw new ContainerClosedException; + + } + + /** + * ### Ensures the container accepts configuration changes + * + * Prevents service registration and configuration changes when the container is frozen or closed. + * + * Closure takes precedence over freezing when both conditions apply. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen. + * + * @return void + */ + public function assertMutable ():void { + + $this->assertOpen(); + + if ($this->frozen) + throw new ContainerFrozenException; + + } + + /** + * ### Freezes the container configuration + * + * Permanently prevents further registration and configuration changes. + * + * Existing services remain available for resolution. + * + * Repeated calls have no additional effect. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. + * + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * + * @return void + */ + public function freeze ():void { + + $this->assertOpen(); + + $this->frozen = true; + + } + + /** + * ### Checks whether the container is frozen + * @since 1.0.0 + * + * @return bool True if the container configuration has been frozen. + */ + public function isFrozen ():bool { + + return $this->frozen; + + } + + /** + * ### Closes the container lifecycle + * + * Permanently marks the container as closed. + * + * Repeated calls have no additional effect. + * @since 1.0.0 + * + * @return void + */ + public function close ():void { + + $this->closed = true; + + } + + /** + * ### Checks whether the container is closed + * @since 1.0.0 + * + * @return bool True if the container has been closed, false otherwise. + */ + public function isClosed ():bool { + + return $this->closed; + + } + +} \ No newline at end of file diff --git a/src/Internal/ScopeRegistry.php b/src/Internal/ScopeRegistry.php index 7ca35de..5a5ea15 100644 --- a/src/Internal/ScopeRegistry.php +++ b/src/Internal/ScopeRegistry.php @@ -23,8 +23,8 @@ * * Scopes are registered when created and removed when closed. * - * Weak references prevent the registry from retaining scopes solely because they - * were registered. + * The registry maintains strong references to registered scopes, ensuring that + * active scopes remain tracked until they are explicitly closed. * * The registry does not control the scope lifecycle or dispose of scoped services. * @since 1.0.0 diff --git a/src/Resolver.php b/src/Resolver.php index 80e6d3a..eaac630 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -20,10 +20,10 @@ Alias, Cls, Factory, Instance, Service }; use FireHub\Container\Internal\ { - Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, - ContextualResolver, DependencyPlanCache, DisposalTracker, InstanceStore, MethodInvoker, MethodPlanCache, - ParameterBindingRegistry, ParameterPlanCompiler, ParameterResolver, ResolutionContext, ScopeState, TagRegistry, - TypeMatcher + Autowirer, CandidateSelector, ConstructorMetadataCache, ContainerState, ContextualBindingRegistry, + ContextualCandidateSelector, ContextualResolver, DependencyPlanCache, DisposalTracker, InstanceStore, + MethodInvoker, MethodPlanCache, ParameterBindingRegistry, ParameterPlanCompiler, ParameterResolver, + ResolutionContext, ScopeState, TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, @@ -107,6 +107,9 @@ * @param \FireHub\Container\Internal\DisposalTracker $disposables

* Disposable service tracker. *

+ * @param \FireHub\Container\Internal\ContainerState $state

+ * Container state. + *

* * @return void */ @@ -116,7 +119,8 @@ public function __construct ( ParameterBindingRegistry $parameters, ContextualBindingRegistry $contextual, private TagRegistry $tags, - private DisposalTracker $disposables + private DisposalTracker $disposables, + private ContainerState $state ) { $this->metadata = new ConstructorMetadataCache; @@ -169,16 +173,21 @@ public function __construct ( * Checks whether a service identifier exists without constructing or resolving its instance. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Registry::has() To check for service existence. * * @param class-string $id

* Service identifier. *

* + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. + * * @return bool True if the service is registered. */ public function has (string $id):bool { + $this->state->assertOpen(); + return $this->registry->has($id); } @@ -191,6 +200,7 @@ public function has (string $id):bool { * Aliases are followed until their underlying service definition is reached. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Resolver::resolve() To resolve a service. * @uses \FireHub\Container\Internal\InstanceStore::get() To retrieve a previously resolved instance. * @@ -208,11 +218,14 @@ public function has (string $id):bool { * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return TService Resolved service instance. */ public function get (string $id):object { + $this->state->assertOpen(); + $instance = $this->singletons->get($id); if ($instance !== null) @@ -256,6 +269,7 @@ public function get (string $id):object { * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return TService Resolved service. */ @@ -293,6 +307,7 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope, * Service aliases are resolved to their underlying targets and do not produce duplicate collection entries. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Registry::all() To retrieve all registered service identifiers. * @uses \FireHub\Container\Resolver::canonicalId() To resolve service aliases. * @uses \FireHub\Container\Resolver::matchesCollectionType() To check whether a service matches the specified type. @@ -313,11 +328,14 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope, * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return list Matching service instances. */ public function all (string $type, ResolutionContext $context):array { + $this->state->assertOpen(); + $services = []; $resolved = []; foreach ($this->registry->all() as $id => $definition) { @@ -382,6 +400,7 @@ public function all (string $type, ResolutionContext $context):array { * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return list Matching service instances. */ @@ -419,6 +438,7 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop * An unknown tag produces an empty collection. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. * @uses \FireHub\Container\Resolver::resolve() To resolve each tagged service. * @@ -438,11 +458,14 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return list Resolved service instances. */ public function tagged (string $tag, ResolutionContext $context):array { + $this->state->assertOpen(); + $services = []; foreach ($this->tags->get($tag) as $id) @@ -488,6 +511,7 @@ public function tagged (string $tag, ResolutionContext $context):array { * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return list Resolved service instances. */ @@ -523,6 +547,7 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc * invocation is performed through an active scope. * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Internal\MethodInvoker::invoke() To execute the callable. * * @param callable $callback

@@ -536,11 +561,14 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the * resolved dependency does not satisfy its declared type. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return mixed Value returned by the invoked callable. */ public function invoke (callable $callback, array $arguments = []):mixed { + $this->state->assertOpen(); + return $this->invoker->invoke( $callback, $arguments, @@ -614,6 +642,7 @@ public function invokeScoped (callable $callback, array $arguments, InstanceStor * ### Resolves a service within the specified context * @since 1.0.0 * + * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open. * @uses \FireHub\Container\Registry::find() To retrieve a service definition. * @uses \FireHub\Container\Internal\ResolutionStack::enter() To mark the service as being currently being resolved. * @uses \FireHub\Container\Internal\ResolutionStack::leave() To remove the service from the resolution stack. @@ -636,11 +665,14 @@ public function invokeScoped (callable $callback, array $arguments, InstanceStor * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided. * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed. * * @return TService Resolved service. */ private function resolve (string $id, ResolutionContext $context):object { + $this->state->assertOpen(); + $definition = $this->registry->find($id); if ($definition === null) @@ -974,11 +1006,16 @@ private function matchesCollectionType (string $id, string $target, string $type * Service definition. *

* + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not + * instantiable or a required constructor dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are + * eligible. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy + * its registered service type. * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported. - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot - * be resolved. - * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. * * @return object Created service instance. */ @@ -1013,10 +1050,15 @@ private function create (Service $definition, ResolutionContext $context):object * Active resolution context. *

* - * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot - * be resolved. - * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered. - * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not + * instantiable or a required constructor dependency cannot be resolved. + * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are + * eligible. + * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected. + * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope. + * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy + * its registered service type. * * @return object Constructed service instance. */ From 45bb67128e98635376fd757927a908e87365052e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 12:52:22 +0200 Subject: [PATCH 08/11] Add ContainerState class to manage dependency injection lifecycle states --- src/Resolver.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Resolver.php b/src/Resolver.php index eaac630..3214784 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -773,8 +773,6 @@ private function resolveService (string $id, Service $definition, ResolutionCont if (!$instance instanceof $id) throw new ServiceTypeMismatchException; - $store?->set($id, $instance); - if ( $instance instanceof Disposable && !$definition instanceof Instance @@ -788,6 +786,8 @@ private function resolveService (string $id, Service $definition, ResolutionCont } + $store?->set($id, $instance); + return $instance; } finally { From cb12556f033a980eec00ddcf4cfb2c6b6258e5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 13:04:24 +0200 Subject: [PATCH 09/11] Add disposable service classes for container lifecycle testing --- tests/Stubs/DisposableTestService.php | 42 ++ tests/Stubs/FailingDisposableTestService.php | 47 ++ tests/Stubs/FirstOrderedDisposableService.php | 20 + tests/Stubs/OrderedDisposableTestService.php | 50 ++ .../Stubs/ResolvingDisposableTestService.php | 49 ++ .../Stubs/SecondOrderedDisposableService.php | 20 + tests/Unit/ContainerLifecycleTest.php | 505 ++++++++++++++++++ tests/Unit/DisposableServicesTest.php | 426 +++++++++++++++ 8 files changed, 1159 insertions(+) create mode 100644 tests/Stubs/DisposableTestService.php create mode 100644 tests/Stubs/FailingDisposableTestService.php create mode 100644 tests/Stubs/FirstOrderedDisposableService.php create mode 100644 tests/Stubs/OrderedDisposableTestService.php create mode 100644 tests/Stubs/ResolvingDisposableTestService.php create mode 100644 tests/Stubs/SecondOrderedDisposableService.php create mode 100644 tests/Unit/ContainerLifecycleTest.php create mode 100644 tests/Unit/DisposableServicesTest.php diff --git a/tests/Stubs/DisposableTestService.php b/tests/Stubs/DisposableTestService.php new file mode 100644 index 0000000..cc521c5 --- /dev/null +++ b/tests/Stubs/DisposableTestService.php @@ -0,0 +1,42 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Core\Boundary\Lifecycle\Disposable; + +/** + * ### Disposable test service + * @since 1.0.0 + */ +final class DisposableTestService implements Disposable { + + /** + * ### Number of disposal calls + * @since 1.0.0 + */ + public int $disposals = 0; + + /** + * ### Releases the service + * @since 1.0.0 + * + * @return void + */ + public function dispose ():void { + + $this->disposals++; + + } + +} \ No newline at end of file diff --git a/tests/Stubs/FailingDisposableTestService.php b/tests/Stubs/FailingDisposableTestService.php new file mode 100644 index 0000000..b536028 --- /dev/null +++ b/tests/Stubs/FailingDisposableTestService.php @@ -0,0 +1,47 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Core\Boundary\Lifecycle\Disposable; +use RuntimeException; + +/** + * ### Failing disposable test service + * + * Throws an exception during disposal. + * @since 1.0.0 + */ +final class FailingDisposableTestService implements Disposable { + + /** + * ### Number of disposal calls + * @since 1.0.0 + */ + public int $disposals = 0; + + /** + * ### Releases the service + * @since 1.0.0 + * + * @return void + */ + public function dispose ():void { + + $this->disposals++; + + throw new RuntimeException('Disposal failed'); + + } + +} \ No newline at end of file diff --git a/tests/Stubs/FirstOrderedDisposableService.php b/tests/Stubs/FirstOrderedDisposableService.php new file mode 100644 index 0000000..cd07385 --- /dev/null +++ b/tests/Stubs/FirstOrderedDisposableService.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### First ordered disposable service + * @since 1.0.0 + */ +final class FirstOrderedDisposableService extends OrderedDisposableTestService {} \ No newline at end of file diff --git a/tests/Stubs/OrderedDisposableTestService.php b/tests/Stubs/OrderedDisposableTestService.php new file mode 100644 index 0000000..1a3bd99 --- /dev/null +++ b/tests/Stubs/OrderedDisposableTestService.php @@ -0,0 +1,50 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Core\Boundary\Lifecycle\Disposable; + +/** + * ### Ordered disposable test service + * + * Records disposal order using a shared event collection. + * @since 1.0.0 + */ +class OrderedDisposableTestService implements Disposable { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param string $name Service name. + * @param array $events Shared disposal events. + */ + public function __construct ( + public string $name, + private array &$events + ) {} + + /** + * ### Records disposal + * @since 1.0.0 + * + * @return void + */ + public function dispose ():void { + + $this->events[] = $this->name; + + } + +} \ No newline at end of file diff --git a/tests/Stubs/ResolvingDisposableTestService.php b/tests/Stubs/ResolvingDisposableTestService.php new file mode 100644 index 0000000..3925e63 --- /dev/null +++ b/tests/Stubs/ResolvingDisposableTestService.php @@ -0,0 +1,49 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Core\Boundary\Lifecycle\Disposable; +use FireHub\Container\Container; + +/** + * ### Disposable service that attempts resolution during disposal + * @since 1.0.0 + */ +final class ResolvingDisposableTestService implements Disposable { + + /** + * ### Constructor + * @param Container $container Parent container. + * @since 1.0.0 + * + */ + public function __construct( + private Container $container + ) {} + + /** + * ### Attempts resolution during disposal + * @return void + * @throws ContainerClosedException If the container is closed. + * + * @since 1.0.0 + * + */ + public function dispose(): void { + + $this->container->get(DisposableTestService::class); + + } + +} \ No newline at end of file diff --git a/tests/Stubs/SecondOrderedDisposableService.php b/tests/Stubs/SecondOrderedDisposableService.php new file mode 100644 index 0000000..e08363a --- /dev/null +++ b/tests/Stubs/SecondOrderedDisposableService.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Second ordered disposable service + * @since 1.0.0 + */ +final class SecondOrderedDisposableService extends OrderedDisposableTestService {} \ No newline at end of file diff --git a/tests/Unit/ContainerLifecycleTest.php b/tests/Unit/ContainerLifecycleTest.php new file mode 100644 index 0000000..7ce9572 --- /dev/null +++ b/tests/Unit/ContainerLifecycleTest.php @@ -0,0 +1,505 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Exception\ { + ContainerActiveScopesException, ContainerClosedException, ContainerFrozenException, ScopeActiveException, + ScopeClosedException +}; +use FireHub\Tests\Container\Stubs\Logger; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests dependency injection container lifecycle + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[Group('lifecycle')] +#[CoversClass(Container::class)] +final class ContainerLifecycleTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### New containers are open and mutable + * @since 1.0.0 + * + * @return void + */ + public function testNewContainerIsOpenAndMutable ():void { + + $container = new Container; + + self::assertFalse($container->isFrozen()); + self::assertFalse($container->isClosed()); + + $container->singleton(Logger::class); + + self::assertTrue($container->has(Logger::class)); + + } + + /** + * ### Freezing prevents further registrations + * @since 1.0.0 + * + * @return void + */ + public function testFreezePreventsRegistrations ():void { + + $container = new Container; + + $container->freeze(); + + self::assertTrue($container->isFrozen()); + self::assertFalse($container->isClosed()); + + $this->assertThrows( + ContainerFrozenException::class, + function () use ($container):void { + + $container->singleton(Logger::class); + + } + ); + + } + + /** + * ### Frozen containers continue resolving services + * @since 1.0.0 + * + * @return void + */ + public function testFrozenContainerAllowsResolution ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $container->freeze(); + + $first = $container->get(Logger::class); + $second = $container->get(Logger::class); + + self::assertInstanceOf(Logger::class, $first); + self::assertSame($first, $second); + + } + + /** + * ### Freezing is idempotent + * @since 1.0.0 + * + * @return void + */ + public function testFreezeIsIdempotent ():void { + + $container = new Container; + + $container->freeze(); + $container->freeze(); + + self::assertTrue($container->isFrozen()); + + } + + /** + * ### Closing an open container changes its lifecycle state + * @since 1.0.0 + * + * @return void + */ + public function testCloseChangesContainerState ():void { + + $container = new Container; + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closing is idempotent + * @since 1.0.0 + * + * @return void + */ + public function testCloseIsIdempotent ():void { + + $container = new Container; + + $container->close(); + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closed containers reject resolution + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsResolution ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $container->get(Logger::class); + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->get(Logger::class); + + } + ); + + } + + /** + * ### Closed containers reject registrations + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsRegistrations ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->singleton(Logger::class); + + } + ); + + } + + /** + * ### Closed containers reject freezing + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsFreeze ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->freeze(); + + } + ); + + } + + /** + * ### Closed containers reject scope creation + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsScopeCreation ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->createScope(); + + } + ); + + } + + /** + * ### Closed containers reject service queries + * @since 1.0.0 + * + * @return void + */ + public function testClosedContainerRejectsServiceQueries ():void { + + $container = new Container; + + $container->close(); + + $this->assertThrows( + ContainerClosedException::class, + function () use ($container):void { + + $container->has(Logger::class); + + } + ); + + } + + /** + * ### Frozen containers can be closed + * @since 1.0.0 + * + * @return void + */ + public function testFrozenContainerCanBeClosed ():void { + + $container = new Container; + + $container->freeze(); + $container->close(); + + self::assertTrue($container->isFrozen()); + self::assertTrue($container->isClosed()); + + } + + /** + * ### Active scopes prevent container closure + * @since 1.0.0 + * + * @return void + */ + public function testActiveScopePreventsContainerClosure ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $this->assertThrows( + ContainerActiveScopesException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertFalse($container->isClosed()); + self::assertFalse($scope->isClosed()); + + $scope->close(); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Every active scope must close before its container + * @since 1.0.0 + * + * @return void + */ + public function testMultipleScopesPreventContainerClosure ():void { + + $container = new Container; + + $first = $container->createScope(); + $second = $container->createScope(); + + $first->close(); + + $this->assertThrows( + ContainerActiveScopesException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + $second->close(); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closing a scope is idempotent + * @since 1.0.0 + * + * @return void + */ + public function testScopeCloseIsIdempotent ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $scope->close(); + $scope->close(); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Closed scopes reject resolution + * @since 1.0.0 + * + * @return void + */ + public function testClosedScopeRejectsResolution ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $scope->close(); + + $this->assertThrows( + ScopeClosedException::class, + function () use ($scope):void { + + $scope->get(Logger::class); + + } + ); + + } + + /** + * ### A scope cannot close while its run callback is active + * @since 1.0.0 + * + * @return void + */ + public function testScopeCannotCloseDuringRun ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $scope->run( + function () use ($scope):void { + + $this->assertThrows( + ScopeActiveException::class, + function () use ($scope):void { + + $scope->close(); + + } + ); + + } + ); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + } + + /** + * ### Scope run automatically closes the scope + * @since 1.0.0 + * + * @return void + */ + public function testScopeRunAutomaticallyClosesScope ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $executed = false; + + $scope->run( + function () use (&$executed):void { + + $executed = true; + + } + ); + + self::assertTrue($executed); + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Scope run closes the scope when its callback fails + * @since 1.0.0 + * + * @return void + */ + public function testScopeRunClosesScopeOnException ():void { + + $container = new Container; + + $scope = $container->createScope(); + + $this->assertThrows( + \RuntimeException::class, + function () use ($scope):void { + + $scope->run( + function ():void { + + throw new \RuntimeException('Execution failed'); + + } + ); + + } + ); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + +} \ No newline at end of file diff --git a/tests/Unit/DisposableServicesTest.php b/tests/Unit/DisposableServicesTest.php new file mode 100644 index 0000000..f0036d7 --- /dev/null +++ b/tests/Unit/DisposableServicesTest.php @@ -0,0 +1,426 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Core\Meta\Enum\Container\Lifetime; +use FireHub\Container\Container; +use FireHub\Container\Exception\ServiceDisposalException; +use FireHub\Tests\Container\Stubs\{ + DisposableTestService, FailingDisposableTestService, FirstOrderedDisposableService, ResolvingDisposableTestService, + SecondOrderedDisposableService +}; +use FireHub\Tests\Container\Support\ExceptionAssertions; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests disposable service lifecycle + * @since 1.0.0 + */ +#[Small] +#[Group('container')] +#[Group('lifecycle')] +#[Group('disposal')] +#[CoversClass(Container::class)] +final class DisposableServicesTest extends FireHubTestCase { + + use ExceptionAssertions; + + /** + * ### Singleton disposable services are released on container closure + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDisposedOnContainerClose ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + + $service = $container->get(DisposableTestService::class); + + self::assertSame(0, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Singleton services are disposed of only once + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDisposedOnlyOnce ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + + $service = $container->get(DisposableTestService::class); + + $container->close(); + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Unresolved singleton services are not disposed + * @since 1.0.0 + * + * @return void + */ + public function testUnresolvedSingletonIsNotInstantiatedDuringClose ():void { + + $container = new Container; + + $created = 0; + + $container->factory( + DisposableTestService::class, + function () use (&$created):DisposableTestService { + + $created++; + + return new DisposableTestService; + + }, + Lifetime::SINGLETON + ); + + $container->close(); + + self::assertSame(0, $created); + + } + + /** + * ### Scoped services are released on scope closure + * @since 1.0.0 + * + * @return void + */ + public function testScopedServiceDisposedOnScopeClose ():void { + + $container = new Container; + + $container->scoped(DisposableTestService::class); + + $scope = $container->createScope(); + + $service = $scope->get(DisposableTestService::class); + + self::assertSame(0, $service->disposals); + + $scope->close(); + + self::assertSame(1, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Independent scopes dispose of their own instances + * @since 1.0.0 + * + * @return void + */ + public function testIndependentScopesDisposeTheirOwnInstances ():void { + + $container = new Container; + + $container->scoped(DisposableTestService::class); + + $firstScope = $container->createScope(); + $secondScope = $container->createScope(); + + $first = $firstScope->get(DisposableTestService::class); + $second = $secondScope->get(DisposableTestService::class); + + self::assertNotSame($first, $second); + + $firstScope->close(); + + self::assertSame(1, $first->disposals); + self::assertSame(0, $second->disposals); + + $secondScope->close(); + + self::assertSame(1, $second->disposals); + + $container->close(); + + } + + /** + * ### Closing a scope does not dispose of singleton services + * @since 1.0.0 + * + * @return void + */ + public function testScopeCloseDoesNotDisposeSingleton ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + + $scope = $container->createScope(); + + $service = $scope->get(DisposableTestService::class); + + $scope->close(); + + self::assertSame(0, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Transient services are not automatically disposed + * @since 1.0.0 + * + * @return void + */ + public function testTransientServicesAreNotAutomaticallyDisposed ():void { + + $container = new Container; + + $container->transient(DisposableTestService::class); + + $first = $container->get(DisposableTestService::class); + $second = $container->get(DisposableTestService::class); + + self::assertNotSame($first, $second); + + $container->close(); + + self::assertSame(0, $first->disposals); + self::assertSame(0, $second->disposals); + + } + + /** + * ### Externally supplied instances remain caller-owned + * @since 1.0.0 + * + * @return void + */ + public function testExternalInstancesAreNotDisposed ():void { + + $container = new Container; + + $service = new DisposableTestService; + + $container->instance( + DisposableTestService::class, + $service + ); + + self::assertSame( + $service, + $container->get(DisposableTestService::class) + ); + + $container->close(); + + self::assertSame(0, $service->disposals); + + } + + /** + * ### Disposable singleton resources are released in reverse creation order + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDisposalUsesReverseCreationOrder ():void { + + $container = new Container; + + $events = []; + + $container->factory( + FirstOrderedDisposableService::class, + function () use (&$events):FirstOrderedDisposableService { + + return new FirstOrderedDisposableService('first', $events); + + }, + Lifetime::SINGLETON + ); + + $container->factory( + SecondOrderedDisposableService::class, + function () use (&$events):SecondOrderedDisposableService { + + return new SecondOrderedDisposableService('second', $events); + + }, + Lifetime::SINGLETON + ); + + $container->get(FirstOrderedDisposableService::class); + $container->get(SecondOrderedDisposableService::class); + + $container->close(); + + self::assertSame( + ['second', 'first'], + $events + ); + + } + + /** + * ### Disposal failures do not prevent container closure + * @since 1.0.0 + * + * @return void + */ + public function testDisposalFailureDoesNotPreventContainerClosure ():void { + + $container = new Container; + + $container->singleton(FailingDisposableTestService::class); + + $service = $container->get(FailingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertTrue($container->isClosed()); + self::assertSame(1, $service->disposals); + + $container->close(); + + self::assertSame(1, $service->disposals); + + } + + /** + * ### Disposal failures do not prevent remaining resources from being released + * @since 1.0.0 + * + * @return void + */ + public function testDisposalContinuesAfterFailure ():void { + + $container = new Container; + + $container->singleton(DisposableTestService::class); + $container->singleton(FailingDisposableTestService::class); + + $successful = $container->get(DisposableTestService::class); + $failing = $container->get(FailingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertSame(1, $successful->disposals); + self::assertSame(1, $failing->disposals); + + } + + /** + * ### Scoped disposal failures do not leave active scope registrations + * @since 1.0.0 + * + * @return void + */ + public function testScopeDisposalFailureUnregistersScope ():void { + + $container = new Container; + + $container->scoped(FailingDisposableTestService::class); + + $scope = $container->createScope(); + + $scope->get(FailingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($scope):void { + + $scope->close(); + + } + ); + + self::assertTrue($scope->isClosed()); + + $container->close(); + + self::assertTrue($container->isClosed()); + + } + + /** + * ### Resolution is prohibited during container disposal + * @since 1.0.0 + * + * @return void + */ + public function testResolutionDuringDisposalIsRejected ():void { + + $container = new Container; + + $service = new ResolvingDisposableTestService($container); + + $container->factory( + ResolvingDisposableTestService::class, + fn ():ResolvingDisposableTestService => $service, + Lifetime::SINGLETON + ); + + $container->get(ResolvingDisposableTestService::class); + + $this->assertThrows( + ServiceDisposalException::class, + function () use ($container):void { + + $container->close(); + + } + ); + + self::assertTrue($container->isClosed()); + + } + +} \ No newline at end of file From 0939208689827ce201481a8eb8162235a6c2d7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 13:09:35 +0200 Subject: [PATCH 10/11] Update CHANGELOG and README for version 0.3.0 release --- CHANGELOG.md | 22 ++++++++++++++++++++++ README.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b0c6c..ba319e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[v0.3.0](https://github.com/The-FireHub-Project/Container/compare/v0.2.0...v0.3.0)] – 2026-10-22 + +### Added +* Disposable service lifecycle management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Automatic disposal of singleton and scoped services by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Container and scope shutdown support by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Disposable resource tracking with identity deduplication and reverse disposal order by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Active scope tracking to prevent premature container shutdown by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 +* Shared container lifecycle state management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3 + +### Changed +* Centralized container freezing and closure state management in `ContainerState`. +* Enforced container lifecycle restrictions across dependency resolution operations. +* Improved scope cleanup and resource release during shutdown. + +### Fixed +* Prevented dependency resolution after container closure, including cached singleton instances. +* Ensured container shutdown remains idempotent. +* Ensured disposable resources are released even when individual disposal operations fail. +* Prevented container shutdown while active scopes remain registered. + + ## [[v0.2.0](https://github.com/The-FireHub-Project/Container/compare/v0.1.0...v0.2.0)] – 2026-10-21 ### Added diff --git a/README.md b/README.md index cdd9560..0ebc44e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /> version From 607190f02975e513a3a5d35b389a98994fbf66db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= <56673130+dieselxxx@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:10:38 +0200 Subject: [PATCH 11/11] Update README.md --- README.md | 86 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 0ebc44e..2e44657 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,6 @@ /> -[![PHPStan](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml) -[![PHPUnit](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml) -[![Codecov](https://codecov.io/gh/The-FireHub-Project/Container/branch/develop/graph/badge.svg?token=XW2YEONF51)](https://app.codecov.io/gh/The-FireHub-Project/Container/tree/develop) -

FireHub Icon Development Branch +## FireHub Icon Repository Purpose + +FireHub Container is the dependency injection and service resolution component of the FireHub ecosystem. + +Built on the contracts provided by FireHub Core, it supplies the mechanisms required to register, construct, resolve, and manage application services. + +The purpose of this repository is to centralize dependency management while preserving separation of concerns between service definitions, runtime implementation, and application execution. -⚠️ **This is the `develop` branch** +The Container architecture is designed to support: -- Unstable -- APIs may change without notice -- Not intended for production use +- Service registration and dependency resolution +- Interface-to-implementation bindings +- Instance, factory, and class-based service definitions +- Singleton and transient service lifetimes +- Dependency graph resolution and circular dependency detection +- Immutable service registrations after container construction +- Future support for autowiring and execution-scoped services -## FireHub Icon Branch Purpose +FireHub Container follows the architectural principles of The FireHub Project by separating responsibilities between contracts, dependency management, and application lifecycle orchestration. -The `develop` branch is the **primary integration branch** for all ongoing development. +The Container does not control application execution or kernel lifecycle transitions. Instead, it provides dependency management services that can be integrated into applications through the appropriate bootstrap and composition mechanisms. -It serves as the staging area where: -- Feature branches are merged -- Bug fixes are integrated -- Experimental work is stabilized -- Code is prepared for upcoming releases +## FireHub Icon Security -All **release branches** are created **from `develop`**. +For security reporting and guidelines, please refer to our [SECURITY.md](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file. -## Stability Guarantee +## FireHub Icon Community -❌ No backward compatibility guarantee -❌ APIs may change without notice -❌ Behavior may be incomplete or inconsistent -❌ Breaking changes are expected +![GitHub watchers](https://img.shields.io/github/watchers/The-FireHub-Project/Container?style=social) +![GitHub Repo stars](https://img.shields.io/github/stars/The-FireHub-Project/Container?style=social) +![GitHub forks](https://img.shields.io/github/forks/The-FireHub-Project/Container?style=social) -This branch is intended **only for contributors and advanced testers**. +- Be sure to review the [Code of Conduct](https://github.com/The-FireHub-Project/.github/blob/master/.github/CODE_OF_CONDUCT.md). +- Read a [Contributing](https://github.com/The-FireHub-Project/.github/blob/master/.github/CONTRIBUTING.md) file to see how you can contribute to this project. +- Read our [Security policy](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file to see how you can report a vulnerability. +- Review the project [Governance model](https://github.com/The-FireHub-Project/.github/blob/master/.github/GOVERNANCE.md), including roles, decision-making, RFC process, and releases. -## FireHub Icon Composer Usage (Not Recommended) +## FireHub Icon Support + +

+ + GitHub in-progress issues + + GitHub total issues + + GitHub opened pull requests +

-For internal testing only: +For help with FireHub products, please refer to the following resources: -```json -{ - "require": { - "the-firehub-project/container": "dev-develop" - } -} -``` -⚠️ Never use dev-develop in production. +- **General questions & discussions**: [GitHub Discussions](https://github.com/orgs/The-FireHub-Project/discussions) +- **Bug reports & issues**: [Support](https://github.com/The-FireHub-Project/Container/issues) +- **Documentation & guides**: [FireHub Docs](https://the-firehub-project.github.io) +- **Commercial support** (Pro/Enterprise): contact your account manager ## FireHub Icon Authors and Contributors @@ -134,4 +152,4 @@ Architecture guidelines, design principles, and ecosystem documentation are avai This software is licensed under the Apache-2.0 License. -For more details, read the full license [here](./LICENSE). \ No newline at end of file +For more details, read the full license [here](./LICENSE).