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
48 changes: 40 additions & 8 deletions src/Internal/Ast/FunctionContractInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ?
$methodName = $isClassMethod ? strtolower($node->name->toString()) : '';
$isConstructor = $isClassMethod && $methodName === '__construct';
$isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true);
$isMagicGet = $isClassMethod && $methodName === '__get';
$isMagicCall = $isClassMethod && ($methodName === '__call' || $methodName === '__callstatic');
$isNativeNever = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'never';
$isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void';
$isPrivate = $isClassMethod && $node->isPrivate();

$paramCount = \count($node->params);

$hasParam = self::hasParamContracts(
$hasParam = ($isMagicGet || $isMagicCall) || self::hasParamContracts(
$docText,
$isClassMethod,
$hasInheritance,
Expand Down Expand Up @@ -90,20 +92,20 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ?
$hasReturn = ! $isMagicLifecycle
&& ! $isNativeNever
&& ! ($isNativeVoid && ! $hasReturnDoc)
&& self::hasReturnContracts($docText, $isClassMethod, $isPrivate);
&& ($isMagicGet || $isMagicCall || self::hasReturnContracts($docText, $isClassMethod, $isPrivate));

if (! $hasParam && ! $hasReturn && ! $hasParamOut && ! $hasSelfOut) {
return;
}

$thisArg = self::resolveThisArg($isClassMethod, $node);
$needsReturnVars = $hasParam && ($paramCount > 0) && (
$needsReturnVars = ($hasParam && ($paramCount > 0) && (
$hasInheritance || str_contains($docText, ' is ') || ($hasReturnDoc && str_contains($docText, '$'))
);
)) || ($isClassMethod && $paramCount > 0 && ($isMagicGet || $isMagicCall));

$injectedStmts = [];
if ($hasParam) {
$injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg, $isReadonlyClass);
$injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg, $isReadonlyClass, $needsReturnVars);
}

