Skip to content

Commit 8fca19b

Browse files
authored
Improve performance by optimizing and memoizing hot paths (#36)
1 parent 8911a64 commit 8fca19b

9 files changed

Lines changed: 374 additions & 140 deletions

File tree

src/Contract/ContractParser.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
2121
use TypePHP\Internal\Config;
2222
use TypePHP\Resolver\SpecialTypeResolver;
23+
use TypePHP\Validator\TypeValidatorRegistry;
2324

2425
/**
2526
* @internal Main orchestrator parsing and caching PHPDoc contracts (@param, @return, @template, @phpstan-type, @var).
@@ -55,6 +56,9 @@ public static function reset(): void
5556
self::$cache = [];
5657
self::$propertyCache = [];
5758
self::$magicMethodCache = [];
59+
DocblockExtractor::reset();
60+
FileFilter::reset();
61+
TypeValidatorRegistry::reset();
5862
}
5963

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

708712
if ($node instanceof CallableTypeNode) {
709713
$parameters = array_map(
710-
fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode(
714+
fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode(
711715
self::substituteAliases($param->type, $aliases),
712716
$param->isReference,
713717
$param->isVariadic,
@@ -741,7 +745,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
741745
if ($node instanceof GenericTypeNode) {
742746
$genericType = self::substituteAliases($node->type, $aliases);
743747
$genericTypes = array_map(
744-
fn ($t) => self::substituteAliases($t, $aliases),
748+
fn($t) => self::substituteAliases($t, $aliases),
745749
$node->genericTypes
746750
);
747751

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

759763
if ($node instanceof UnionTypeNode) {
760764
return new UnionTypeNode(array_map(
761-
fn ($t) => self::substituteAliases($t, $aliases),
765+
fn($t) => self::substituteAliases($t, $aliases),
762766
$node->types
763767
));
764768
}
765769

766770
if ($node instanceof IntersectionTypeNode) {
767771
return new IntersectionTypeNode(array_map(
768-
fn ($t) => self::substituteAliases($t, $aliases),
772+
fn($t) => self::substituteAliases($t, $aliases),
769773
$node->types
770774
));
771775
}
@@ -794,4 +798,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
794798

795799
return $node;
796800
}
797-
}
801+
}

src/Contract/DocblockExtractor.php

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@
2626
*/
2727
final class DocblockExtractor
2828
{
29+
/**
30+
* In-memory cache for normalized and parsed PhpDocNode ASTs keyed by raw docblock string.
31+
*
32+
* @var array<string, PhpDocNode>
33+
*/
34+
private static array $docParseCache = [];
35+
36+
/**
37+
* Resets the parsed docblock cache. Useful for test isolation.
38+
*/
39+
public static function reset(): void
40+
{
41+
self::$docParseCache = [];
42+
}
43+
2944
/**
3045
* Returns shared static instances of PHPStan's PhpDocParser and Lexer.
3146
*
@@ -50,15 +65,19 @@ public static function getParserComponents(): array
5065
}
5166

5267
/**
53-
* Normalizes and parses a PHPDoc doccomment string into an AST PhpDocNode.
68+
* Normalizes and parses a PHPDoc doccomment string into an AST PhpDocNode with in-memory memoization.
5469
*/
5570
public static function parseDocString(string $doc): PhpDocNode
5671
{
57-
$doc = DocblockNormalizer::normalize($doc);
72+
if (isset(self::$docParseCache[$doc])) {
73+
return self::$docParseCache[$doc];
74+
}
75+
76+
$normalized = DocblockNormalizer::normalize($doc);
5877
[$phpDocParser, $lexer] = self::getParserComponents();
59-
$tokens = new TokenIterator($lexer->tokenize($doc));
78+
$tokens = new TokenIterator($lexer->tokenize($normalized));
6079

61-
return $phpDocParser->parse($tokens);
80+
return self::$docParseCache[$doc] = $phpDocParser->parse($tokens);
6281
}
6382

6483
/**
@@ -405,4 +424,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam
405424

406425
return null;
407426
}
408-
}
427+
}

src/Contract/FileFilter.php

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,43 @@
1212
*/
1313
final class FileFilter
1414
{
15+
/**
16+
* In-memory cache of boolean exclusion results keyed by normalized file path.
17+
*
18+
* @var array<string, bool>
19+
*/
20+
private static array $pathFilterCache = [];
21+
22+
/**
23+
* Pre-compiled include regex patterns and match lengths.
24+
*
25+
* @var array<int, array{len: int, regex: string}>|null
26+
*/
27+
private static ?array $compiledIncludes = null;
28+
29+
/**
30+
* Pre-compiled exclude regex patterns and match lengths.
31+
*
32+
* @var array<int, array{len: int, regex: string}>|null
33+
*/
34+
private static ?array $compiledExcludes = null;
35+
36+
/**
37+
* Cached normalized cache directory path.
38+
*/
39+
private static ?string $cachedCacheDir = null;
40+
41+
/**
42+
* Resets the path decision cache and pre-compiled regex patterns. Useful for test isolation.
43+
*/
44+
public static function reset(): void
45+
{
46+
self::$pathFilterCache = [];
47+
self::$compiledIncludes = null;
48+
self::$compiledExcludes = null;
49+
self::$cachedCacheDir = null;
50+
}
51+
1552
/**
1653
* Determines whether a given file path is excluded from contract inheritance.
1754
* Non-PHP files and excluded paths return true.
@@ -24,16 +61,54 @@ public static function isFileExcluded(string|false|null $fileName): bool
2461

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

64+
if (isset(self::$pathFilterCache[$normalizedPath])) {
65+
return self::$pathFilterCache[$normalizedPath];
66+
}
67+
2768
// Non-PHP files are always excluded from PHPDoc contract processing
2869
if (! str_ends_with(strtolower($normalizedPath), '.php')) {
29-
return true;
70+
return self::$pathFilterCache[$normalizedPath] = true;
3071
}
3172

32-
$normalizedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/';
33-
if (str_starts_with($normalizedPath, $normalizedCacheDir)) {
34-
return true;
73+
if (self::$cachedCacheDir === null) {
74+
self::$cachedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/';
3575
}
3676

77+
if (str_starts_with($normalizedPath, self::$cachedCacheDir)) {
78+
return self::$pathFilterCache[$normalizedPath] = true;
79+
}
80+
81+
if (self::$compiledIncludes === null || self::$compiledExcludes === null) {
82+
self::compilePatterns();
83+
}
84+
85+
$longestIncludeMatch = 0;
86+
/** @var array<int, array{len: int, regex: string}> $includes */
87+
$includes = self::$compiledIncludes;
88+
foreach ($includes as $compiled) {
89+
if (preg_match($compiled['regex'], $normalizedPath) === 1) {
90+
$longestIncludeMatch = max($longestIncludeMatch, $compiled['len']);
91+
}
92+
}
93+
94+
$longestExcludeMatch = 0;
95+
/** @var array<int, array{len: int, regex: string}> $excludes */
96+
$excludes = self::$compiledExcludes;
97+
foreach ($excludes as $compiled) {
98+
if (preg_match($compiled['regex'], $normalizedPath) === 1) {
99+
$longestExcludeMatch = max($longestExcludeMatch, $compiled['len']);
100+
}
101+
}
102+
103+
// Equal specificity tie-breaker: Exclude wins!
104+
return self::$pathFilterCache[$normalizedPath] = ($longestExcludeMatch >= $longestIncludeMatch);
105+
}
106+
107+
/**
108+
* Compiles configured include and exclude globs into regex patterns once per configuration lifecycle.
109+
*/
110+
private static function compilePatterns(): void
111+
{
37112
$config = Config::get();
38113
/** @var array<mixed> $includes */
39114
$includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**'];
@@ -42,30 +117,27 @@ public static function isFileExcluded(string|false|null $fileName): bool
42117

43118
$baseDir = Config::getProjectRoot();
44119

45-
$longestIncludeMatch = 0;
120+
self::$compiledIncludes = [];
46121
foreach ($includes as $pattern) {
47-
if (! \is_string($pattern)) {
48-
continue;
49-
}
50-
$regex = self::compileGlobToRegex($pattern, $baseDir);
51-
if (preg_match($regex, $normalizedPath) === 1) {
52-
$longestIncludeMatch = max($longestIncludeMatch, \strlen(trim($pattern)));
122+
if (\is_string($pattern)) {
123+
$trimmed = trim($pattern);
124+
self::$compiledIncludes[] = [
125+
'len' => \strlen($trimmed),
126+
'regex' => self::compileGlobToRegex($trimmed, $baseDir),
127+
];
53128
}
54129
}
55130

56-
$longestExcludeMatch = 0;
131+
self::$compiledExcludes = [];
57132
foreach ($excludes as $pattern) {
58-
if (! \is_string($pattern)) {
59-
continue;
60-
}
61-
$regex = self::compileGlobToRegex($pattern, $baseDir);
62-
if (preg_match($regex, $normalizedPath) === 1) {
63-
$longestExcludeMatch = max($longestExcludeMatch, \strlen(trim($pattern)));
133+
if (\is_string($pattern)) {
134+
$trimmed = trim($pattern);
135+
self::$compiledExcludes[] = [
136+
'len' => \strlen($trimmed),
137+
'regex' => self::compileGlobToRegex($trimmed, $baseDir),
138+
];
64139
}
65140
}
66-
67-
// Equal specificity tie-breaker: Exclude wins!
68-
return $longestExcludeMatch >= $longestIncludeMatch;
69141
}
70142

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

90162
return '#' . $pattern . '#i';
91163
}
92-
}
164+
}

src/Internal/Checker/ParamChecker.php

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public static function checkParams(
3535
object|string|null $thisOrClass,
3636
TypeValidatorRegistry $registry
3737
): ?ErrorMessage {
38-
if (! (bool) (Config::get()['params'] ?? true)) {
38+
if (! Config::isParamsEnabled()) {
3939
return null;
4040
}
4141

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

110110
if (\count($traitAliases) > 0) {
111-
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
112-
foreach ($trace as $frame) {
113-
$frameFunc = $frame['function'];
114-
$frameClass = $frame['class'] ?? '';
115-
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
116-
return $targetClass . '::' . $frameFunc;
111+
$isPotentialAlias = isset($traitAliases[$methodName]);
112+
if (! $isPotentialAlias) {
113+
foreach ($traitAliases as $originalTarget) {
114+
if (str_ends_with($originalTarget, '::' . $methodName)) {
115+
$isPotentialAlias = true;
116+
117+
break;
118+
}
119+
}
120+
}
121+
122+
if ($isPotentialAlias) {
123+
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
124+
foreach ($trace as $frame) {
125+
$frameFunc = $frame['function'];
126+
$frameClass = $frame['class'] ?? '';
127+
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
128+
return $targetClass . '::' . $frameFunc;
129+
}
117130
}
118131
}
119132
}
@@ -134,7 +147,7 @@ private static function handleMagicCall(
134147
TypeValidatorRegistry $registry
135148
): ?ErrorMessage {
136149
$isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic');
137-
if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) {
150+
if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) {
138151
return null;
139152
}
140153

@@ -164,9 +177,9 @@ private static function handleMagicCall(
164177
*/
165178
private static function initializeCallContext(string $effectiveFunction, ?object $thisObj, array $templates): void
166179
{
167-
if ($thisObj === null) {
180+
if ($thisObj === null && \count($templates) > 0) {
168181
TemplateManager::clearCallBindings($effectiveFunction, $templates);
169-
} elseif (str_contains($effectiveFunction, '::')) {
182+
} elseif ($thisObj !== null && str_contains($effectiveFunction, '::')) {
170183
$declaringClass = explode('::', $effectiveFunction, 2)[0];
171184
TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass);
172185
}

src/Internal/Checker/ReturnChecker.php

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public static function checkReturn(
3737
TypeValidatorRegistry $registry,
3838
callable $wrapIterableCallback
3939
): mixed {
40-
if (! (bool) (Config::get()['returns'] ?? true)) {
40+
if (! Config::isReturnsEnabled()) {
4141
return $value;
4242
}
4343

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

100100
if (\count($traitAliases) > 0) {
101-
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
102-
foreach ($trace as $frame) {
103-
$frameFunc = $frame['function'];
104-
$frameClass = $frame['class'] ?? '';
105-
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
106-
return $targetClass . '::' . $frameFunc;
101+
$isPotentialAlias = isset($traitAliases[$methodName]);
102+
if (! $isPotentialAlias) {
103+
foreach ($traitAliases as $originalTarget) {
104+
if (str_ends_with($originalTarget, '::' . $methodName)) {
105+
$isPotentialAlias = true;
106+
107+
break;
108+
}
109+
}
110+
}
111+
112+
if ($isPotentialAlias) {
113+
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
114+
foreach ($trace as $frame) {
115+
$frameFunc = $frame['function'];
116+
$frameClass = $frame['class'] ?? '';
117+
if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) {
118+
return $targetClass . '::' . $frameFunc;
119+
}
107120
}
108121
}
109122
}
@@ -126,7 +139,7 @@ private static function handleMagicReturn(
126139
callable $wrapIterableCallback
127140
): mixed {
128141
$isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic');
129-
if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) {
142+
if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) {
130143
return null;
131144
}
132145

0 commit comments

Comments
 (0)