diff --git a/.gitignore b/.gitignore index 3c36179..d21f1fc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,10 @@ build composer.lock vendor .idea +.vscode .php-cs-fixer.cache .phpunit.result.cache -cache +.phpspellcheck.cache +composer/ +.DS_Store .aider* diff --git a/composer.json b/composer.json index d1c4c88..1e55e70 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "php": "^8.2", "nyholm/psr7": "^1.3", "psr/http-client": "^1.0", + "psr/cache": "^3.0", "symfony/process": "^6.4 | ^7", "webmozart/assert": "^1.11" }, @@ -50,7 +51,10 @@ "psr-4": { "PhpSpellcheck\\": "src" }, - "files": [ "src/Text/functions.php" , "src/Utils/php-functions.php" ] + "files": [ + "src/Text/functions.php", + "src/Utils/php-functions.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/docker-compose.yml b/docker-compose.yml index 5fe6fe3..053bcd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: PHP_VERSION: ${PHP_VERSION:-8.4} volumes: - .:/usr/src/myapp - - ./cache:/root/composer/cache + - ./composer/cache:/root/composer/cache environment: - LANG=en_US.UTF-8 - COMPOSER_CACHE_DIR=/root/composer/cache diff --git a/src/Cache/CacheItem.php b/src/Cache/CacheItem.php new file mode 100644 index 0000000..1ef43c0 --- /dev/null +++ b/src/Cache/CacheItem.php @@ -0,0 +1,76 @@ +key; + } + + public function get(): mixed + { + return $this->value; + } + + public function isHit(): bool + { + return $this->isHit; + } + + public function set(mixed $value): static + { + $this->value = $value; + + return $this; + } + + public function expiresAt(?DateTimeInterface $expiration): static + { + $this->expiry = $expiration; + + return $this; + } + + public function expiresAfter(DateInterval|int|null $time): static + { + if ($time === null) { + $this->expiry = null; + + return $this; + } + + if (\is_int($time)) { + $this->expiry = new \DateTime('@' . (time() + $time)); + + return $this; + } + + $datetime = new \DateTime(); + $datetime->add($time); + + $this->expiry = $datetime; + + return $this; + } + + public function setIsHit(bool $hit): void + { + $this->isHit = $hit; + } +} diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php new file mode 100644 index 0000000..5125376 --- /dev/null +++ b/src/Cache/FileCache.php @@ -0,0 +1,226 @@ + + */ + private array $deferred = []; + + /** + * $namespace - The namespace of the cache (e.g., 'Aspell' creates .phpspellcache.cache/Aspell/*) + * $defaultLifetime - The default lifetime in seconds for cached items (0 = never expires) + * $directory - Optional custom directory path for cache storage. + */ + public function __construct( + private readonly string $namespace = '@', + private readonly int $defaultLifetime = 0, + private ?string $directory = null, + ) { + if ($directory === null) { + $directory = $this->getDefaultDirectory(); + } + + $this->validateNamespace(); + + $directory .= DIRECTORY_SEPARATOR . $namespace; + + if (!is_dir($directory) && !@mkdir($directory, 0o777, true) && !is_dir($directory)) { + throw new RuntimeException(\sprintf('Directory "%s" could not be created', $directory)); + } + + $this->directory = $directory .= DIRECTORY_SEPARATOR; + } + + public static function create( + string $namespace = '@', + int $defaultLifetime = 0, + ?string $directory = null + ): self { + return new self($namespace, $defaultLifetime, $directory); + } + + public function getItem(string $key): CacheItemInterface + { + $this->validateKey($key); + $filepath = $this->getFilePath($key); + + $item = new CacheItem($key); + + if (!file_exists($filepath)) { + return $item; + } + + $data = \PhpSpellcheck\file_get_contents($filepath); + + if ($data === '') { + return $item; + } + + $value = unserialize($data); + + if (!\is_object($value) + || !property_exists($value, 'data') + || !property_exists($value, 'expiresAt') + ) { + return $item; + } + + if ($value->expiresAt !== 0 + && $value->expiresAt !== null + && $value->expiresAt <= time() + ) { + unlink($filepath); + + return $item; + } + + $item->set($value->data)->setIsHit(true); + + if (\is_int($value->expiresAt) && $value->expiresAt > 0) { + $item->expiresAt(new \DateTime('@' . $value->expiresAt)); + } + + return $item; + } + + /** + * @param array $keys + * + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + return array_map(fn ($key): CacheItemInterface => $this->getItem($key), $keys); + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function clear(): bool + { + $this->deferred = []; + $files = glob($this->directory.'*'); + + if ($files === false || empty($files)) { + return false; + } + + $result = true; + foreach ($files as $file) { + $result = unlink($file) && $result; + } + + return $result; + } + + public function deleteItem(string $key): bool + { + $this->validateKey($key); + unset($this->deferred[$key]); + + if (!file_exists($this->getFilePath($key))) { + return true; + } + + return unlink($this->getFilePath($key)); + } + + public function deleteItems(array $keys): bool + { + $result = true; + foreach ($keys as $key) { + $result = $this->deleteItem($key) && $result; + } + + return $result; + } + + public function save(CacheItemInterface $item): bool + { + $this->validateKey($item->getKey()); + + if (!property_exists($item, 'expiry')) { + throw new InvalidArgumentException('CacheItem expiry property is required'); + } + + $expiresAt = match(true) { + $item->expiry instanceof \DateTimeInterface => $item->expiry->getTimestamp(), + $this->defaultLifetime > 0 => time() + $this->defaultLifetime, + default => null + }; + + $value = (object) [ + 'data' => $item->get(), + 'expiresAt' => $expiresAt, + ]; + + $serialized = serialize($value); + $filepath = $this->getFilePath($item->getKey()); + + try { + return (bool) \PhpSpellcheck\file_put_contents($filepath, $serialized, LOCK_EX); + } catch (\Exception $e) { + return false; + } + } + + public function saveDeferred(CacheItemInterface $item): bool + { + $this->validateKey($item->getKey()); + $this->deferred[$item->getKey()] = $item; + + return true; + } + + public function commit(): bool + { + $success = true; + foreach ($this->deferred as $item) { + $success = $this->save($item) && $success; + } + $this->deferred = []; + + return $success; + } + + public function getFilePath(string $key): string + { + return $this->directory . $key; + } + + private function getDefaultDirectory(): string + { + return \dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; + } + + private function validateNamespace(): void + { + if (\PhpSpellcheck\preg_match('#[^-+_.A-Za-z0-9]#', $this->namespace, $match) === 1) { + throw new InvalidArgumentException(\sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); + } + } + + private function validateKey(string $key): void + { + if (\PhpSpellcheck\preg_match('/^[a-zA-Z0-9_\.]+$/', $key) === 0) { + throw new InvalidArgumentException( + \sprintf( + 'Invalid cache key "%s". A cache key can only contain letters (a-z, A-Z), numbers (0-9), underscores (_), and periods (.).', + $key + ) + ); + } + } +} diff --git a/src/Cache/FileCacheInterface.php b/src/Cache/FileCacheInterface.php new file mode 100644 index 0000000..2175270 --- /dev/null +++ b/src/Cache/FileCacheInterface.php @@ -0,0 +1,12 @@ +spellchecker, $text, $languages, $context])); + + $cacheItem = $this->cache->getItem($cacheKey); + + if ($cacheItem->isHit()) { + foreach ((array) $cacheItem->get() as $misspelling) { + if ($misspelling instanceof MisspellingInterface) { + yield $misspelling; + } + } + + return; + } + + $misspellings = iterator_to_array($this->spellchecker->check($text, $languages, $context)); + $this->cache->save($cacheItem->set($misspellings)); + + yield from $misspellings; + } + + public function getSupportedLanguages(): iterable + { + $cacheKey = md5(serialize([$this->spellchecker])); + + $cacheItem = $this->cache->getItem($cacheKey); + + if ($cacheItem->isHit()) { + foreach ((array) $cacheItem->get() as $language) { + if (\is_string($language)) { + yield $language; + } + } + + return; + } + + $languages = iterator_to_array($this->spellchecker->getSupportedLanguages()); + $this->cache->save($cacheItem->set($languages)); + + yield from $languages; + } +} diff --git a/tests/Cache/FileCacheTest.php b/tests/Cache/FileCacheTest.php new file mode 100644 index 0000000..f4c95b9 --- /dev/null +++ b/tests/Cache/FileCacheTest.php @@ -0,0 +1,173 @@ +cache = new FileCache('FileCacheTest'); + $this->cache->clear(); + } + + public function tearDown(): void + { + $this->cache->clear(); + } + + public function testCreateReturnsFileCacheInstance(): void + { + $cache = FileCache::create('FileCacheTest'); + $this->assertInstanceOf(FileCache::class, $cache); + } + + public function testGetItemReturnsNonExistentItem(): void + { + $item = $this->cache->getItem('key1'); + $this->assertFalse($item->isHit()); + $this->assertNull($item->get()); + } + + public function testSaveAndGetItem(): void + { + $item = $this->cache->getItem('key2'); + $item->set('value2'); + + $this->cache->save($item); + + $newItem = $this->cache->getItem('key2'); + + $this->assertTrue($newItem->isHit()); + $this->assertEquals('value2', $newItem->get()); + } + + public function testDeleteItem(): void + { + $item = $this->cache->getItem('key3'); + $item->set('value3'); + $this->cache->save($item); + + $this->assertTrue($this->cache->deleteItem('key3')); + $this->assertFalse($this->cache->hasItem('key3')); + } + + public function testSaveDeferred(): void + { + $item = $this->cache->getItem('key4'); + $item->set('value4'); + + $this->cache->saveDeferred($item); + $this->assertFalse($this->cache->hasItem('key4')); + + $this->cache->commit(); + $this->assertTrue($this->cache->hasItem('key4')); + } + + public function testClearCache(): void + { + $item1 = $this->cache->getItem('key5'); + $item1->set('value5'); + $this->cache->save($item1); + + $item2 = $this->cache->getItem('key6'); + $item2->set('value6'); + $this->cache->save($item2); + + $this->assertTrue($this->cache->clear()); + $this->assertFalse($this->cache->hasItem('key5')); + $this->assertFalse($this->cache->hasItem('key6')); + } + + public function testGetItems(): void + { + $keys = ['key7', 'key8']; + $items = $this->cache->getItems($keys); + + foreach ($items as $item) { + $this->assertFalse($item->isHit()); + } + } + + public function testDeleteItems(): void + { + $item1 = $this->cache->getItem('key9'); + $item1->set('value9'); + $this->cache->save($item1); + + $item2 = $this->cache->getItem('key10'); + $item2->set('value10'); + $this->cache->save($item2); + + $this->assertTrue($this->cache->deleteItems(['key9', 'key10'])); + $this->assertFalse($this->cache->hasItem('key9')); + $this->assertFalse($this->cache->hasItem('key10')); + } + + public function testItemExpiration(): void + { + $item = $this->cache->getItem('expiring_key'); + $item->set('expiring_value'); + $item->expiresAt(new DateTime('+1 second')); + $this->cache->save($item); + + $this->assertTrue($this->cache->hasItem('expiring_key')); + sleep(2); + $this->assertFalse($this->cache->getItem('expiring_key')->isHit()); + } + + public function testInvalidNamespaceThrowsException(): void + { + $this->expectException(\PhpSpellcheck\Exception\InvalidArgumentException::class); + new FileCache('Invalid/Namespace'); + } + + public function testInvalidKeyThrowsException(): void + { + $this->expectException(\PhpSpellcheck\Exception\InvalidArgumentException::class); + $this->cache->getItem('invalid/key'); + } + + public function testDefaultLifetime(): void + { + $cache = new FileCache('FileCacheTest', 1); + $item = $cache->getItem('key'); + $item->set('value'); + $cache->save($item); + + $this->assertTrue($cache->hasItem('key')); + sleep(2); + $this->assertFalse($cache->getItem('key')->isHit()); + } + + public function testCustomDirectory(): void + { + $cache = new FileCache('FileCacheTest', 0, '/tmp'); + $item = $cache->getItem('key'); + $item->set('value'); + $cache->save($item); + + $this->assertFileExists('/tmp/FileCacheTest/key'); + $cache->clear(); + } + + public function testExpiredCachedFileIsDeletedWhenCallingGetItem(): void + { + $cache = new FileCache('FileCacheTest', 1, '/tmp'); + $item = $cache->getItem('unlinked_key'); + $item->set('value'); + $item->expiresAfter(1); + $cache->save($item); + + sleep(2); + + $cache->getItem('unlinked_key'); + + $this->assertFileDoesNotExist('/tmp/FileCacheTest/unlinked_key'); + } +} diff --git a/tests/Spellchecker/CacheableSpellcheckerTest.php b/tests/Spellchecker/CacheableSpellcheckerTest.php new file mode 100644 index 0000000..c9517ee --- /dev/null +++ b/tests/Spellchecker/CacheableSpellcheckerTest.php @@ -0,0 +1,60 @@ +cache = new FileCache('CacheableSpellcheckerTest'); + $this->cache->clear(); + + $spellchecker = Aspell::create(self::FAKE_BINARIES_PATH); + $this->cacheableSpellchecker = new CacheableSpellchecker($this->cache, $spellchecker); + } + + public function tearDown(): void + { + $this->cache->clear(); + } + + public function testCheckReturnsFromCache(): void + { + $text = 'testt speling'; + $result1 = iterator_to_array($this->cacheableSpellchecker->check($text)); + $result2 = iterator_to_array($this->cacheableSpellchecker->check($text)); + + $this->assertEquals($result1, $result2); + } + + public function testGetSupportedLanguagesReturnsFromCache(): void + { + $langs1 = iterator_to_array($this->cacheableSpellchecker->getSupportedLanguages()); + $langs2 = iterator_to_array($this->cacheableSpellchecker->getSupportedLanguages()); + + $this->assertSame($langs1, $langs2); + } + + public function testCheckWithDifferentParameters(): void + { + $text = 'testt speling'; + $result1 = iterator_to_array($this->cacheableSpellchecker->check($text, ['en_US'])); + $result2 = iterator_to_array($this->cacheableSpellchecker->check($text, ['en_GB'])); + + foreach ($result1 as $misspelling) { + $this->assertNotSame($misspelling, $result2); + } + } +}