if ($hasReturn || $hasParamOut || $hasSelfOut) {
Expand Down Expand Up @@ -313,9 +315,10 @@ private static function buildParamInjections(
array $params,
string $docText,
Node\Expr $thisArg,
bool $isReadonlyClass = false
bool $isReadonlyClass = false,
bool $needsReturnVars = false
): array {
$injectedStmts = [self::buildSetupScopeStmt($params, $thisArg)];
$injectedStmts = [self::buildSetupScopeStmt($params, $thisArg, $needsReturnVars)];
$callableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isCallableCandidate'], 'wrapCallable', $isReadonlyClass);
$iterableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isIterableCandidate'], 'wrapIterable', $isReadonlyClass);

Expand All @@ -325,7 +328,7 @@ private static function buildParamInjections(
/**
* @param array<Node\Param> $params
*/
private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg): Node\Stmt\If_
private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg, bool $needsReturnVars = false): Node\Stmt\If_
{
$arrayItems = [];
foreach ($params as $param) {
Expand Down Expand Up @@ -389,6 +392,35 @@ private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg):

$combinedCondition = new Node\Expr\BinaryOp\BooleanOr($noParamCacheCheck, $hasTemplatesCheck);

if ($needsReturnVars) {
$ifStmt = new Node\Stmt\If_(
new Node\Expr\ConstFetch(new Node\Name('true')),
[
'stmts' => [
$argsAssign,
new Node\Stmt\If_(
$combinedCondition,
[
'stmts' => [
new Node\Stmt\If_(
new Node\Expr\Instanceof_(
new Node\Expr\Assign(new Node\Expr\Variable('__typephpErr'), $checkCall),
new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage')
),
['stmts' => [$throwStmt]]
),
],
]
),
],
]
);

$ifStmt->setAttribute('typephp_injected', true);

return $ifStmt;
}

$ifStmt = new Node\Stmt\If_(
$combinedCondition,
[
Expand Down
173 changes: 169 additions & 4 deletions src/Internal/Checker/InlineChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ final class InlineChecker
*/
public static array $nullPropertyCache = [];

/**
* Cache for whether a class property's type references generic templates:
* [$className][$propName] => bool.
*
* @var array<string, array<string, bool>>
*/
private static array $propertyUsesTemplatesCache = [];

/**
* Cache for whether a class has any methods declaring @self-out or @this-out:
* [$className] => bool.
*
* @var array<string, bool>
*/
private static array $classHasSelfOutCache = [];

/**
* Resets internal type node and function caches. Useful for test isolation.
*/
Expand All @@ -69,6 +85,8 @@ public static function reset(): void
self::$parsedTypeNodeCache = [];
self::$resolvedClassContextCache = [];
self::$nullPropertyCache = [];
self::$propertyUsesTemplatesCache = [];
self::$classHasSelfOutCache = [];
}

/**
Expand Down Expand Up @@ -236,24 +254,39 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
return $value;
}

$typeNode = DocblockParser::parseProperty($className, $propName);
if ($typeNode === null) {
$rawTypeNode = DocblockParser::parseProperty($className, $propName);
if ($rawTypeNode === null) {
self::$nullPropertyCache[$className][$propName] = true;

return $value;
}

if (! self::shouldValidateType($typeNode)) {
if (! self::shouldValidateType($rawTypeNode)) {
return $value;
}

$propertyUsesTemplates = false;
$typeNode = $rawTypeNode;

if (\is_object($objectOrClass)) {
$typeNode = self::substitutePropertyGenerics($typeNode, $objectOrClass, $className);
$propertyUsesTemplates = self::propertyUsesTemplates($rawTypeNode, $className, $propName);
if ($propertyUsesTemplates) {
$typeNode = self::substitutePropertyGenerics($rawTypeNode, $objectOrClass, $className);
}
}

try {
$err = $registry->validate($value, $typeNode, 'Property ' . $className . '::$' . $propName);
if ($err !== null) {
if (
$propertyUsesTemplates &&
\is_object($objectOrClass) &&
self::classHasSelfOut($className) &&
self::trySelfOutTransition($value, $objectOrClass, $propName, $registry)
) {
return $value;
}

return $err;
}
} catch (\Throwable $e) {
Expand All @@ -263,6 +296,138 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
return $value;
}

/**
* Checks if a property's declared type references class generic templates with memoization.
*/
private static function propertyUsesTemplates(TypeNode $rawTypeNode, string $className, string $propName): bool
{
if (isset(self::$propertyUsesTemplatesCache[$className][$propName])) {
return self::$propertyUsesTemplatesCache[$className][$propName];
}

$constructorTarget = $className . '::__construct';
$contract = DocblockParser::parse($constructorTarget);
$allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])];

if ($allTemplates === []) {
return self::$propertyUsesTemplatesCache[$className][$propName] = false;
}

return self::$propertyUsesTemplatesCache[$className][$propName] = DocblockParser::typeReferencesTemplate($rawTypeNode, $allTemplates);
}

/**
* Checks if a class declares any methods containing @self-out or @this-out annotations with memoization.
*/
private static function classHasSelfOut(string $className): bool
{
if (isset(self::$classHasSelfOutCache[$className])) {
return self::$classHasSelfOutCache[$className];
}

if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) {
return self::$classHasSelfOutCache[$className] = false;
}

try {
/** @var class-string<object> $className */
$ref = new \ReflectionClass($className);
foreach ($ref->getMethods() as $method) {
$doc = $method->getDocComment();
if ($doc !== false && (str_contains($doc, 'self-out') || str_contains($doc, 'this-out'))) {
return self::$classHasSelfOutCache[$className] = true;
}
}
} catch (\Throwable $e) {
// Silently ignore reflection errors
}

return self::$classHasSelfOutCache[$className] = false;
}

