44
55use PhpParser \Node ;
66use PHPStan \File \FileReader ;
7- use function array_key_first ;
7+ use PHPStan \Internal \LruCache ;
8+ use function clearstatcache ;
9+ use function filemtime ;
10+ use function filesize ;
811use function strlen ;
912
1013final class CachedParser implements Parser
1114{
1215
1316 /**
14- * Size-based eviction never shrinks the cache below this many entries.
17+ * Size-based eviction never shrinks the AST cache below this many entries.
1518 * Without this floor, a single source larger than $cachedSourceBytesMax
1619 * would flush the whole cache on every insertion, degenerating the cache
1720 * to a single entry whenever such a source is hot.
@@ -26,16 +29,24 @@ final class CachedParser implements Parser
2629 */
2730 private const CACHED_SOURCE_BYTES_DEFAULT_LIMIT = 4_194_304 ;
2831
29- /** @var array<string, Node\Stmt[]>*/
30- private array $ cachedNodesByString = [];
31-
32- private int $ cachedNodesByStringCount = 0 ;
32+ /**
33+ * parseFile() is called once per class using a trait, so the same file
34+ * is read from disk over and over (one hot trait file was read 94,000
35+ * times in a cold run of a large Laravel project). Memoizing the contents
36+ * by path skips those redundant reads; the total memoized source size is
37+ * bounded the same way as the AST cache.
38+ */
39+ private const MEMOIZED_SOURCE_BYTES_LIMIT = 524_288 ;
3340
34- private int $ cachedSourceBytes = 0 ;
41+ /** @var LruCache<Node\Stmt[]> keyed by source code, weighing the length of that source */
42+ private LruCache $ cachedNodesByString ;
3543
3644 /** @var array<string, true> */
3745 private array $ parsedByString = [];
3846
47+ /** @var LruCache<array{int, int, string}> path => [mtime, size, source code] */
48+ private LruCache $ cachedSourceByFile ;
49+
3950 /**
4051 * The AST of a parsed file takes up roughly 50-60x more memory than the
4152 * source code itself, so alongside the entry count limit, the total source
@@ -51,6 +62,12 @@ public function __construct(
5162 private int $ cachedSourceBytesMax = self ::CACHED_SOURCE_BYTES_DEFAULT_LIMIT ,
5263 )
5364 {
65+ $ this ->cachedNodesByString = new LruCache (
66+ $ this ->cachedNodesByStringCountMax ,
67+ $ this ->cachedSourceBytesMax ,
68+ self ::SIZE_EVICTION_FLOOR_LIMIT ,
69+ );
70+ $ this ->cachedSourceByFile = new LruCache (maxWeight: self ::MEMOIZED_SOURCE_BYTES_LIMIT );
5471 }
5572
5673 /**
@@ -59,24 +76,23 @@ public function __construct(
5976 */
6077 public function parseFile (string $ file ): array
6178 {
62- $ sourceCode = FileReader :: read ($ file );
63- $ isCached = isset ( $ this ->cachedNodesByString [ $ sourceCode] );
64- if ($ isCached && !isset ($ this ->parsedByString [$ sourceCode ])) {
65- return $ this -> markRecentlyUsed ( $ sourceCode ) ;
79+ $ sourceCode = $ this -> readFile ($ file );
80+ $ cachedNodes = $ this ->cachedNodesByString -> get ( $ sourceCode );
81+ if ($ cachedNodes !== null && !isset ($ this ->parsedByString [$ sourceCode ])) {
82+ return $ cachedNodes ;
6683 }
6784
6885 $ nodes = $ this ->originalParser ->parseFile ($ file );
69- if ($ isCached ) {
86+ if ($ cachedNodes !== null ) {
7087 // upgrade an entry previously produced by parseString() in place -
7188 // no net change to the entry count, just refresh its LRU position
72- unset($ this ->cachedNodesByString [$ sourceCode ], $ this ->parsedByString [$ sourceCode ]);
73- } else {
74- $ this ->evictLeastRecentlyUsed (strlen ($ sourceCode ));
75- $ this ->cachedNodesByStringCount ++;
76- $ this ->cachedSourceBytes += strlen ($ sourceCode );
89+ $ this ->cachedNodesByString ->replace ($ sourceCode , $ nodes );
90+ unset($ this ->parsedByString [$ sourceCode ]);
91+
92+ return $ nodes ;
7793 }
7894
79- $ this ->cachedNodesByString [ $ sourceCode] = $ nodes ;
95+ $ this ->store ( $ sourceCode, $ nodes) ;
8096
8197 return $ nodes ;
8298 }
@@ -86,63 +102,59 @@ public function parseFile(string $file): array
86102 */
87103 public function parseString (string $ sourceCode ): array
88104 {
89- if (isset ($ this ->cachedNodesByString [$ sourceCode ])) {
90- return $ this ->markRecentlyUsed ($ sourceCode );
105+ $ cachedNodes = $ this ->cachedNodesByString ->get ($ sourceCode );
106+ if ($ cachedNodes !== null ) {
107+ return $ cachedNodes ;
91108 }
92109
93110 $ nodes = $ this ->originalParser ->parseString ($ sourceCode );
94- $ this ->evictLeastRecentlyUsed (strlen ($ sourceCode ));
95- $ this ->cachedNodesByString [$ sourceCode ] = $ nodes ;
96- $ this ->cachedNodesByStringCount ++;
97- $ this ->cachedSourceBytes += strlen ($ sourceCode );
111+ $ this ->store ($ sourceCode , $ nodes );
98112 $ this ->parsedByString [$ sourceCode ] = true ;
99113
100114 return $ nodes ;
101115 }
102116
103117 /**
104- * LRU bookkeeping: re-insert the entry at the end so genuinely cold sources
105- * are evicted first, not the ones inserted earliest.
106- *
107- * @return Node\Stmt[]
118+ * @param Node\Stmt[] $nodes
108119 */
109- private function markRecentlyUsed (string $ sourceCode ): array
120+ private function store (string $ sourceCode, array $ nodes ): void
110121 {
111- $ nodes = $ this ->cachedNodesByString [$ sourceCode ];
112- unset($ this ->cachedNodesByString [$ sourceCode ]);
113- $ this ->cachedNodesByString [$ sourceCode ] = $ nodes ;
114-
115- return $ nodes ;
122+ foreach ($ this ->cachedNodesByString ->set ($ sourceCode , $ nodes , strlen ($ sourceCode )) as $ evictedSourceCode ) {
123+ unset($ this ->parsedByString [$ evictedSourceCode ]);
124+ }
116125 }
117126
118- private function evictLeastRecentlyUsed ( int $ incomingSourceBytes ): void
127+ private function readFile ( string $ file ): string
119128 {
120- if ($ this ->cachedNodesByStringCountMax === 0 ) {
121- return ;
129+ // mtime alone has one-second granularity, so a same-second edit could be
130+ // served stale in a long-running process (PHPStan Pro, fixer worker);
131+ // keying by size as well catches edits that change the length. filesize()
132+ // is served from PHP's stat cache populated by filemtime(), so it costs
133+ // no extra syscall.
134+ clearstatcache (true , $ file );
135+ $ mtime = @filemtime ($ file );
136+ $ size = @filesize ($ file );
137+ if ($ mtime === false || $ size === false ) {
138+ return FileReader ::read ($ file );
122139 }
123140
124- while (
125- $ this ->cachedNodesByStringCount >= $ this ->cachedNodesByStringCountMax
126- || (
127- $ this ->cachedSourceBytesMax > 0
128- && $ this ->cachedSourceBytes + $ incomingSourceBytes > $ this ->cachedSourceBytesMax
129- && $ this ->cachedNodesByStringCount > self ::SIZE_EVICTION_FLOOR_LIMIT
130- )
131- ) {
132- $ oldestKey = array_key_first ($ this ->cachedNodesByString );
133- if ($ oldestKey === null ) {
134- break ;
135- }
136-
137- unset($ this ->cachedNodesByString [$ oldestKey ], $ this ->parsedByString [$ oldestKey ]);
138- $ this ->cachedNodesByStringCount --;
139- $ this ->cachedSourceBytes -= strlen ($ oldestKey );
141+ $ cached = $ this ->cachedSourceByFile ->get ($ file );
142+ if ($ cached !== null && $ cached [0 ] === $ mtime && $ cached [1 ] === $ size ) {
143+ return $ cached [2 ];
140144 }
145+
146+ $ sourceCode = FileReader ::read ($ file );
147+ if (strlen ($ sourceCode ) <= self ::MEMOIZED_SOURCE_BYTES_LIMIT ) {
148+ // set() replaces a stale entry for this path (mtime or size changed) as well
149+ $ this ->cachedSourceByFile ->set ($ file , [$ mtime , $ size , $ sourceCode ], strlen ($ sourceCode ));
150+ }
151+
152+ return $ sourceCode ;
141153 }
142154
143155 public function getCachedNodesByStringCount (): int
144156 {
145- return $ this ->cachedNodesByStringCount ;
157+ return $ this ->cachedNodesByString -> count () ;
146158 }
147159
148160 public function getCachedNodesByStringCountMax (): int
@@ -155,7 +167,7 @@ public function getCachedNodesByStringCountMax(): int
155167 */
156168 public function getCachedNodesByString (): array
157169 {
158- return $ this ->cachedNodesByString ;
170+ return $ this ->cachedNodesByString -> all () ;
159171 }
160172
161173}
0 commit comments