Skip to content
Merged
15 changes: 15 additions & 0 deletions src/Internal/Checker/ParamChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
Expand Down Expand Up @@ -230,6 +231,7 @@ private static function handleMagicCall(

/**
* Pre-infers generic template parameters from array arguments before callback wrapping.
* Only runs if at least one parameter in the signature is a callable that uses generic templates.
*
* @param array<string, TypeNode> $types
* @param array<string, mixed> $vars
Expand All @@ -242,6 +244,19 @@ private static function preInferGenericArrayTemplates(
?object $thisObj,
array $templates
): void {
$hasCallableParam = false;

foreach ($types as $tNode) {
if ($tNode instanceof CallableTypeNode) {
$hasCallableParam = true;
break;
}
}

if (! $hasCallableParam) {
return;
}

foreach ($types as $paramName => $typeNode) {
if (! \array_key_exists($paramName, $vars) || ! \is_array($vars[$paramName]) || \count($vars[$paramName]) === 0) {
continue;
Expand Down
98 changes: 72 additions & 26 deletions src/Internal/StreamWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ final class StreamWrapper implements StreamWrapperInterface
private static array $statCache = [];

/**
* In-memory cache for static-path negative misses only (e.g. vendor directories).
* Never stores negative misses for dynamic paths (var/cache, storage).
* In-memory cache for static-path negative misses only (vendor directories).
*
* @var array<string, true>
*/
Expand Down Expand Up @@ -226,7 +225,11 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) {
$target = ($resolvedPath !== false) ? $resolvedPath : $path;
/** @var resource|false $handle */
$handle = self::silent(fn () => fopen($target, $mode));
$handle = self::silent(
fn () => ($this->context !== null)
? fopen($target, $mode, false, $this->context)
: fopen($target, $mode)
);
$this->handle = $handle !== false ? $handle : null;
self::register();

Expand All @@ -243,7 +246,7 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
}

/**
* Very Important: Determines whether the stream is being opened by a native PHP source-viewing function
* Determines whether the stream is being opened by a native PHP source-viewing function
* (e.g. highlight_file, show_source, file_get_contents, token_get_all) by inspecting shallow backtrace frames.
*/
private static function isReadOnlyCall(): bool
Expand All @@ -257,15 +260,20 @@ private static function isReadOnlyCall(): bool
}