/**
* Attempts a typestate transition when a property assignment to $this fails against current bindings.
*/
private static function trySelfOutTransition(mixed $value, object $object, string $propName, TypeValidatorRegistry $registry): bool
{
if (! Config::isSelfOutEnabled()) {
return false;
}

$trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 7);
$callerFrame = null;
$callerFunction = null;
$contract = null;

foreach ($trace as $frame) {
if (isset($frame['object'], $frame['class']) && $frame['object'] === $object) {
if ($frame['class'] === 'TypePHP\Internal\RuntimeTypeChecker' || str_starts_with($frame['class'], 'TypePHP\\Internal\\')) {
continue;
}

$candidateFunction = $frame['class'] . '::' . $frame['function'];
$candidateContract = DocblockParser::parse($candidateFunction);

if (($candidateContract['hasSelfOutContract'] ?? false) && $candidateContract['selfOut'] !== null) {
$callerFrame = $frame;
$callerFunction = $candidateFunction;
$contract = $candidateContract;

break;
}
}
}

if ($callerFrame === null || $callerFunction === null || $contract === null) {
return false;
}

$selfOutNode = $contract['selfOut'];
$allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])];
$boundTemplates = (\count($allTemplates) > 0)
? TemplateManager::getBoundTemplates($callerFunction, $object, $allTemplates)
: [];

if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) {
$selfOutNode = TemplateSubstitutor::substitute($selfOutNode, $boundTemplates, $allTemplates);
$selfOutNode = SpecialTypeResolver::resolve($selfOutNode, $callerFunction, $object);
}

$vars = $callerFrame['args'] ?? [];
if (
$selfOutNode instanceof ConditionalTypeForParameterNode ||
$selfOutNode instanceof ConditionalTypeNode
) {
$selfOutNode = ConditionalChecker::resolve($selfOutNode, $vars, $boundTemplates, $registry, $callerFunction);
}

if (! ($selfOutNode instanceof GenericTypeNode)) {
return false;
}

$previousBindings = TemplateManager::getBoundTemplatesForInstance($object);

TemplateManager::bindInstanceFromNode($object, $selfOutNode, forceBind: true);

$className = $object::class;
$propTypeNode = DocblockParser::parseProperty($className, $propName);
if ($propTypeNode !== null) {
$propTypeNode = self::substitutePropertyGenerics($propTypeNode, $object, $className);
}

$testErr = $propTypeNode !== null
? $registry->validate($value, $propTypeNode, 'Property ' . $className . '::$' . $propName)
: null;

if ($testErr === null) {
return true;
}

TemplateManager::restoreInstanceBindings($object, $previousBindings);

return false;
}

/**
* Resolves caller class or function context and applies templates & type aliases to the AST.
*/
Expand Down
9 changes: 5 additions & 4 deletions src/Internal/Checker/ParamChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public static function reset(): void
*/
public static function areAllParamsUnconstrained(string $effectiveFunction): bool
{
if (str_contains($effectiveFunction, '__call')) {
if (str_contains($effectiveFunction, '__call') || str_ends_with($effectiveFunction, '::__get')) {
return false;
}

Expand Down Expand Up @@ -149,20 +149,21 @@ public static function checkParams(
}

$isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic');
$isMagicGet = str_ends_with($effectiveFunction, '::__get');

if (! $isMagicCall && isset(self::$noParamContractCache[$effectiveFunction])) {
if (! $isMagicCall && ! $isMagicGet && isset(self::$noParamContractCache[$effectiveFunction])) {
return null;
}

if ($vars === [] && ! $isMagicCall) {
if ($vars === [] && ! $isMagicCall && ! $isMagicGet) {
return null;
}

$magicError = self::handleMagicCall($effectiveFunction, $vars, $thisObj, $registry);
if ($magicError !== null) {
return $magicError;
}
if ($isMagicCall) {
if ($isMagicCall || $isMagicGet) {
return null;
}

Expand Down
Loading
Loading