diff --git a/CssSelectorConverter.php b/CssSelectorConverter.php index 7120a29..a90cb8d 100644 --- a/CssSelectorConverter.php +++ b/CssSelectorConverter.php @@ -26,6 +26,8 @@ */ class CssSelectorConverter { + public static int $maxCachedItems = 1024; + private Translator $translator; private array $cache; @@ -62,6 +64,21 @@ public function __construct(bool $html = true) */ public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string { - return $this->cache[$prefix][$cssExpr] ??= $this->translator->cssToXPath($cssExpr, $prefix); + $cacheKey = $prefix."\0".$cssExpr; + + if (isset($this->cache[$cacheKey])) { + // Move the item last in cache (LRU) + $value = $this->cache[$cacheKey]; + unset($this->cache[$cacheKey]); + + return $this->cache[$cacheKey] = $value; + } + + if (\count($this->cache) >= self::$maxCachedItems) { + // Evict the oldest entry + unset($this->cache[array_key_first($this->cache)]); + } + + return $this->cache[$cacheKey] = $this->translator->cssToXPath($cssExpr, $prefix); } } diff --git a/Tests/CssSelectorConverterTest.php b/Tests/CssSelectorConverterTest.php index 1d4dbb7..26707cd 100644 --- a/Tests/CssSelectorConverterTest.php +++ b/Tests/CssSelectorConverterTest.php @@ -52,6 +52,37 @@ public function testParseExceptions() (new CssSelectorConverter())->toXPath('h1:'); } + public function testLruCacheMovesRecentlyUsedToEnd() + { + CssSelectorConverter::$maxCachedItems = 5; + $htmlCacheProperty = new \ReflectionProperty(CssSelectorConverter::class, 'htmlCache'); + $htmlCacheProperty->setValue(null, []); + + $converter = new CssSelectorConverter(true); + + // Fill cache with 5 entries (h0-h4) + for ($i = 0; $i < 5; ++$i) { + $converter->toXPath("h$i"); + } + + // Access h0 to move it to end (most recently used) + $converter->toXPath('h0'); + + // Trigger eviction + $converter->toXPath('h5'); + + $cache = $htmlCacheProperty->getValue(); + + // h0 was accessed recently (moved to end), so it survives eviction + $this->assertArrayHasKey("descendant-or-self::\0h0", $cache); + // h5 is the newest entry + $this->assertArrayHasKey("descendant-or-self::\0h5", $cache); + // h1 was the oldest untouched entry, should be evicted + $this->assertArrayNotHasKey("descendant-or-self::\0h1", $cache); + + CssSelectorConverter::$maxCachedItems = 1024; + } + #[DataProvider('getCssToXPathWithoutPrefixTestData')] public function testCssToXPathWithoutPrefix($css, $xpath) {