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
14 changes: 9 additions & 5 deletions src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
use TypePHP\Internal\Config;
use TypePHP\Resolver\SpecialTypeResolver;
use TypePHP\Validator\TypeValidatorRegistry;

/**
* @internal Main orchestrator parsing and caching PHPDoc contracts (@param, @return, @template, @phpstan-type, @var).
Expand Down Expand Up @@ -55,6 +56,9 @@ public static function reset(): void
self::$cache = [];
self::$propertyCache = [];
self::$magicMethodCache = [];
DocblockExtractor::reset();
FileFilter::reset();
TypeValidatorRegistry::reset();
}

/**
Expand Down Expand Up @@ -707,7 +711,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo

if ($node instanceof CallableTypeNode) {
$parameters = array_map(
fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode(
fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode(
self::substituteAliases($param->type, $aliases),
$param->isReference,
$param->isVariadic,
Expand Down Expand Up @@ -741,7 +745,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
if ($node instanceof GenericTypeNode) {
$genericType = self::substituteAliases($node->type, $aliases);
$genericTypes = array_map(
fn ($t) => self::substituteAliases($t, $aliases),
fn($t) => self::substituteAliases($t, $aliases),
$node->genericTypes
);

Expand All @@ -758,14 +762,14 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo

if ($node instanceof UnionTypeNode) {
return new UnionTypeNode(array_map(
fn ($t) => self::substituteAliases($t, $aliases),
fn($t) => self::substituteAliases($t, $aliases),
$node->types
));
}

if ($node instanceof IntersectionTypeNode) {
return new IntersectionTypeNode(array_map(
fn ($t) => self::substituteAliases($t, $aliases),
fn($t) => self::substituteAliases($t, $aliases),
$node->types
));
}
Expand Down Expand Up @@ -794,4 +798,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo

return $node;
}
}
}
29 changes: 24 additions & 5 deletions src/Contract/DocblockExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@
*/
final class DocblockExtractor
{
/**
* In-memory cache for normalized and parsed PhpDocNode ASTs keyed by raw docblock string.
*
* @var array<string, PhpDocNode>
*/
private static array $docParseCache = [];

/**
* Resets the parsed docblock cache. Useful for test isolation.
*/
public static function reset(): void
{
self::$docParseCache = [];
}

/**
* Returns shared static instances of PHPStan's PhpDocParser and Lexer.
*
Expand All @@ -50,15 +65,19 @@ public static function getParserComponents(): array
}

/**
* Normalizes and parses a PHPDoc doccomment string into an AST PhpDocNode.
* Normalizes and parses a PHPDoc doccomment string into an AST PhpDocNode with in-memory memoization.
*/
public static function parseDocString(string $doc): PhpDocNode
{
$doc = DocblockNormalizer::normalize($doc);
if (isset(self::$docParseCache[$doc])) {
return self::$docParseCache[$doc];
}

$normalized = DocblockNormalizer::normalize($doc);
[$phpDocParser, $lexer] = self::getParserComponents();
$tokens = new TokenIterator($lexer->tokenize($doc));
$tokens = new TokenIterator($lexer->tokenize($normalized));

return $phpDocParser->parse($tokens);
return self::$docParseCache[$doc] = $phpDocParser->parse($tokens);
}

/**
Expand Down Expand Up @@ -405,4 +424,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam

return null;
}
}
}
116 changes: 94 additions & 22 deletions src/Contract/FileFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,43 @@
*/
final class FileFilter
{
/**
* In-memory cache of boolean exclusion results keyed by normalized file path.
*
* @var array<string, bool>
*/
private static array $pathFilterCache = [];

/**
* Pre-compiled include regex patterns and match lengths.
*
* @var array<int, array{len: int, regex: string}>|null
*/
private static ?array $compiledIncludes = null;

/**
* Pre-compiled exclude regex patterns and match lengths.
*
* @var array<int, array{len: int, regex: string}>|null
*/
private static ?array $compiledExcludes = null;

/**
* Cached normalized cache directory path.
*/
private static ?string $cachedCacheDir = null;

/**
* Resets the path decision cache and pre-compiled regex patterns. Useful for test isolation.
*/
public static function reset(): void
{
self::$pathFilterCache = [];
self::$compiledIncludes = null;
self::$compiledExcludes = null;
self::$cachedCacheDir = null;
}

/**
* Determines whether a given file path is excluded from contract inheritance.
* Non-PHP files and excluded paths return true.
Expand All @@ -24,16 +61,54 @@ public static function isFileExcluded(string|false|null $fileName): bool

$normalizedPath = str_replace('\\', '/', $fileName);

if (isset(self::$pathFilterCache[$normalizedPath])) {
return self::$pathFilterCache[$normalizedPath];
}

// Non-PHP files are always excluded from PHPDoc contract processing
if (! str_ends_with(strtolower($normalizedPath), '.php')) {
return true;
return self::$pathFilterCache[$normalizedPath] = true;
}

$normalizedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/';
if (str_starts_with($normalizedPath, $normalizedCacheDir)) {
return true;
if (self::$cachedCacheDir === null) {
self::$cachedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/';
}

if (str_starts_with($normalizedPath, self::$cachedCacheDir)) {
return self::$pathFilterCache[$normalizedPath] = true;
}

if (self::$compiledIncludes === null || self::$compiledExcludes === null) {
self::compilePatterns();
}

$longestIncludeMatch = 0;
/** @var array<int, array{len: int, regex: string}> $includes */
$includes = self::$compiledIncludes;
foreach ($includes as $compiled) {
if (preg_match($compiled['regex'], $normalizedPath) === 1) {
$longestIncludeMatch = max($longestIncludeMatch, $compiled['len']);
}
}

$longestExcludeMatch = 0;
/** @var array<int, array{len: int, regex: string}> $excludes */
$excludes = self::$compiledExcludes;
foreach ($excludes as $compiled) {
if (preg_match($compiled['regex'], $normalizedPath) === 1) {
$longestExcludeMatch = max($longestExcludeMatch, $compiled['len']);
}
}

// Equal specificity tie-breaker: Exclude wins!
return self::$pathFilterCache[$normalizedPath] = ($longestExcludeMatch >= $longestIncludeMatch);
}

/**
* Compiles configured include and exclude globs into regex patterns once per configuration lifecycle.
*/
private static function compilePatterns(): void
{
$config = Config::get();
/** @var array<mixed> $includes */
$includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**'];
Expand All @@ -42,30 +117,27 @@ public static function isFileExcluded(string|false|null $fileName): bool

$baseDir = Config::getProjectRoot();

$longestIncludeMatch = 0;
self::$compiledIncludes = [];
foreach ($includes as $pattern) {
if (! \is_string($pattern)) {
continue;
}
$regex = self::compileGlobToRegex($pattern, $baseDir);
if (preg_match($regex, $normalizedPath) === 1) {
$longestIncludeMatch = max($longestIncludeMatch, \strlen(trim($pattern)));
if (\is_string($pattern)) {
$trimmed = trim($pattern);
self::$compiledIncludes[] = [
'len' => \strlen($trimmed),
'regex' => self::compileGlobToRegex($trimmed, $baseDir),
];
}
}

$longestExcludeMatch = 0;
self::$compiledExcludes = [];
foreach ($excludes as $pattern) {
if (! \is_string($pattern)) {
continue;
}
$regex = self::compileGlobToRegex($pattern, $baseDir);
if (preg_match($regex, $normalizedPath) === 1) {
$longestExcludeMatch = max($longestExcludeMatch, \strlen(trim($pattern)));
if (\is_string($pattern)) {
$trimmed = trim($pattern);
self::$compiledExcludes[] = [
'len' => \strlen($trimmed),
'regex' => self::compileGlobToRegex($trimmed, $baseDir),
];
}
}

// Equal specificity tie-breaker: Exclude wins!
return $longestExcludeMatch >= $longestIncludeMatch;
}

/**
Expand All @@ -89,4 +161,4 @@ private static function compileGlobToRegex(string $glob, string $baseDir): strin

return '#' . $pattern . '#i';
}
}
}
33 changes: 23 additions & 10 deletions src/Internal/Checker/ParamChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static function checkParams(
object|string|null $thisOrClass,
TypeValidatorRegistry $registry
): ?ErrorMessage {
if (! (bool) (Config::get()['params'] ?? true)) {
if (! Config::isParamsEnabled()) {
return null;
}

Expand Down Expand Up @@ -108,12 +108,25 @@ private static function resolveEffectiveFunction(string $function, object|string
$traitAliases = HierarchyResolver::getTraitAliases($targetClass);

if (\count($traitAliases) > 0) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
foreach ($trace as $frame) {
$frameFunc = $frame['function'];
$frameClass = $frame['class'] ?? '';
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
return $targetClass . '::' . $frameFunc;
$isPotentialAlias = isset($traitAliases[$methodName]);
if (! $isPotentialAlias) {
foreach ($traitAliases as $originalTarget) {
if (str_ends_with($originalTarget, '::' . $methodName)) {
$isPotentialAlias = true;

break;
}
}
}

if ($isPotentialAlias) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
foreach ($trace as $frame) {
$frameFunc = $frame['function'];
$frameClass = $frame['class'] ?? '';
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
return $targetClass . '::' . $frameFunc;
}
}
}
}
Expand All @@ -134,7 +147,7 @@ private static function handleMagicCall(
TypeValidatorRegistry $registry
): ?ErrorMessage {
$isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic');
if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) {
if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) {
return null;
}

Expand Down Expand Up @@ -164,9 +177,9 @@ private static function handleMagicCall(
*/
private static function initializeCallContext(string $effectiveFunction, ?object $thisObj, array $templates): void
{
if ($thisObj === null) {
if ($thisObj === null && \count($templates) > 0) {
TemplateManager::clearCallBindings($effectiveFunction, $templates);
} elseif (str_contains($effectiveFunction, '::')) {
} elseif ($thisObj !== null && str_contains($effectiveFunction, '::')) {
$declaringClass = explode('::', $effectiveFunction, 2)[0];
TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass);
}
Expand Down
29 changes: 21 additions & 8 deletions src/Internal/Checker/ReturnChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static function checkReturn(
TypeValidatorRegistry $registry,
callable $wrapIterableCallback
): mixed {
if (! (bool) (Config::get()['returns'] ?? true)) {
if (! Config::isReturnsEnabled()) {
return $value;
}

Expand Down Expand Up @@ -98,12 +98,25 @@ private static function resolveEffectiveFunction(string $function, object|string
$traitAliases = HierarchyResolver::getTraitAliases($targetClass);

if (\count($traitAliases) > 0) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
foreach ($trace as $frame) {
$frameFunc = $frame['function'];
$frameClass = $frame['class'] ?? '';
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
return $targetClass . '::' . $frameFunc;
$isPotentialAlias = isset($traitAliases[$methodName]);
if (! $isPotentialAlias) {
foreach ($traitAliases as $originalTarget) {
if (str_ends_with($originalTarget, '::' . $methodName)) {
$isPotentialAlias = true;

break;
}
}
}

if ($isPotentialAlias) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
foreach ($trace as $frame) {
$frameFunc = $frame['function'];
$frameClass = $frame['class'] ?? '';
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
return $targetClass . '::' . $frameFunc;
}
}
}
}
Expand All @@ -126,7 +139,7 @@ private static function handleMagicReturn(
callable $wrapIterableCallback
): mixed {
$isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic');
if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) {
if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) {
return null;
}

Expand Down
Loading
Loading