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
93 changes: 93 additions & 0 deletions src/Internal/Ast/ContractVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
use PHPStan\PhpDocParser\Parser\TokenIterator;
use TypePHP\Internal\Docblock\DocblockExtractor;
use TypePHP\Internal\Docblock\DocblockNormalizer;
use TypePHP\Internal\Util\Config;

/**
Expand All @@ -30,6 +33,11 @@ final class ContractVisitor extends NodeVisitorAbstract
Node\Expr\AssignOp\Coalesce::class => Node\Expr\BinaryOp\Coalesce::class,
];

/**
* @var array<string, array<string|int, string>>
*/
private static array $parsedShapeCache = [];

private ScopeManager $scopeManager;

private string $currentNamespace = '';
Expand Down Expand Up @@ -441,6 +449,16 @@ private function handleExpression(Node\Stmt\Expression $node): ?array

/** @var Node\Expr\List_|Node\Expr\Array_ $destructuringVar */
$destructuringVar = $node->expr->var;

// Auto-propagate types from RHS if it's a known ArrayShape in scope
$rhs = $node->expr->expr;
if ($rhs instanceof Node\Expr\Variable && \is_string($rhs->name)) {
$sourceType = $this->scopeManager->getVarTypeFromScope($rhs->name);
if ($sourceType !== null && str_contains($sourceType, '{')) {
$this->propagateShapeTypesToDestructuring($destructuringVar, $sourceType);
}
}

$destructuredVars = $this->extractDestructuringVariables($destructuringVar);
$checkStmts = [];

Expand All @@ -463,6 +481,81 @@ private function handleExpression(Node\Stmt\Expression $node): ?array
return $checkStmts !== [] ? [$node, ...$checkStmts] : null;
}

/**
* Propagates field types from an array shape into destructured local variables with O(1) AST memoization.
*/
private function propagateShapeTypesToDestructuring(Node\Expr\List_|Node\Expr\Array_ $destructuringVar, string $shapeTypeString): void
{
try {
$shapeMap = self::$parsedShapeCache[$shapeTypeString] ?? null;

if ($shapeMap === null) {
$normalized = DocblockNormalizer::normalize($shapeTypeString);
[$typeParser, $lexer] = DocblockExtractor::getTypeParserComponents();
$tokens = new TokenIterator($lexer->tokenize($normalized));
$parsed = $typeParser->parse($tokens);

if (! ($parsed instanceof ArrayShapeNode)) {
return;
}

$shapeMap = [];
$autoIdx = 0;
foreach ($parsed->items as $item) {
$key = match (true) {
$item->keyName instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode => $item->keyName->value,
$item->keyName instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode => (int) $item->keyName->value,
$item->keyName instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode => $item->keyName->name,
$item->keyName !== null => (string) $item->keyName,
default => $autoIdx,
};

if (\is_int($key)) {
$autoIdx = max($autoIdx, $key + 1);
}

$shapeMap[$key] = (string) $item->valueType;
}

self::$parsedShapeCache[$shapeTypeString] = $shapeMap;
}

$currentIdx = 0;
foreach ($destructuringVar->items as $item) {
if ($item === null) {
$currentIdx++;

continue;
}

$destructKey = null;
if ($item->key instanceof Node\Scalar\String_) {
$destructKey = $item->key->value;
} elseif ($item->key instanceof Node\Scalar\LNumber) {
$destructKey = $item->key->value;
$currentIdx = max($currentIdx, $destructKey + 1);
} elseif ($item->key === null) {
$destructKey = $currentIdx;
$currentIdx++;
}

if ($destructKey !== null && isset($shapeMap[$destructKey])) {
$targetVar = $item->value;
$fieldType = $shapeMap[$destructKey];

if ($targetVar instanceof Node\Expr\Variable && \is_string($targetVar->name)) {
$this->scopeManager->registerVarType($targetVar->name, $fieldType);
} elseif ($targetVar instanceof Node\Expr\List_ || $targetVar instanceof Node\Expr\Array_) {
// Recursively propagate nested shape fields
$this->propagateShapeTypesToDestructuring($targetVar, $fieldType);
}
}
}
} catch (\Throwable $e) {
// Silently ignore parsing errors
}
}

