Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/CircularDependencyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/**
* Copyright 2026-2026 Horde LLC (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (BSD). If you
* did not receive this file, see http://www.horde.org/licenses/bsd.
*
* @category Horde
* @copyright 2026-2026 Horde LLC
* @license http://www.horde.org/licenses/bsd BSD
* @package Injector
*/

namespace Horde\Injector;

/**
* Exception thrown when circular dependency is detected.
*
* @author Ralf Lang <lang@b1-systems.de>
* @category Horde
* @copyright 2026-2026 Horde LLC
* @license http://www.horde.org/licenses/bsd BSD
* @package Injector
*/
class CircularDependencyException extends Exception {}
31 changes: 29 additions & 2 deletions src/DependencyFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,26 @@ public function getMethodDependencies(
foreach ($method->getParameters() as $parameter) {
$dependencies[] = $this->getParameterDependency($injector, $parameter);
}
} catch (CircularDependencyException $e) {
// Re-throw circular dependency as-is
throw $e;
} catch (Exception $e) {
throw new Exception("$method has unfulfilled dependencies ($parameter)", 0, $e);
$className = $method->getDeclaringClass()->getName();
$methodName = $method->getName();
$paramName = $parameter->getName();
$paramType = $parameter->getType() ? (string) $parameter->getType() : 'untyped';

throw new Exception(
sprintf(
'Cannot resolve parameter $%s (%s) for %s::%s()',
$paramName,
$paramType,
$className,
$methodName
),
0,
$e
);
}

return $dependencies;
Expand Down Expand Up @@ -104,6 +122,15 @@ public function getParameterDependency(
return $parameter->getDefaultValue();
}

throw new Exception("Untyped parameter \$" . $parameter->getName() . "can't be fulfilled");
$paramName = $parameter->getName();
$paramType = $parameter->getType() ? (string) $parameter->getType() : 'untyped';

throw new Exception(
sprintf(
'Parameter $%s (%s) cannot be resolved',
$paramName,
$paramType
)
);
}
}
148 changes: 147 additions & 1 deletion src/Injector.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@
*
* @var ReflectionClass[]
*/
private $reflection = [];

Check failure on line 63 in src/Injector.php

View workflow job for this annotation

GitHub Actions / CI

Property Horde\Injector\Injector::$reflection with generic class ReflectionClass does not specify its types: T

/**
* Cache any hits for speeding up has();
*
* @var array
*/
private array $hasCache = [];

Check failure on line 70 in src/Injector.php

View workflow job for this annotation

GitHub Actions / CI

Property Horde\Injector\Injector::$hasCache type has no value type specified in iterable type array.

/**
* Cache misses
Expand All @@ -78,6 +78,13 @@
*/
private array $hasNotCache = [];

/**
* Track classes being resolved to detect circular dependencies.
*
* @var string[]
*/
private array $resolutionStack = [];

/**
* Create a new Injector object.
*
Expand Down Expand Up @@ -328,25 +335,164 @@
*/
public function get(string $id)
{
// Detect circular dependencies
if (in_array($id, $this->resolutionStack, true)) {
$cycle = [...$this->resolutionStack, $id];
throw new CircularDependencyException(
sprintf(
'Circular dependency detected: %s',
implode(' → ', $cycle)
)
);
}

// Track current resolution
$this->resolutionStack[] = $id;

try { // Do we have an instance?
if (!$this->hasInstance($id)) {
// Do we have a binding for this interface? If so then we don't
// ask our parent.
if (!isset($this->bindings[$id])
// Does our parent have an instance?
&& ($instance = $this->parentInjector->get($id))) {
array_pop($this->resolutionStack);
return $instance;
}

// We have to make our own instance
$this->setInstance($id, $this->createInstance($id));
}
} catch (CircularDependencyException $e) {
// Re-throw circular dependency as-is
array_pop($this->resolutionStack);
throw $e;
} catch (NotFoundException $e) {
// If already enhanced (contains "Cannot create"), just re-throw
if (strpos($e->getMessage(), 'Cannot create') === 0) {
array_pop($this->resolutionStack);
throw $e;
}

// Otherwise, enhance with context
array_pop($this->resolutionStack);

$message = "Cannot create $id";

$chain = $this->extractDependencyChain($e);
if (!empty($chain) && count($chain) > 1) {
$message .= "\n Dependency chain: " . implode(' → ', $chain);
}

$rootCause = $this->extractRootCause($e);
$message .= "\n Root cause: $rootCause";

throw new NotFoundException($message, $e->getCode(), $e);
} catch (Exception $e) {
throw new NotFoundException('The requested interface was not found: ' . $id, $e->getCode(), $e);
array_pop($this->resolutionStack);

// Enhance error message with context
$message = "Cannot create $id";

$chain = $this->extractDependencyChain($e);
if (!empty($chain) && count($chain) > 1) {
$message .= "\n Dependency chain: " . implode(' → ', $chain);
}

$rootCause = $this->extractRootCause($e);
$message .= "\n Root cause: $rootCause";

throw new NotFoundException($message, $e->getCode(), $e);
}

array_pop($this->resolutionStack);
return $this->instances[$id];
}

/**
* Extract root cause from exception chain.
*
* @param \Throwable $e The exception to analyze.
*
* @return string Human-readable root cause description.
*/
private function extractRootCause(\Throwable $e): string
{
// Walk to deepest exception
$current = $e;
while ($current->getPrevious()) {
$current = $current->getPrevious();
}

$msg = $current->getMessage();

// Extract clean message from various formats
if (preg_match('/Class "(\w+)" does not exist/', $msg, $matches)) {
return "Class {$matches[1]} does not exist";
}

if (preg_match('/Cannot bind interface or abstract class "(\S+)"/', $msg, $matches)) {
return "Interface {$matches[1]} is not bound to an implementation";
}

if (preg_match('/Parameter \$(\w+) \((\w+)\) cannot be resolved/', $msg, $matches)) {
return "Parameter \${$matches[1]} ({$matches[2]}) cannot be resolved";
}

if (preg_match('/Untyped parameter \$(\w+)/', $msg, $matches)) {
return "Parameter \${$matches[1]} has no type hint";
}

// Fallback to exception message (first line only for brevity)
$lines = explode("\n", $msg);
return $lines[0];
}

/**
* Extract dependency chain from exception chain.
*
* @param \Throwable $e The exception to analyze.
*
* @return string[] Array of class names in dependency chain.
*/
private function extractDependencyChain(\Throwable $e): array
{
$chain = [];
$current = $e;

while ($current) {
$msg = $current->getMessage();

// Extract from "Cannot resolve parameter $x (ClassName) for ..."
if (preg_match('/Cannot resolve parameter \$\w+ \(([^)]+)\) for (\S+)::/', $msg, $matches)) {
$paramType = $matches[1];
$ownerClass = $matches[2];

// Add owner class if not already in chain
if (empty($chain) || end($chain) !== $ownerClass) {
$chain[] = $ownerClass;
}

// Add parameter type if not already in chain
if (end($chain) !== $paramType) {
$chain[] = $paramType;
}
}

// Also extract from old-style "The requested interface was not found: X"
if (preg_match('/The requested interface was not found: (\S+)/', $msg, $matches)) {
$className = $matches[1];
if (empty($chain) || end($chain) !== $className) {
$chain[] = $className;
}
}

$current = $current->getPrevious();
}

return $chain;
}

/**
* Retrieve an instance of the specified object/interface.
*
Expand Down
Loading
Loading