<?php
declare(strict_types=1);
namespace DI\Compiler;
use function chmod;
use DI\Definition\ArrayDefinition;
use DI\Definition\DecoratorDefinition;
use DI\Definition\Definition;
use DI\Definition\EnvironmentVariableDefinition;
use DI\Definition\Exception\InvalidDefinition;
use DI\Definition\FactoryDefinition;
use DI\Definition\ObjectDefinition;
use DI\Definition\Reference;
use DI\Definition\Source\DefinitionSource;
use DI\Definition\StringDefinition;
use DI\Definition\ValueDefinition;
use DI\DependencyException;
use DI\Proxy\ProxyFactory;
use function dirname;
use function file_put_contents;
use InvalidArgumentException;
use Laravel\SerializableClosure\Support\ReflectionClosure;
use function rename;
use function sprintf;
use function tempnam;
use function unlink;
/**
* Compiles the container into PHP code much more optimized for performances.
*
* @author Matthieu Napoli <[email protected]>
*/
class Compiler
{
private string $containerClass;
private string $containerParentClass;
/**
* Definitions indexed by the entry name. The value can be null if the definition needs to be fetched.
*
* Keys are strings, values are `Definition` objects or null.
*/
private \ArrayIterator $entriesToCompile;
/**
* Progressive counter for definitions.
*
* Each key in $entriesToCompile is defined as 'SubEntry' + counter
* and each definition has always the same key in the CompiledContainer
* if PHP-DI configuration does not change.
*/
private int $subEntryCounter = 0;
/**
* Progressive counter for CompiledContainer get methods.
*
* Each CompiledContainer method name is defined as 'get' + counter
* and remains the same after each recompilation
* if PHP-DI configuration does not change.
*/
private int $methodMappingCounter = 0;
/**
* Map of entry names to method names.
*
* @var string[]
*/
private array $entryToMethodMapping = [];
/**
* @var string[]
*/
private array $methods = [];
private bool $autowiringEnabled;
public function __construct(
private ProxyFactory $proxyFactory,
) {
}
public function getProxyFactory() : ProxyFactory
{
return $this->proxyFactory;
}
/**
* Compile the container.
*
* @return string The compiled container file name.
*/
public function compile(
DefinitionSource $definitionSource,
string $directory,
string $className,
string $parentClassName,
bool $autowiringEnabled
) : string {
$fileName = rtrim($directory, '/') . '/' . $className . '.php';
if (file_exists($fileName)) {
// The container is already compiled
return $fileName;
}
$this->autowiringEnabled = $autowiringEnabled;
// Validate that a valid class name was provided
$validClassName = preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $className);
if (!$validClassName) {
throw new InvalidArgumentException("The container cannot be compiled: `$className` is not a valid PHP class name");
}
$this->entriesToCompile = new \ArrayIterator($definitionSource->getDefinitions());
// We use an ArrayIterator so that we can keep adding new items to the list while we compile entries
foreach ($this->entriesToCompile as $entryName => $definition) {
$silenceErrors = false;
// This is an entry found by reference during autowiring
if (!$definition) {
$definition = $definitionSource->getDefinition($entryName);
// We silence errors for those entries because type-hints may reference interfaces/abstract classes
// which could later be defined, or even not used (we don't want to block the compilation for those)
$silenceErrors = true;
}
if (!$definition) {
// We do not throw a `NotFound` exception here because the dependency
// could be defined at runtime
continue;
}
// Check that the definition can be compiled
$errorMessage = $this->isCompilable($definition);
if ($errorMessage !== true) {
continue;
}
try {
$this->compileDefinition($entryName, $definition);
} catch (InvalidDefinition $e) {
if ($silenceErrors) {
// forget the entry
unset($this->entryToMethodMapping[$entryName]);
} else {
throw $e;
}
}
}
$this->containerClass = $className;
$this->containerParentClass = $parentClassName;
ob_start();
require __DIR__ . '/Template.php';
$fileContent = ob_get_clean();
$fileContent = "<?php\n" . $fileContent;
$this->createCompilationDirectory(dirname($fileName));
$this->writeFileAtomic($fileName, $fileContent);
return $fileName;
}
private function writeFileAtomic(string $fileName, string $content) : void
{
$tmpFile = @tempnam(dirname($fileName), 'swap-compile');
if ($tmpFile === false) {
throw new InvalidArgumentException(
sprintf('Error while creating temporary file in %s', dirname($fileName))
);
}
@chmod($tmpFile, 0666);
$written = file_put_contents($tmpFile, $content);
if ($written === false) {
@unlink($tmpFile);
throw new InvalidArgumentException(sprintf('Error while writing to %s', $tmpFile));
}
@chmod($tmpFile, 0666);
$renamed = @rename($tmpFile, $fileName);
if (!$renamed) {
@unlink($tmpFile);
throw new InvalidArgumentException(sprintf('Error while renaming %s to %s', $tmpFile, $fileName));
}
}
/**
* @return string The method name
* @throws DependencyException
* @throws InvalidDefinition
*/
private function compileDefinition(string $entryName, Definition $definition) : string
{
// Generate a unique method name
$methodName = 'get' . (++$this->methodMappingCounter);
$this->entryToMethodMapping[$entryName] = $methodName;
switch (true) {
case $definition instanceof ValueDefinition:
$value = $definition->getValue();
$code = 'return ' . $this->compileValue($value) . ';';
break;
case $definition instanceof Reference:
$targetEntryName = $definition->getTargetEntryName();
$code = 'return $this->delegateContainer->get(' . $this->compileValue($targetEntryName) . ');';
// If this method is not yet compiled we store it for compilation
if (!isset($this->entriesToCompile[$targetEntryName])) {
$this->entriesToCompile[$targetEntryName] = null;
}
break;
case $definition instanceof StringDefinition:
$entryName = $this->compileValue($definition->getName());
$expression = $this->compileValue($definition->getExpression());
$code = 'return \DI\Definition\StringDefinition::resolveExpression(' . $entryName . ', ' . $expression . ', $this->delegateContainer);';
break;
case $definition instanceof EnvironmentVariableDefinition:
$variableName = $this->compileValue($definition->getVariableName());
$isOptional = $this->compileValue($definition->isOptional());
$defaultValue = $this->compileValue($definition->getDefaultValue());
$code = <<<PHP
\$value = \$_ENV[$variableName] ?? \$_SERVER[$variableName] ?? getenv($variableName);
if (false !== \$value) return \$value;
if (!$isOptional) {
throw new \DI\Definition\Exception\InvalidDefinition("The environment variable '{$definition->getVariableName()}' has not been defined");
}
return $defaultValue;
PHP;
break;
case $definition instanceof ArrayDefinition:
try {
$code = 'return ' . $this->compileValue($definition->getValues()) . ';';
} catch (\Exception $e) {
throw new DependencyException(sprintf(
'Error while compiling %s. %s',
$definition->getName(),
$e->getMessage()
), 0, $e);
}
break;
case $definition instanceof ObjectDefinition:
$compiler = new ObjectCreationCompiler($this);
$code = $compiler->compile($definition);
$code .= "\n return \$object;";
break;
case $definition instanceof DecoratorDefinition:
$decoratedDefinition = $definition->getDecoratedDefinition();
if (! $decoratedDefinition instanceof Definition) {
if (! $definition->getName()) {
throw new InvalidDefinition('Decorators cannot be nested in another definition');
}
throw new InvalidDefinition(sprintf(
'Entry "%s" decorates nothing: no previous definition with the same name was found',
$definition->getName()
));
}
$code = sprintf(
'return call_user_func(%s, %s, $this->delegateContainer);',
$this->compileValue($definition->getCallable()),
$this->compileValue($decoratedDefinition)
);
break;
case $definition instanceof FactoryDefinition:
$value = $definition->getCallable();
// Custom error message to help debugging
$isInvokableClass = is_string($value) && class_exists($value) && method_exists($value, '__invoke');
if ($isInvokableClass && !$this->autowiringEnabled) {
throw new InvalidDefinition(sprintf(
'Entry "%s" cannot be compiled. Invokable classes cannot be automatically resolved if autowiring is disabled on the container, you need to enable autowiring or define the entry manually.',
$entryName
));
}
$definitionParameters = '';
if (!empty($definition->getParameters())) {
$definitionParameters = ', ' . $this->compileValue($definition->getParameters());
}
$code = sprintf(
'return $this->resolveFactory(%s, %s%s);',
$this->compileValue($value),
var_export($entryName, true),
$definitionParameters
);
break;
default:
// This case should not happen (so it cannot be tested)
throw new \Exception('Cannot compile definition of type ' . get_class($definition));
}
$this->methods[$methodName] = $code;
return $methodName;
}
public function compileValue(mixed $value) : string
{
// Check that the value can be compiled
$errorMessage = $this->isCompilable($value);
if ($errorMessage !== true) {
throw new InvalidDefinition($errorMessage);
}
if ($value instanceof Definition) {
// Give it an arbitrary unique name
$subEntryName = 'subEntry' . (++$this->subEntryCounter);
// Compile the sub-definition in another method
$methodName = $this->compileDefinition($subEntryName, $value);
// The value is now a method call to that method (which returns the value)
return "\$this->$methodName()";
}
if (is_array($value)) {
$value = array_map(function ($value, $key) {
$compiledValue = $this->compileValue($value);
$key = var_export($key, true);
return " $key => $compiledValue,\n";
}, $value, array_keys($value));
$value = implode('', $value);
return "[\n$value ]";
}
if ($value instanceof \Closure) {
return $this->compileClosure($value);
}
return var_export($value, true);
}
private function createCompilationDirectory(string $directory) : void
{
if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new InvalidArgumentException(sprintf('Compilation directory does not exist and cannot be created: %s.', $directory));
}
if (!is_writable($directory)) {
throw new InvalidArgumentException(sprintf('Compilation directory is not writable: %s.', $directory));
}
}
/**
* @return string|true If true is returned that means that the value is compilable.
*/
private function isCompilable($value) : string|bool
{
if ($value instanceof ValueDefinition) {
return $this->isCompilable($value->getValue());
}
if (($value instanceof DecoratorDefinition) && empty($value->getName())) {
return 'Decorators cannot be nested in another definition';
}
// All other definitions are compilable
if ($value instanceof Definition) {
return true;
}
if ($value instanceof \Closure) {
return true;
}
if (is_object($value)) {
return 'An object was found but objects cannot be compiled';
}
if (is_resource($value)) {
return 'A resource was found but resources cannot be compiled';
}
return true;
}
/**
* @throws InvalidDefinition
*/
private function compileClosure(\Closure $closure) : string
{
$reflector = new ReflectionClosure($closure);
if ($reflector->getUseVariables()) {
throw new InvalidDefinition('Cannot compile closures which import variables using the `use` keyword');
}
if ($reflector->isBindingRequired() || $reflector->isScopeRequired()) {
throw new InvalidDefinition('Cannot compile closures which use $this or self/static/parent references');
}
// Force all closures to be static (add the `static` keyword), i.e. they can't use
// $this, which makes sense since their code is copied into another class.
$code = ($reflector->isStatic() ? '' : 'static ') . $reflector->getCode();
return trim($code, "\t\n\r;");
}
}