src/Plugin/Double.php
<?php
namespace Kahlan\Plugin;
use Kahlan\Jit\Parser;
use Kahlan\Jit\Patcher\Pointcut;
use Reflection;
use ReflectionMethod;
use ReflectionClass;
use Kahlan\MissingImplementationException;
use Kahlan\Analysis\Inspector;
class Double
{
/**
* Class dependencies.
*
* @var array
*/
protected static $_classes = [
'parser' => Parser::class,
'pointcut' => Pointcut::class
];
/**
* The pointcut patcher instance.
*
* @var object
*/
protected static $_pointcut = null;
/**
* Registered stubbed instance/class methods.
*
* @var array
*/
protected static $_registered = [];
/**
* Stub index counter.
*
* @var integer
*/
protected static $_index = 0;
/**
* Creates a polyvalent instance.
*
* @param array $options Array of options. Options are:
* - `'class'` string : the fully-namespaced class name.
* - `'extends'` string : the fully-namespaced parent class name.
* - `'args'` array : arguments to pass to the constructor.
* - `'methods'` string[]: override given methods with empty functions.
* - `'stubMethods'` array : override given methods to return given values, e.g. ['foo' => 'bar'].
* - `'fakeMethods'` array : override given methods to run given callback, e.g. ['foo' => function () { return 'bar'; }].
* @return object The created instance.
*/
public static function instance($options = [])
{
$class = static::classname($options);
if (isset($options['args'])) {
$refl = new ReflectionClass($class);
$instance = $refl->newInstanceArgs($options['args']);
} else {
$instance = new $class();
}
if (isset($options['stubMethods']) && is_array($options['stubMethods'])) {
foreach ($options['stubMethods'] as $name => $return) {
allow($instance)->toReceive($name)->andReturn($return);
}
}
if (isset($options['fakeMethods']) && is_array($options['fakeMethods'])) {
foreach ($options['fakeMethods'] as $name => $callback) {
allow($instance)->toReceive($name)->andRun($callback);
}
}
return $instance;
}
/**
* Creates a polyvalent static class.
*
* @param array $options Array of options. Options are:
* - `'class'` : the fully-namespaced class name.
* - `'extends'` : the fully-namespaced parent class name.
* @return string The created fully-namespaced class name.
*/
public static function classname($options = [])
{
$defaults = ['class' => 'Kahlan\Spec\Plugin\Double\Double' . static::$_index++];
$options += $defaults;
if (!static::$_pointcut) {
$pointcut = static::$_classes['pointcut'];
static::$_pointcut = new $pointcut();
}
if (!class_exists($options['class'], false)) {
$parser = static::$_classes['parser'];
$code = static::generate($options);
$nodes = $parser::parse($code);
$code = $parser::unparse(static::$_pointcut->process($nodes));
if (PHP_VERSION_ID >= 80100) {
// @see https://regex101.com/r/q71PDE/2
$pattern = '#(public( static)? function(?! __(?!wakeup|set_state)))#U';
$replacement = '
#[\ReturnTypeWillChange]
${1}';
$code = preg_replace($pattern, $replacement, $code);
}
eval('?>' . $code);
}
return $options['class'];
}
/**
* Creates a class definition.
*
* @param array $options Array of options. Options are:
* - `'class'` _string_ : the fully-namespaced class name.
* - `'extends'` _string_ : the fully-namespaced parent class name.
* - `'implements'` _array_ : the implemented interfaces.
* - `'uses'` _array_ : the used traits.
* - `'methods'` _array_ : the methods to stubs.
* - `'layer'` _boolean_: indicate if public methods should be layered.
* @return string The generated class string content.
*/
public static function generate($options = [])
{
$defaults = [
'class' => 'Kahlan\Spec\Plugin\Double\Double' . static::$_index++,
'extends' => '',
'implements' => [],
'uses' => [],
'methods' => [],
'layer' => null,
'openTag' => true,
'closeTag' => true
];
$options += $defaults;
if ($options['extends'] || $options['implements']) {
$options += ['magicMethods' => false];
} else {
$options += ['magicMethods' => true];
}
$class = $options['class'];
$namespace = '';
if (($pos = strrpos($class, '\\')) !== false) {
$namespace = substr($class, 0, $pos);
$class = substr($class, $pos + 1);
}
if ($namespace) {
$namespace = "namespace {$namespace};\n";
}
$uses = static::_generateUses($options['uses']);
$extends = static::_generateExtends($options['extends']);
$implements = static::_generateImplements($options['implements']);
$methods = static::_generateMethodStubs($options['methods'], $options['magicMethods']);
if ($options['extends']) {
$methods += static::_generateClassMethods($options['extends'], $options['layer']);
}
$methods += static::_generateInterfaceMethods($options['implements']);
$methods = $methods ? ' ' . join("\n ", $methods) : '';
$openTag = $options['openTag'] ? "<?php\n" : '';
$closeTag = $options['closeTag'] ? "?>" : '';
return $openTag . $namespace . <<<EOT
#[\AllowDynamicProperties]
class {$class}{$extends}{$implements} {
{$uses}{$methods}
}
$closeTag
EOT;
}
/**
* Returns Magic Methods definitions.
*
* @return array
*/
public static function _getMagicMethods()
{
return [
'__construct' => "public function __construct() {}",
'__destruct' => "public function __destruct() {}",
'__call' => "public function __call(\$name, \$args) { return new static(); }",
'::__callStatic' => "public static function __callStatic(\$name, \$args) { return get_called_class(); }",
'__get' => "public function __get(\$key){ return new static(); }",
'__set' => "public function __set(\$key, \$value) { \$this->{\$key} = \$value; }",
'__isset' => "public function __isset(\$key) { return isset(\$this->{\$key}); }",
'__unset' => "public function __unset(\$key) { unset(\$this->{\$key}); }",
'__sleep' => "public function __sleep() { return []; }",
'__wakeup' => "public function __wakeup() {}",
'__toString' => "public function __toString() { return get_class(\$this); }",
'__invoke' => "public function __invoke() {}",
'__set_state' => "public static function __set_state(\$properties) {}",
'__clone' => "public function __clone() {}"
];
}
/**
* Creates a `use` definition.
*
* @param array $uses An array of traits.
* @return string The generated `use` definition.
*/
protected static function _generateUses($uses)
{
if (!$uses) {
return '';
}
$traits = [];
foreach ((array) $uses as $use) {
if (!trait_exists($use)) {
throw new MissingImplementationException("Unexisting trait `{$use}`");
}
$traits[] = '\\' . ltrim($use, '\\');
}
return ' use ' . join(', ', $traits) . ';';
}
/**
* Creates an `extends` definition.
*
* @param string $extends The parent class name.
* @return string The generated `extends` definition.
*/
protected static function _generateExtends($extends)
{
if (!$extends) {
return '';
}
return ' extends \\' . ltrim($extends, '\\');
}
/**
* Creates an `implements` definition.
*
* @param array $uses An array of interfaces.
* @return string The generated `implements` definition.
*/
protected static function _generateImplements($implements)
{
if (!$implements) {
return '';
}
$classes = [];
foreach ((array) $implements as $implement) {
$classes[] = '\\' . ltrim($implement, '\\');
}
return ' implements ' . join(', ', $classes);
}
/**
* Creates method stubs.
*
* @param array $methods An array of method definitions.
* @param boolean $defaults If `true`, Magic Methods will be appended.
* @return array The generated method definitions.
*/
protected static function _generateMethodStubs($methods, $defaults = true)
{
$result = [];
$methods = $methods !== null ? (array) $methods : [];
if ($defaults) {
$methods = array_merge($methods, array_keys(static::_getMagicMethods()));
}
$methods = array_unique($methods);
$magicMethods = static::_getMagicMethods();
foreach ($methods as $name) {
if (isset($magicMethods[$name])) {
$result[$name] = $magicMethods[$name];
} else {
$static = $return = '';
if ($name[0] === '&') {
$return = '$r = null; return $r;';
}
if (preg_match('/^&?::.*/', $name)) {
$static = 'static ';
$name = substr($name, 2);
}
$result[$name] = "public {$static}function {$name}() {{$return}}";
}
}
return $result;
}
/**
* Creates method definitions from a class name.
*
* @param string $class A class name.
* @param boolean $layer If `true`, all public methods are "overriden".
* @return array The generated methods.
*/
protected static function _generateClassMethods($class, $layer = null)
{
if (!class_exists($class)) {
throw new MissingImplementationException("Unexisting class `{$class}`");
}
$result = static::_generateAbstractMethods($class);
if ($layer === false) {
return $result;
}
$reflection = Inspector::inspect($class);
if (!$layer && !$reflection->isInternal()) {
return $result;
}
$finals = $reflection->getMethods(ReflectionMethod::IS_FINAL);
$methods = array_diff($reflection->getMethods(ReflectionMethod::IS_PUBLIC), $finals);
foreach ($methods as $method) {
$result[$method->getName()] = static::_generateMethod($method, true);
}
return $result;
}
/**
* Creates method definitions from a class name.
*
* @param string $class A class name.
* @param integer $mask The method mask to filter.
* @return array The generated methods.
*/
protected static function _generateAbstractMethods($class)
{
$result = [];
if (!class_exists($class)) {
throw new MissingImplementationException("Unexisting parent class `{$class}`");
}
$reflection = Inspector::inspect($class);
$methods = $reflection->getMethods(ReflectionMethod::IS_ABSTRACT);
foreach ($methods as $method) {
$result[$method->getName()] = static::_generateMethod($method);
}
return $result;
}
/**
* Creates method definitions from an interface array.
*
* @param array $interfaces A array on interfaces.
* @param integer $mask The method mask to filter.
* @return array The generated methods.
*/
protected static function _generateInterfaceMethods($interfaces, $mask = 255)
{
if (!$interfaces) {
return [];
}
$result = [];
foreach ((array) $interfaces as $interface) {
if (!interface_exists($interface)) {
throw new MissingImplementationException("Unexisting interface `{$interface}`");
}
$reflection = Inspector::inspect($interface);
$methods = $reflection->getMethods($mask);
foreach ($methods as $method) {
$result[$method->getName()] = static::_generateMethod($method);
}
}
return $result;
}
/**
* Creates a method definition from a `ReflectionMethod` instance.
*
* @param object $method A instance of `ReflectionMethod`.
* @return string The generated method.
*/
protected static function _generateMethod($method, $callParent = false)
{
$result = join(' ', Reflection::getModifierNames($method->getModifiers()));
$result = preg_replace('/abstract\s*/', '', $result);
$name = $method->getName();
$parameters = static::_generateSignature($method);
$type = static::_generateReturnType($method);
$typehint = $type ? ": {$type} " : '';
$body = "{$result} function {$name}({$parameters}) {$typehint}{";
if ($callParent) {
$parameters = static::_generateParameters($method);
$return = 'return ';
if ($method->isConstructor() || $method->isDestructor() || $type === 'void') {
$return = '';
}
$body .= "{$return}parent::{$name}({$parameters});";
}
return $body . "}";
}
/**
* Extract the return type of a method.
*
* @param object $method A instance of `ReflectionMethod`.
* @return string The return type.
*/
protected static function _generateReturnType($method)
{
return Inspector::returnTypehint($method->getReturnType());
}
/**
* Creates a parameters signature of a `ReflectionMethod` instance.
*
* @param object $method A instance of `ReflectionMethod`.
* @return string The parameters definition list.
*/
protected static function _generateSignature($method)
{
$params = [];
$isVariadic = $method->isVariadic();
foreach ($method->getParameters() as $num => $parameter) {
$typehint = Inspector::typehint($parameter);
$name = $parameter->getName();
$name = ($name && $name !== '...') ? $name : 'param' . $num;
$reference = $parameter->isPassedByReference() ? '&' : '';
$default = '';
if ($parameter->isDefaultValueAvailable()) {
$default = var_export($parameter->getDefaultValue(), true);
$default = ' = ' . preg_replace('/\s+/', '', $default);
} elseif ($parameter->isOptional()) {
if ($isVariadic && $parameter->isVariadic()) {
$reference = '...';
$default = '';
} else {
$default = ' = NULL';
}
}
$typehint = $typehint ? $typehint . ' ' : $typehint;
$params[] = "{$typehint}{$reference}\${$name}{$default}";
}
return join(', ', $params);
}
/**
* Creates a parameters list from a `ReflectionMethod` instance.
*
* @param object $method A instance of `ReflectionMethod`.
* @return string The parameters definition list.
*/
protected static function _generateParameters($method)
{
$params = [];
foreach ($method->getParameters() as $num => $parameter) {
$name = $parameter->getName();
$name = ($name && $name !== '...') ? $name : 'param' . $num;
$params[] = "\${$name}";
}
return join(', ', $params);
}
}