private function handleAssign(Node\Expr\Assign $node): void
{
if ($node->var instanceof Node\Expr\Variable && \is_string($node->var->name)) {
Expand Down
13 changes: 13 additions & 0 deletions src/Internal/Ast/ScopeManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ final class ScopeManager
*/
private int $depth = 0;

/**
* Programmatically registers a variable's type into the current scope frame.
* Preserves explicit inline @var declarations in the current frame unless overwrite is true.
*/
public function registerVarType(string $varName, string $typeString, bool $overwrite = false): void
{
if (! $overwrite && isset($this->scopeStack[$this->depth][$varName])) {
return;
}

$this->scopeStack[$this->depth][$varName] = $typeString;
}

/**
* Pushes a new lexical scope frame (O(1)).
*
Expand Down
54 changes: 50 additions & 4 deletions src/Internal/Checker/InlineChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ final class InlineChecker
*/
private static array $classHasSelfOutCache = [];

/**
* In-memory cache for resolved static property generic types: [$className][$propName] => TypeNode.
*
* @var array<string, array<string, TypeNode>>
*/
private static array $resolvedStaticPropertyTypeCache = [];

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

/**
Expand Down Expand Up @@ -304,9 +312,6 @@ private static function isArrayShapeType(TypeNode $node): bool
return false;
}

/**
* Evaluates class property validation dynamically based on configuration with zero-allocation 2D caching.
*/
/**
* Evaluates class property validation dynamically based on configuration with zero-allocation 2D caching.
*/
Expand All @@ -326,8 +331,9 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
return $value;
}

// 1. Enforce magic property access permissions only for non-physical dynamic properties
$rawTypeNode = null;
if (Config::isMagicPropertiesEnabled()) {
if (! property_exists($className, $propName) && Config::isMagicPropertiesEnabled()) {
$magicContract = DocblockParser::parseMagicPropertyContract($className, $propName);
if ($magicContract !== null) {
if (! Config::isMagicPropertyWritesEnabled()) {
Expand All @@ -342,6 +348,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
}
}

// 2. Fall back to standard declared class property if not intercepted by a magic contract
if ($rawTypeNode === null) {
$rawTypeNode = DocblockParser::parseProperty($className, $propName);
}
Expand All @@ -364,6 +371,11 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
if ($propertyUsesTemplates) {
$typeNode = self::substitutePropertyGenerics($rawTypeNode, $objectOrClass, $className);
}
} elseif (\is_string($objectOrClass)) {
$propertyUsesTemplates = self::propertyUsesTemplates($rawTypeNode, $className, $propName);
if ($propertyUsesTemplates) {
$typeNode = self::substituteStaticPropertyGenerics($rawTypeNode, $className, $propName);
}
}

try {
Expand Down Expand Up @@ -673,6 +685,40 @@ private static function substitutePropertyGenerics(TypeNode $typeNode, object $o
return $typeNode;
}

/**
* Substitutes generic template types declared on static class properties with zero-allocation memoization.
*/
private static function substituteStaticPropertyGenerics(TypeNode $typeNode, string $className, string $propName): TypeNode
{
if (isset(self::$resolvedStaticPropertyTypeCache[$className][$propName])) {
return self::$resolvedStaticPropertyTypeCache[$className][$propName];
}

$constructorTarget = $className . '::__construct';
$contract = DocblockParser::parse($constructorTarget);

$allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])];
$classBindings = TemplateManager::getClassInheritedBindings($className);
$classAliases = DocblockParser::parseClassAliases($className);
$activeBindings = [...$classAliases, ...$classBindings];

if (\count($activeBindings) > 0 || \count($allTemplates) > 0) {
$typeNode = TemplateSubstitutor::substitute($typeNode, $activeBindings, $allTemplates);

if (class_exists($className, false) || class_exists($className) || interface_exists($className) || trait_exists($className)) {
try {
/** @var class-string<object> $className */
$refClass = new \ReflectionClass($className);
$typeNode = SpecialTypeResolver::resolve($typeNode, $refClass);
} catch (\ReflectionException $e) {
// Silently continue if reflection fails
}
}
}

return self::$resolvedStaticPropertyTypeCache[$className][$propName] = $typeNode;
}