/**
* Opens an underlying filesystem handle directly with error reporting options.
* Opens an underlying filesystem handle directly with error reporting options and context support.
*/
private function openDirectHandle(string $targetFile, string $mode, int $options): bool
{
$isInclude = ($options & self::STREAM_OPEN_FOR_INCLUDE) !== 0;
$useIncludePath = ($options & STREAM_USE_PATH) !== 0;

self::unregister();
/** @var resource|false $handle */
$handle = self::silent(fn () => fopen($targetFile, $mode));
$handle = self::silent(
fn () => ($this->context !== null)
? fopen($targetFile, $mode, $useIncludePath, $this->context)
: fopen($targetFile, $mode, $useIncludePath)
);
$this->handle = $handle !== false ? $handle : null;
self::register();

Expand Down Expand Up @@ -396,18 +404,19 @@ public function stream_close(): void

/**
* Resolves file status with dual-tier memoization caching:
* 1. Positive hit cache ($statCache): Stores stat arrays for confirmed files.
* 2. Static negative cache ($staticNegativeStatCache): Caches false lookups strictly for static vendor paths.
* 3. Dynamic writable bypass: Bypasses negative caching for dynamic directories (var/cache, storage).
* 1. Differentiates between stat() and lstat() (STREAM_URL_STAT_LINK).
* 2. Only memoizes .php source files and immutable vendor paths.
*
* @return array<int|string, int>|false
*/
public function url_stat(string $path, int $flags): array|false
{
$normalized = str_replace('\\', '/', $path);
$isLink = ($flags & STREAM_URL_STAT_LINK) !== 0;
$cacheKey = $normalized . ($isLink ? ':lstat' : ':stat');

if (isset(self::$statCache[$normalized])) {
return self::$statCache[$normalized];
if (isset(self::$statCache[$cacheKey])) {
return self::$statCache[$cacheKey];
}

if (isset(self::$staticNegativeStatCache[$normalized])) {
Expand All @@ -416,14 +425,19 @@ public function url_stat(string $path, int $flags): array|false

self::unregister();
/** @var array<int|string, int>|false $result */
$result = self::silent(fn () => (($flags & STREAM_URL_STAT_LINK) !== 0) ? @lstat($path) : @stat($path));
$result = self::silent(fn () => $isLink ? @lstat($path) : @stat($path));
self::register();

if ($result !== false) {
return self::$statCache[$normalized] = $result;
$isPhp = str_ends_with(strtolower($normalized), '.php');
if ($isPhp || PathMatcher::isVendorPath($normalized)) {
self::$statCache[$cacheKey] = $result;
}

return $result;
}

if (! PathMatcher::isDynamicWritablePath($normalized)) {
if (PathMatcher::isVendorPath($normalized)) {
self::$staticNegativeStatCache[$normalized] = true;
}

Expand All @@ -433,7 +447,11 @@ public function url_stat(string $path, int $flags): array|false
public function stream_metadata(string $path, int $option, mixed $value): bool
{
$normalized = str_replace('\\', '/', $path);
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
unset(
self::$statCache[$normalized . ':stat'],
self::$statCache[$normalized . ':lstat'],
self::$staticNegativeStatCache[$normalized]
);

self::unregister();
$result = false;
Expand All @@ -457,7 +475,11 @@ public function dir_opendir(string $path, int $options): bool
{
self::unregister();
/** @var resource|false $dh */
$dh = self::silent(fn () => @opendir($path));
$dh = self::silent(
fn () => ($this->context !== null)
? @opendir($path, $this->context)
: @opendir($path)
);
$this->dirHandle = $dh !== false ? $dh : null;
self::register();

Expand Down Expand Up @@ -497,10 +519,16 @@ public function dir_closedir(): bool
public function mkdir(string $path, int $mode, int $options): bool
{
$normalized = str_replace('\\', '/', $path);
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
unset(
self::$statCache[$normalized . ':stat'],
self::$statCache[$normalized . ':lstat'],
self::$staticNegativeStatCache[$normalized]
);

self::unregister();
$result = (bool) self::silent(fn () => @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0));
$result = ($this->context !== null)
? @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0, $this->context)
: @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0);
self::register();

return $result;
Expand All @@ -509,10 +537,16 @@ public function mkdir(string $path, int $mode, int $options): bool
public function rmdir(string $path, int $options): bool
{
$normalized = str_replace('\\', '/', $path);
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
unset(
self::$statCache[$normalized . ':stat'],
self::$statCache[$normalized . ':lstat'],
self::$staticNegativeStatCache[$normalized]
);

self::unregister();
$result = (bool) self::silent(fn () => @rmdir($path));
$result = ($this->context !== null)
? rmdir($path, $this->context)
: rmdir($path);
self::register();

return $result;
Expand All @@ -521,10 +555,16 @@ public function rmdir(string $path, int $options): bool
public function unlink(string $path): bool
{
$normalized = str_replace('\\', '/', $path);
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
unset(
self::$statCache[$normalized . ':stat'],
self::$statCache[$normalized . ':lstat'],
self::$staticNegativeStatCache[$normalized]
);

self::unregister();
$result = (bool) self::silent(fn () => @unlink($path));
$result = ($this->context !== null)
? unlink($path, $this->context)
: unlink($path);
self::register();

return $result;
Expand All @@ -535,14 +575,18 @@ public function rename(string $pathFrom, string $pathTo): bool
$normFrom = str_replace('\\', '/', $pathFrom);
$normTo = str_replace('\\', '/', $pathTo);
unset(
self::$statCache[$normFrom],
self::$statCache[$normTo],
self::$statCache[$normFrom . ':stat'],
self::$statCache[$normFrom . ':lstat'],
self::$staticNegativeStatCache[$normFrom],
self::$statCache[$normTo . ':stat'],
self::$statCache[$normTo . ':lstat'],
self::$staticNegativeStatCache[$normTo]
);

self::unregister();
$result = (bool) self::silent(fn () => @rename($pathFrom, $pathTo));
$result = ($this->context !== null)
? rename($pathFrom, $pathTo, $this->context)
: rename($pathFrom, $pathTo);
self::register();

return $result;
Expand Down Expand Up @@ -657,7 +701,9 @@ private function openCachedStream(string $resolvedPath, string $mode): bool
}
}

$cacheHandle = fopen($cachedFile, $mode);
$cacheHandle = ($this->context !== null)
? fopen($cachedFile, $mode, false, $this->context)
: fopen($cachedFile, $mode);
$this->handle = $cacheHandle !== false ? $cacheHandle : null;

return $this->handle !== null;
Expand Down
18 changes: 9 additions & 9 deletions src/Internal/Visitor/FunctionContractInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node):
$methodName = $isClassMethod ? strtolower($node->name->toString()) : '';
$isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true);

$isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void';
$isNativeNever = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'never';

$hasParam = self::hasParamContracts($docText, $isClassMethod);
$hasReturn = ! $isMagicLifecycle && self::hasReturnContracts($docText, $isClassMethod);
$hasReturn = ! $isMagicLifecycle && ! $isNativeNever && self::hasReturnContracts($docText, $isClassMethod);

if (! $hasParam && ! $hasReturn) {
return;
Expand Down Expand Up @@ -136,7 +139,7 @@ private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $n
return false;
}

$visitor = new class () extends NodeVisitorAbstract {
$visitor = new class() extends NodeVisitorAbstract {
public bool $isGen = false;

public function enterNode(Node $n): ?int
Expand Down Expand Up @@ -534,10 +537,8 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi
private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array
{
$traverser = new NodeTraverser();
$traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract {
public function __construct(private Node\Expr $thisArg)
{
}
$traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract {
public function __construct(private Node\Expr $thisArg) {}

public function enterNode(Node $n): int|Node|null
{
Expand Down Expand Up @@ -591,13 +592,12 @@ public function enterNode(Node $n): int|Node|null
private static function wrapNonGeneratorReturns(array $stmts, Node\Expr $thisArg, bool $isNativeVoid, bool $needsReturnVars = false): array
{
$traverser = new NodeTraverser();
$traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars) extends NodeVisitorAbstract {
$traverser->addVisitor(new class($thisArg, $isNativeVoid, $needsReturnVars) extends NodeVisitorAbstract {
public function __construct(
private Node\Expr $thisArg,
private bool $isNativeVoid,
private bool $needsReturnVars
) {
}
) {}

public function enterNode(Node $n): int|array|null
{
Expand Down
18 changes: 17 additions & 1 deletion src/Resolver/TemplateManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -622,11 +622,27 @@ private static function bindSingleTemplateArgument(
$templateName = $templateTag->name;
$existingBindings = self::$instanceTemplateBindings[$instance] ?? [];

if (isset($existingBindings[$templateName])) {
if (isset($existingBindings[$templateName])) {
$existingTypeNode = $existingBindings[$templateName];
$valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance);

if (! $valid) {
if ($existingTypeNode instanceof IdentifierTypeNode && strtolower($existingTypeNode->name) === 'mixed') {
$bindings = self::$instanceTemplateBindings[$instance] ?? [];
$bindings[$templateName] = $expectedTypeNode;
self::$instanceTemplateBindings[$instance] = $bindings;

return null;
}

if ($isReturnContext && self::checkVariance($expectedTypeNode, $existingTypeNode, GenericTypeNode::VARIANCE_COVARIANT)) {
$bindings = self::$instanceTemplateBindings[$instance] ?? [];
$bindings[$templateName] = $expectedTypeNode;
self::$instanceTemplateBindings[$instance] = $bindings;

return null;
}

return ErrorFactory::createError(
$context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given"
);
Expand Down
Loading
Loading