divineniiquaye/rade-di

View on GitHub
src/DefinitionBuilder.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

declare(strict_types=1);

/*
 * This file is part of DivineNii opensource projects.
 *
 * PHP version 7.4 and above required
 *
 * @author    Divine Niiquaye Ibok <divineibok@gmail.com>
 * @copyright 2021 DivineNii (https://divinenii.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Rade\DI;

use Nette\Utils\{FileSystem, Validators};
use Symfony\Component\Config\Resource\{FileExistenceResource, FileResource, ResourceInterface};
use Symfony\Contracts\Service\ResetInterface;

/**
 * A builder specialized in creating homogeneous service definitions.
 *
 * This class has some performance impact and recommended to be used with ContainerBuilder class.
 *
 * @experimental in 1.0
 *
 * @method self|Definition autowire(string $id, Definitions\TypedDefinition|object|null $definition = null)
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
class DefinitionBuilder implements ResetInterface
{
    private ?string $definition = null, $directory = null;
    private bool $trackDefaults = false, $condition = true;

    /** @var array<string,array<int,mixed>> */
    private array $classes = [];

    /** @var array<string,array<int,array<int,mixed>>> */
    private array $defaults = [];

    public function __construct(private Container $container)
    {
    }

    public function __destruct()
    {
        if (!empty($this->classes)) {
            foreach ($this->classes as [$definition, $classes]) {
                // prepare for deep cloning
                $serializedDef = \serialize($definition);

                foreach ($classes as $resource) {
                    $this->container->set($resource, \unserialize($serializedDef)->replace($resource));
                }
            }

            $this->classes = [];
        }
    }

    /**
     * Where all the magic happens.
     *
     * @param array<int,mixed> $arguments
     *
     * @throws \Throwable
     *
     * @return $this
     */
    public function __call(string $name, array $arguments)
    {
        if (!$this->condition) {
            return $this;
        }

        if (!$id = $this->definition) {
            throw $this->createInitializingError($name);
        }

        if ($this->trackDefaults) {
            $this->defaults[$id][] = [$name, $arguments];
        } else {
            try {
                $definition = (!isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0]);
                \call_user_func_array([$definition, $name], $arguments);
            } catch (\Throwable $e) {
                throw $this->createErrorException($name, $e);
            }
        }

        return $this;
    }

    /**
     * This method calls the __destruct() method as a way of
     * loading namespaced service definitions into container if exist.
     *
     * @return $this
     */
    final public function load(): self
    {
        $this->__destruct();

        return $this;
    }

    /**
     * Resets the builder to initial state.
     *
     * @return $this
     */
    public function reset()
    {
        $this->definition = $this->directory = null;
        $this->classes = $this->defaults = [];
        $this->trackDefaults = false;

        return $this;
    }

    /**
     * Set a config into container's parameter.
     *
     * @param mixed $value
     *
     * @return $this
     */
    public function parameter(string $name, $value)
    {
        if ($this->condition) {
            $this->container->parameters[$name] = $value;
        }

        return $this;
    }

    /**
     * Marks an alias id to service id.
     *
     * @return $this
     */
    public function alias(string $id, string $serviceId = null)
    {
        if ($this->condition) {
            $this->container->alias($id, $serviceId ?? $this->definition);
        }

        return $this;
    }

    /**
     * A condition to be evaluated before service is created.
     *
     * @param callable $condition a callable that returns a boolean value
     *
     * @return $this
     */
    public function if(callable $condition)
    {
        $this->condition = $condition($this->container);

        return $this;
    }

    /**
     * Reverts the condition to true if it was false.
     *
     * Note: This method is required to be called after if() method.
     *
     * @return $this
     */
    public function endIf()
    {
        $this->condition = true;

        return $this;
    }

    /**
     * Enables autowiring.
     *
     * @param array<int,string>      $types
     * @param Definition|object|null $definition
     *
     * @return Definition|$this
     */
    public function autowire(string $id, object $definition = null)
    {
        if ($this->condition) {
            $this->doCreate($this->container->autowire($this->definition = $id, $definition));
        }

        return $this;
    }

    /**
     * Set a service definition.
     *
     * @param Definition|object|null $definition
     *
     * @return Definition|$this
     */
    public function set(string $id, object $definition = null)
    {
        if ($this->condition) {
            $this->doCreate($this->container->set($this->definition = $id, $definition));
        }

        return $this;
    }

    /**
     * Extends a service definition.
     *
     * @return Definition|$this
     */
    public function extend(string $id)
    {
        if ($this->condition) {
            $this->doCreate($this->container->definition($this->definition = $id));
        }

        return $this;
    }

    /**
     * Replaces old service with a new one, but keeps a reference of the old one as: service_id.inner.
     *
     * @param Definition|object|null $definition
     *
     * @see Rade\DI\Traits\DefinitionTrait::decorate
     *
     * @return Definition|$this
     */
    public function decorate(string $id, object $definition = null, string $newId = null)
    {
        if ($this->condition) {
            $this->doCreate($this->container->decorate($this->definition = $id, $definition, $newId));
        }

        return $this;
    }

    /**
     * Defines a set of defaults for following service definitions.
     *
     * @param bool $merge If true, new defaults will be merged into existing
     *
     * @return Definition|$this
     */
    public function defaults(bool $merge = true)
    {
        if ($this->condition) {
            $this->trackDefaults = true;
            $this->definition = '#defaults';

            if (!$merge) {
                $this->defaults[$this->definition] = [];
            }
        }

        return $this;
    }

    /**
     * Defines a set of defaults only for services whose class matches a defined one.
     *
     * @return Definition|$this
     */
    public function instanceOf(string $interfaceOrClass)
    {
        if ($this->condition) {
            $this->trackDefaults = true;

            if (!Validators::isType($interfaceOrClass)) {
                throw new \RuntimeException(\sprintf('"%s" is set as an "instanceof" conditional, but it does not exist.', $interfaceOrClass));
            }

            $this->definition = $interfaceOrClass;
        }

        return $this;
    }

    /**
     * Registers a set of classes as services using PSR-4 for discovery.
     *
     * @param string               $namespace The namespace prefix of classes in the scanned directory
     * @param string|null          $resource  The directory to look for classes, glob-patterns allowed
     * @param string|string[]|null $exclude   A globbed path of files to exclude or an array of globbed paths of files to exclude
     *
     * @return Definition|$this
     */
    public function namespaced(string $namespace, string $resource = null, $exclude = null)
    {
        if (!$this->condition) {
            return $this;
        }

        if ('\\' !== @$namespace[-1]) {
            throw new \InvalidArgumentException(\sprintf('Namespace prefix must end with a "\\": "%s".', $namespace));
        }

        if (!\preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++$/', $namespace)) {
            throw new \InvalidArgumentException(\sprintf('Namespace is not a valid PSR-4 prefix: "%s".', $namespace));
        }
        $oldDir = $this->directory;

        if (null !== $resource) {
            if ($oldDir && !\is_dir($resource)) {
                $resource = $oldDir . \ltrim($resource, '/\\');
            }

            if (\is_dir($resource = $this->container->parameter($resource))) {
                $this->directory = $resource;
            }
        }

        $classes = $this->findClasses($namespace, $resource ?? $this->findResourcePath($namespace), (array) $exclude);
        $this->doCreate($definition = new Definition($this->definition = $namespace));

        $this->directory = $oldDir;
        $this->classes[$namespace] = [$definition, $classes];

        return $this;
    }

    /**
     * Set|Replace a directory for finding classes.
     *
     * @return $this
     */
    public function directory(string $path)
    {
        if ($this->condition) {
            $this->directory = \rtrim($path, '\\/') . '/';
        }

        return $this;
    }

    public function getContainer(): Container
    {
        return $this->container;
    }

    private function doCreate(object $definition): void
    {
        $this->trackDefaults = false;

        foreach ($this->defaults as $offset => $defaultMethods) {
            if ('#defaults' !== $offset) {
                $class = $definition instanceof Definition ? $definition->getEntity() : $definition;

                if (!(\is_string($class) || \is_object($class)) || !\is_subclass_of($class, $offset)) {
                    continue;
                }
            }

            foreach ($defaultMethods as [$defaultMethod, $defaultArguments]) {
                if (!\method_exists($definition, $defaultMethod)) {
                    continue;
                }

                try {
                    $definition->{$defaultMethod}(...$defaultArguments);
                } catch (\Throwable $e) {
                    throw $this->createErrorException($defaultMethod, $e);
                }
            }
        }

        $this->__destruct();
    }

    private function findResourcePath(string $namespace): string
    {
        foreach (\spl_autoload_functions() as $classLoader) {
            if (!\is_array($classLoader)) {
                continue;
            }

            if ($classLoader[0] instanceof \Composer\Autoload\ClassLoader) {
                $psr4Prefixes = $classLoader[0]->getPrefixesPsr4();

                foreach ($psr4Prefixes as $prefix => $paths) {
                    if (!\str_starts_with($namespace, $prefix)) {
                        continue;
                    }

                    foreach ($paths as $path) {
                        $namespacePostfix = '/' . \substr($namespace, \strlen($prefix));
                        $path = FileSystem::normalizePath($path . $namespacePostfix);

                        if (\file_exists($path)) {
                            $this->directory = \dirname($path) . '/';

                            return $path;
                        }
                    }
                }

                break;
            }
        }

        // This will probably never be reached ...
        throw new \RuntimeException('PSR-4 autoloader file can not be found!');
    }

    /**
     * @param array<int,string> $excludePatterns
     *
     * @throws \ReflectionException
     *
     * @return array<int,string>
     */
    private function findClasses(string $namespace, string $pattern, array $excludePatterns): array
    {
        $classNames = [];
        $container = $this->container;

        foreach (\glob($pattern, \GLOB_BRACE) as $directory) {
            if (\is_dir($directory)) {
                $directoryIterator = new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS);
                $files = \iterator_to_array(new \RecursiveIteratorIterator($directoryIterator));
                \uksort($files, 'strnatcmp');
            } else {
                $files = [$directory => new \SplFileInfo($directory)];
            }

            /** @var \SplFileInfo $info */
            foreach ($files as $path => $info) {
                $path = \str_replace('\\', '/', $path); // normalize Windows slashes
                $pathLength = 0;

                foreach ($excludePatterns as $excludePattern) {
                    $excludePattern = $container->parameter($this->directory . $excludePattern);

                    foreach (\glob($excludePattern, \GLOB_BRACE) ?: [$excludePattern] as $excludedPath) {
                        if (\str_starts_with($path, \str_replace('\\', '/', $excludedPath))) {
                            continue 3;
                        }
                    }
                }

                if (!\preg_match('/\\.php$/', $path, $m) || !$info->isReadable()) {
                    continue;
                }

                foreach (\explode('\\', $namespace, -1) as $namespaced) {
                    if ($pos = \strpos($path, $namespaced . '/')) {
                        $pathLength = +$pos + \strlen($namespaced . '/');
                    }
                }

                if (0 === $pathLength) {
                    $pathLength = \preg_match('/\w+\.php$/', $path, $l) ? \strpos($path, $l[0]) : 0;
                }
                $class = \str_replace('/', '\\', \substr($path, $pathLength, -\strlen($m[0])));

                if (null === $class = $this->findClass($container, $namespace . $class, $path, $pattern)) {
                    continue;
                }
                $classNames[] = $class;

                // track only for new & removed files
                if ($container instanceof ContainerBuilder && \interface_exists(ResourceInterface::class)) {
                    $container->addResource(new FileExistenceResource($path));
                }
            }
        }

        return $classNames;
    }

    private function findClass(Container $container, string $class, string $path, string $pattern): ?string
    {
        if (!\preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $class)) {
            return null;
        }

        try {
            $r = new \ReflectionClass($class);
        } catch (\Error | \ReflectionException $e) {
            if (\preg_match('/^Class .* not found$/', $e->getMessage())) {
                return null;
            }

            if ($e instanceof \ReflectionException) {
                $e = new \InvalidArgumentException(\sprintf('Expected to find class "%s" in file "%s" while importing services from resource "%s", but it was not found! Check the namespace prefix used with the resource.', $class, $path, $pattern), 0, $e);
            }

            throw $e;
        }

        if ($container instanceof ContainerBuilder && \interface_exists(ResourceInterface::class)) {
            $container->addResource(new FileExistenceResource($rPath = $r->getFileName()));
            $container->addResource(new FileResource($rPath));
        }

        if ($r->isInstantiable()) {
            return $class;
        }

        return null;
    }

    private function createErrorException(string $name, \Throwable $e): \Throwable
    {
        if (
            \str_starts_with($e->getMessage(), 'call_user_func_array(): Argument #1') ||
            \str_starts_with($e->getMessage(), 'call_user_func_array() expects parameter 1')
        ) {
            $e = new \BadMethodCallException(\sprintf(
                'Call to undefined method %s() method must either belong to an instance of %s or the %s class',
                $name,
                Definition::class,
                __CLASS__,
            ), 0, $e);
        }

        return $e;
    }

    private function createInitializingError(string $name): \LogicException
    {
        return new \LogicException(\sprintf('Did you forget to register a service via "set", "autowire", or "namespaced" methods\' before calling the %s() method.', $name));
    }
}