/**
* Inspects a TypeNode to check if it requires caller context resolution (templates, aliases, self/static/$this).
*/
Expand Down
6 changes: 4 additions & 2 deletions src/Internal/Checker/ParamChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,10 @@ private static function prepareGenericBindings(
array $classTemplates,
object|string|null $thisOrClass = null
): void {
if (\count($methodTemplates) > 0) {
TemplateManager::clearCallBindings($effectiveFunction, $methodTemplates);
$hasTemplatesToClear = \count($methodTemplates) > 0 || ($thisObj === null && \count($classTemplates) > 0);

if ($hasTemplatesToClear) {
TemplateManager::clearCallBindings($effectiveFunction, [...$classTemplates, ...$methodTemplates]);
}

if (\count($classTemplates) > 0 && str_contains($effectiveFunction, '::')) {
Expand Down
9 changes: 6 additions & 3 deletions src/Internal/Docblock/DocblockExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ public static function extractMagicPropertyContract(string $doc, string $propNam
foreach ($phpDocNode->getPropertyTagValues() as $tag) {
if (ltrim($tag->propertyName, '$') === $propName) {
$propTag = $tag;

break;
}
}
Expand All @@ -119,6 +120,7 @@ public static function extractMagicPropertyContract(string $doc, string $propNam
foreach ($phpDocNode->getPropertyReadTagValues() as $tag) {
if (ltrim($tag->propertyName, '$') === $propName) {
$readTag = $tag;

break;
}
}
Expand All @@ -127,6 +129,7 @@ public static function extractMagicPropertyContract(string $doc, string $propNam
foreach ($phpDocNode->getPropertyWriteTagValues() as $tag) {
if (ltrim($tag->propertyName, '$') === $propName) {
$writeTag = $tag;

break;
}
}
Expand Down Expand Up @@ -338,9 +341,9 @@ public static function getVarTags(PhpDocNode $node): array
}

$unnamed = [];
$unnamedPhpstan = array_values(array_filter($node->getVarTagValues('@phpstan-var'), fn($t) => $t->variableName === ''));
$unnamedPsalm = array_values(array_filter($node->getVarTagValues('@psalm-var'), fn($t) => $t->variableName === ''));
$unnamedStandard = array_values(array_filter($node->getVarTagValues('@var'), fn($t) => $t->variableName === ''));
$unnamedPhpstan = array_values(array_filter($node->getVarTagValues('@phpstan-var'), fn ($t) => $t->variableName === ''));
$unnamedPsalm = array_values(array_filter($node->getVarTagValues('@psalm-var'), fn ($t) => $t->variableName === ''));
$unnamedStandard = array_values(array_filter($node->getVarTagValues('@var'), fn ($t) => $t->variableName === ''));

if (\count($unnamedPhpstan) > 0) {
$unnamed = $unnamedPhpstan;
Expand Down
5 changes: 4 additions & 1 deletion src/Internal/RuntimeTypeChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,11 @@ public static function setupScope(string $function, array $vars, object|string|n

$hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] ?? null;
if ($hasMethodTemplates === null) {
$isStaticCall = ($thisObj === null);
$hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] = (
(($contract['templates'] ?? []) !== []) || ($contract['returnUsesMethodTemplates'] ?? false)
(($contract['templates'] ?? []) !== [])
|| ($contract['returnUsesMethodTemplates'] ?? false)
|| ($isStaticCall && ($contract['classTemplates'] ?? []) !== [])
);
self::$hasMethodTemplatesCache[$function] = $hasMethodTemplates;
}
Expand Down
Loading
Loading