phabel/src/Plugin/TypeHintStripper.php

307 lines
12 KiB
PHP

<?php
namespace Phabel\Plugin;
use Phabel\Context;
use Phabel\Plugin;
use Phabel\Target\Php74\ArrowClosure;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\AssignRef;
use PhpParser\Node\Expr\BinaryOp\BooleanOr;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\BinaryOp\Plus;
use PhpParser\Node\Expr\BooleanNot;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\Instanceof_;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\NullableType;
use PhpParser\Node\Param;
use PhpParser\Node\Scalar\LNumber;
use PhpParser\Node\Scalar\MagicConst\Function_ as MagicConstFunction_;
use PhpParser\Node\Scalar\MagicConst\Method;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Foreach_;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\Stmt\Throw_;
use PhpParser\Node\UnionType;
use SplStack;
/**
* Replace all usages of a certain type in typehints.
*
* @author Daniil Gentili <daniil@daniil.it>
*/
class TypeHintStripper extends Plugin
{
private const IGNORE_RETURN = 0;
private const VOID_RETURN = 1;
private const TYPE_RETURN = 2;
/**
* Stack.
*
* @template T as array{0: self::IGNORE_RETURN|self::VOID_RETURN}|array{0: self::TYPE_RETURN, 1: Node, 2: bool, 3: bool, 4: Node, 5: BooleanNot}
*
* @var SplStack<T>
*/
private SplStack $stack;
private ArrowClosure $converter;
/**
* Constructor.
*/
public function __construct()
{
$this->stack = new SplStack;
$this->converter = new ArrowClosure;
}
/**
* Convert a function to a closure.
*/
private function toClosure(FunctionLike &$func): void
{
if ($func instanceof ArrowFunction) {
$func = $this->converter->enterClosure($func);
}
}
/**
* Generate.
*
* @param Variable $var Variable to check
* @param (Name|Identifier)[] $types Types to check
* @param boolean $fromNullable Whether this type is nullable
*
* @return array{0: bool, 1: Node, 2: BooleanNot} Whether the polyfilled gettype should be used, the error message, the condition
*/
private function generateConditions(Variable $var, array $types, bool $fromNullable = false): array
{
/** @var bool Whether no explicit classes were referenced */
$noOopTypes = true;
/** @var string[] */
$typeNames = [];
/** @var Expr[] */
$conditions = [];
/** @var string Last string type name */
$stringType = '';
foreach ($types as $type) {
$typeNames []= $type->toString();
if ($type instanceof Identifier) {
$typeName = $type->toLowerString();
switch ($typeName) {
case 'callable':
case 'array':
case 'bool':
case 'float':
case 'int':
case 'object':
case 'string':
case 'resource':
case 'null':
$stringType = new String_($typeName === 'callable' ?
$typeName :
($typeName === 'object' ? 'an object' : "of type $typeName"));
$conditions []= Plugin::call("is_$typeName", $var);
break;
case 'iterable':
$stringType = new String_('iterable');
$conditions []= new BooleanOr(
Plugin::call("is_array", $var),
new Instanceof_($var, new Name(\Traversable::class))
);
break;
default:
$noOopTypes = false;
$stringType = $type->isSpecialClassName() ?
new Concat(new String_("an instance of "), new ClassConstFetch(new Name($typeName), new Identifier('class'))) :
new String_("an instance of ".$type->toString());
$conditions []= new Instanceof_($var, new Name($typeName));
}
} else {
$noOopTypes = false;
$stringType = new String_("an instance of ".$type->toString());
$conditions []= new Instanceof_($var, $type);
}
}
if (\count($typeNames) > 1) {
$stringType = new String_(\implode("|", $typeNames));
}
if ($fromNullable) {
$stringType = new Concat($stringType, new String_(' or null'));
$conditions []= Plugin::call("is_null", $var);
}
$initial = \array_shift($conditions);
$condition = new BooleanNot(
empty($conditions)
? $initial
: \array_reduce($conditions, fn (Expr $a, Expr $b): BooleanOr => new BooleanOr($a, $b), $initial)
);
return [$noOopTypes, $stringType, $condition];
}
/**
* Strip typehint.
*
* @param Variable $var Variable
* @param null|Identifier|Name|NullableType|UnionType $type Type
*
* @return null|array{0: bool, 1: Node, 2: BooleanNot} Whether the polyfilled gettype should be used, the error message, the condition
*/
private function strip(Variable $var, ?Node $type): ?array
{
if (!$type) {
return null;
}
if ($type instanceof UnionType && $this->getConfig('union', false)) {
return $this->generateConditions($var, $type->types);
}
if ($type instanceof NullableType && $this->getConfig('nullable', false)) {
return $this->generateConditions($var, [$type->type], true);
}
$subType = $type instanceof NullableType ? $type->type : $type;
if (\in_array($subType->toString(), $this->getConfig('types', []))) {
return $this->generateConditions($var, [$subType], $type instanceof NullableType);
}
return null;
}
/**
* Strip type hints from function.
*
* @param FunctionLike $func Function
*
* @return ?FunctionLike
*/
public function enterFunction(FunctionLike $func, Context $ctx): ?FunctionLike
{
$functionName = new Method();
if ($func instanceof ClassMethod) {
/** @var ClassLike */
$parent = $ctx->parents->top();
if (!$parent->name) {
$functionName = new Concat(new String_('class@anonymous:'), new MagicConstFunction_());
}
}
$stmts = [];
foreach ($func->getParams() as $index => $param) {
if (!$condition = $this->strip($param->variadic ? new Variable('phabelVariadic') : $param->var, $param->type)) {
break;
}
$param->type = null;
[$noOop, $string, $condition] = $condition;
$start = $param->variadic ? new Concat(new String_("Argument "), new Plus(new LNumber($index), new Variable('phabelVariadicIndex'))) : new String_("Argument $index");
$start = new Concat($start, new String_(" passed to "));
$start = new Concat($start, $functionName);
$start = new Concat($start, new String_(" must be "));
$start = new Concat($start, $string);
$start = new Concat($start, new String_(", "));
$start = new Concat($start, $noOop ? self::call('gettype', $param->var) : self::callPoly('gettype', $param->var));
$start = new Concat($start, new String_(" given, called in "));
$start = new Concat($start, self::callPoly('trace', new LNumber(0)));
$if = new If_($condition, [new Throw_(new New_(new FullyQualified(\TypeError::class), [new Arg($start)]))]);
if ($param->variadic) {
$stmts []= new Foreach_($param->var, new Variable('phabelVariadic'), ['keyVar' => new Variable('phabelVariadicIndex'), 'stmts' => [$if]]);
} else {
$stmts []= $if;
}
}
if ($stmts) {
$this->toClosure($func);
$func->stmts = \array_merge($stmts, $func->getStmts() ?? []);
}
if ($this->getConfig('void', false) && $func->getReturnType() instanceof Identifier && $func->getReturnType()->toLowerString() === 'void') {
$this->toClosure($func);
$this->stack->push([self::VOID_RETURN]);
}
$var = new Variable('phabelReturn');
if (!$condition = $this->strip($var, $func->getReturnType())) {
$this->stack->push([self::IGNORE_RETURN]);
return null;
}
$this->toClosure($func);
$this->stack->push([self::TYPE_RETURN, $functionName, $func->returnsByRef(), ...$condition]);
return $func;
}
public function enterReturn(Return_ $return, Context $ctx): ?Node
{
$current = $this->stack->top();
if ($current[0] === self::IGNORE_RETURN) {
return null;
}
if ($current[0] === self::VOID_RETURN) {
if ($return->expr !== null) {
// This should be a transpilation error, wait for better stack traces before throwing here
return new Throw_(new New_(new FullyQualified(\ParseError::class), [new String_("A void function must not return a value")]));
}
return null;
}
[, $functionName, $byRef, $noOop, $string, $condition] = $current;
$var = new Variable('phabelReturn');
$assign = $byRef ? new AssignRef($var, $return->expr ?? new Name('null')) : new Assign($var, $return->expr ?? new Name('null'));
$start = new String_("Return value of");
$start = new Concat($start, $functionName);
$start = new Concat($start, new String_(" must be "));
$start = new Concat($start, $string);
$start = new Concat($start, new String_(", "));
$start = new Concat($start, $noOop ? self::call('gettype', $var) : self::callPoly('gettype', $var));
$start = new Concat($start, new String_(" returned in "));
$start = new Concat($start, self::callPoly('trace', 0));
$if = new If_($condition, [new Throw_(new New_(new FullyQualified(\TypeError::class), [new Arg($start)]))]);
$return->expr = $var;
$ctx->insertBefore($return, $assign, $if);
}
/**
* Get trace string for errors.
*
* @param int $index Index
*
* @return string
*/
public static function trace($index)
{
$trace = \debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[$index];
return ($trace['file'] ?? '').' on line '.($trace['line'] ?? '');
}
/**
* Get type string or object.
*
* @param mixed $object Object
*
* @return string
*/
public static function gettype($object)
{
if (\is_object($object)) {
$type = \get_class($object);
return \str_starts_with($type, 'class@anonymous') ? 'instance of class@anonymous' : "instance of $type";
}
return \gettype($object);
}
/**
* Runwithafter.
*
* @return array
*/
public static function runWithAfter(): array
{
return [StringConcatOptimizer::class];
}
}