From 5c3b13a688a9d5fab26e4398149f63731605d7d9 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Thu, 9 Jan 2025 18:01:43 +0000 Subject: [PATCH 01/22] feature/initial-cache-implementation - initial implementation with base tests --- .gitignore | 4 +- docker-compose.yml | 4 +- src/Cache/Cache.php | 45 +++++++++++++++++++++ src/Cache/CacheFactoryInterface.php | 12 ++++++ src/Cache/Stores/FileStore.php | 59 ++++++++++++++++++++++++++++ src/Cache/Stores/StoreInterface.php | 14 +++++++ tests/Cache/CacheManagerTest.php | 42 ++++++++++++++++++++ tests/Cache/Stores/FileStoreTest.php | 46 ++++++++++++++++++++++ 8 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 src/Cache/Cache.php create mode 100644 src/Cache/CacheFactoryInterface.php create mode 100644 src/Cache/Stores/FileStore.php create mode 100644 src/Cache/Stores/StoreInterface.php create mode 100644 tests/Cache/CacheManagerTest.php create mode 100644 tests/Cache/Stores/FileStoreTest.php diff --git a/.gitignore b/.gitignore index a0534c5..73c490c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ build composer.lock vendor .idea +.vscode .php-cs-fixer.cache .phpunit.result.cache -cache +.phpspellcheck.cache +composer/cache \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8b9e245..11e5d42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.4' - services: php: image: tigitz/phpspellchecker:${PHP_VERSION:-8.1} @@ -9,7 +7,7 @@ services: PHP_VERSION: ${PHP_VERSION:-8.1} 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/Cache.php b/src/Cache/Cache.php new file mode 100644 index 0000000..db2c69e --- /dev/null +++ b/src/Cache/Cache.php @@ -0,0 +1,45 @@ +filesystemAdapter->get($key, $callback, $beta, $metadata); + } + + /** + * Removed an item from the pool. + */ + public function delete(string $key): bool + { + return $this->filesystemAdapter->delete($key); + } + + /** + * Fetches an item from the cache. + */ + public function getItem(string $key): ItemInterface + { + return $this->filesystemAdapter->getItem($key); + } + + /** + * Clear data from cache. + */ + public function clear(string $prefix = ''): bool + { + return $this->filesystemAdapter->clear($prefix); + } +} \ No newline at end of file diff --git a/src/Cache/Stores/StoreInterface.php b/src/Cache/Stores/StoreInterface.php new file mode 100644 index 0000000..c674cc5 --- /dev/null +++ b/src/Cache/Stores/StoreInterface.php @@ -0,0 +1,14 @@ +store = $this->getStoreInstance(); + + $this->store->clear(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->store->clear(); + } + + protected function getStoreInstance(): CacheInterface + { + return Cache::create( + namespace: 'CacheManagerTest', + cacheDirectory: __DIR__.'/../../.phpspellcheck.cache' + ); + } + + public function testInstanceOfCacheInterface() + { + $this->assertInstanceOf(StoreInterface::class, $this->store); + } +} \ No newline at end of file diff --git a/tests/Cache/Stores/FileStoreTest.php b/tests/Cache/Stores/FileStoreTest.php new file mode 100644 index 0000000..ca859e1 --- /dev/null +++ b/tests/Cache/Stores/FileStoreTest.php @@ -0,0 +1,46 @@ +store = $this->getFileStoreInstance(); + + $this->store->clear(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->store->clear(); + } + + protected function getFileStoreInstance(): CacheInterface + { + return FileStore::create( + namespace: 'FileStoreTest', + cacheDirectory: __DIR__.'/../../../.phpspellcheck.cache' + ); + } + + public function testInstanceOfCacheInterface() + { + $this->assertInstanceOf(CacheInterface::class, $this->store); + } + + public function testGetCallbackStoresAndReturnsValue() + { + $this->assertSame($this->store->get('key', fn () => 'value'), 'value'); + } +} \ No newline at end of file From 76af19db92069ef8a059f109e72bb4be0761846c Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Thu, 9 Jan 2025 18:03:02 +0000 Subject: [PATCH 02/22] feature/initial-cache-implementation - update gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 73c490c..a618a1e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ vendor .php-cs-fixer.cache .phpunit.result.cache .phpspellcheck.cache -composer/cache \ No newline at end of file +composer/ \ No newline at end of file From 394ed7efe7b5804ffaa1b2a03f6c70727d5cd697 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Thu, 9 Jan 2025 18:03:53 +0000 Subject: [PATCH 03/22] feature/initial-cache-implementation - composer.json updated --- composer.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bdaf151..c78766c 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "nyholm/psr7": "^1.3", "psr/http-client": "^1.0", "symfony/process": "^4.4.30 | ^5.0 |^6.0 | ^7.0", + "symfony/cache": "^4.4.30 | ^5.0 |^6.0 | ^7.0", "thecodingmachine/safe": "^1.0 | ^2.0", "webmozart/assert": "^1.11" }, @@ -51,7 +52,9 @@ "psr-4": { "PhpSpellcheck\\": "src" }, - "files": [ "src/Text/functions.php" ] + "files": [ + "src/Text/functions.php" + ] }, "autoload-dev": { "psr-4": { From 69bfc8a29d8462a9e7f0a3e1aa5d3eea887d4b0d Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Thu, 9 Jan 2025 18:25:05 +0000 Subject: [PATCH 04/22] feature/initial-cache-implementation - streamline cache directory resolution --- src/Cache/Stores/FileStore.php | 11 ++++- src/Cache/Stores/StoreInterface.php | 16 ++++++- tests/Cache/CacheManagerTest.php | 42 ----------------- tests/Cache/CacheTest.php | 69 ++++++++++++++++++++++++++++ tests/Cache/Stores/FileStoreTest.php | 5 +- 5 files changed, 94 insertions(+), 49 deletions(-) delete mode 100644 tests/Cache/CacheManagerTest.php create mode 100644 tests/Cache/CacheTest.php diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php index e662c5d..d40c3a9 100644 --- a/src/Cache/Stores/FileStore.php +++ b/src/Cache/Stores/FileStore.php @@ -4,6 +4,7 @@ namespace PhpSpellcheck\Cache\Stores; +use Composer\Autoload\ClassLoader; use Symfony\Contracts\Cache\ItemInterface; use Symfony\Component\Cache\Adapter\FilesystemAdapter; @@ -22,7 +23,15 @@ public function __construct(private FilesystemAdapter $filesystemAdapter) */ public static function create(string $namespace = '', int $defaultLifetime = 3600, ?string $cacheDirectory = null): self { - return new self(new FilesystemAdapter($namespace, $defaultLifetime, $cacheDirectory ?? dirname(__DIR__, 5).'/..cache')); + return new self(new FilesystemAdapter($namespace, $defaultLifetime, $cacheDirectory ?? self::getDefaultCachePath())); + } + + /** + * Get the default cache directory for the file store. + */ + public static function getDefaultCachePath(): string + { + return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; } /** diff --git a/src/Cache/Stores/StoreInterface.php b/src/Cache/Stores/StoreInterface.php index c674cc5..e0af3e0 100644 --- a/src/Cache/Stores/StoreInterface.php +++ b/src/Cache/Stores/StoreInterface.php @@ -4,11 +4,23 @@ namespace PhpSpellcheck\Cache\Stores; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\CacheInterface; interface StoreInterface extends CacheInterface { - public static function create(string $namespace = '', int $defaultLifetime = 3600, ?string $cacheDirectory = null): self; - + /** + * Clear data from cache. + */ public function clear(string $prefix = ''): bool; + + /** + * Fetches an item from the cache. + */ + public function getItem(string $key): ItemInterface; + + /** + * Create a new cache store instance. + */ + public static function create(string $namespace = '', int $defaultLifetime = 3600, ?string $cacheDirectory = null): self; } \ No newline at end of file diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php deleted file mode 100644 index 79a9f59..0000000 --- a/tests/Cache/CacheManagerTest.php +++ /dev/null @@ -1,42 +0,0 @@ -store = $this->getStoreInstance(); - - $this->store->clear(); - } - - protected function tearDown(): void - { - parent::tearDown(); - - $this->store->clear(); - } - - protected function getStoreInstance(): CacheInterface - { - return Cache::create( - namespace: 'CacheManagerTest', - cacheDirectory: __DIR__.'/../../.phpspellcheck.cache' - ); - } - - public function testInstanceOfCacheInterface() - { - $this->assertInstanceOf(StoreInterface::class, $this->store); - } -} \ No newline at end of file diff --git a/tests/Cache/CacheTest.php b/tests/Cache/CacheTest.php new file mode 100644 index 0000000..bc57662 --- /dev/null +++ b/tests/Cache/CacheTest.php @@ -0,0 +1,69 @@ +store = $this->getStoreInstance(); + + $this->store->clear(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->store->clear(); + } + + protected function getStoreInstance(): StoreInterface + { + return Cache::create(namespace: 'CacheManagerTest'); + } + + public function testInstanceOfCacheInterface() + { + $this->assertInstanceOf(StoreInterface::class, $this->store); + } + + public function testGetCallbackStoresAndReturnsValue() + { + $this->assertSame($this->store->get('key', fn () => 'value'), 'value'); + } + + public function testGetItemReturnsItemInterface() + { + $this->assertInstanceOf(ItemInterface::class, $this->store->getItem('key')); + } + + public function testDeleteReturnsBoolean() + { + $this->store->get('key', fn () => 'value'); + $this->assertTrue($this->store->delete('key')); + } + + public function testClearReturnsBoolean() + { + $this->store->get('key', fn () => 'value'); + $this->assertTrue($this->store->clear()); + } + + public function testInvalidStoreNameThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache store [invalid] is not defined.'); + + Cache::resolveStoreClassName('invalid'); + } +} \ No newline at end of file diff --git a/tests/Cache/Stores/FileStoreTest.php b/tests/Cache/Stores/FileStoreTest.php index ca859e1..4a0ed9c 100644 --- a/tests/Cache/Stores/FileStoreTest.php +++ b/tests/Cache/Stores/FileStoreTest.php @@ -28,10 +28,7 @@ protected function tearDown(): void protected function getFileStoreInstance(): CacheInterface { - return FileStore::create( - namespace: 'FileStoreTest', - cacheDirectory: __DIR__.'/../../../.phpspellcheck.cache' - ); + return FileStore::create(namespace: 'FileStoreTest'); } public function testInstanceOfCacheInterface() From a2fb471d8f1be4f1b6fc771bd0487e3b86afd8af Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Thu, 9 Jan 2025 18:36:36 +0000 Subject: [PATCH 05/22] feature/initial-cache-implementation - decouple adapter args from abstract Cache class --- src/Cache/Cache.php | 4 ++-- src/Cache/CacheFactoryInterface.php | 2 +- tests/Cache/CacheTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index db2c69e..214256a 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -12,13 +12,13 @@ class Cache implements CacheFactoryInterface /** * Get a cache store instance by name. */ - public static function create(?string $name = null, string $namespace = '', int $defaultLifetime = 3600, ?string $cacheDirectory = null): StoreInterface + public static function create(?string $name = null, array $storeArgs = []): StoreInterface { $name = $name ?: self::getDefaultDriver(); $class = self::resolveStoreClassName($name); - return $class::create($namespace, $defaultLifetime, $cacheDirectory); + return $class::create(...$storeArgs); } /** diff --git a/src/Cache/CacheFactoryInterface.php b/src/Cache/CacheFactoryInterface.php index c887797..2386566 100644 --- a/src/Cache/CacheFactoryInterface.php +++ b/src/Cache/CacheFactoryInterface.php @@ -8,5 +8,5 @@ interface CacheFactoryInterface { - public static function create(?string $name = null, string $namespace = '', int $defaultLifetime = 3600, ?string $cacheDirectory = null): StoreInterface; + public static function create(?string $name = null, array $storeArgs = []): StoreInterface; } \ No newline at end of file diff --git a/tests/Cache/CacheTest.php b/tests/Cache/CacheTest.php index bc57662..ab6b4e8 100644 --- a/tests/Cache/CacheTest.php +++ b/tests/Cache/CacheTest.php @@ -29,7 +29,7 @@ protected function tearDown(): void protected function getStoreInstance(): StoreInterface { - return Cache::create(namespace: 'CacheManagerTest'); + return Cache::create(storeArgs: ['namespace' => 'CacheTest']); } public function testInstanceOfCacheInterface() From d64bed3b079826264f05d4e2c78fb4f16bf52c7d Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Thu, 9 Jan 2025 18:57:55 +0000 Subject: [PATCH 06/22] feature/initial-cache-implementation - get phpstan passing --- src/Cache/Cache.php | 6 ++++-- src/Cache/CacheFactoryInterface.php | 5 +++++ src/Cache/Stores/FileStore.php | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 214256a..dbe5d3e 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -11,10 +11,12 @@ class Cache implements CacheFactoryInterface { /** * Get a cache store instance by name. + * + * @param array $storeArgs */ public static function create(?string $name = null, array $storeArgs = []): StoreInterface { - $name = $name ?: self::getDefaultDriver(); + $name = $name ?? self::getDefaultDriver(); $class = self::resolveStoreClassName($name); @@ -24,7 +26,7 @@ public static function create(?string $name = null, array $storeArgs = []): Stor /** * Resolve the cache store class. */ - public static function resolveStoreClassName($name): string + public static function resolveStoreClassName(string $name): string { $class = sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($name), 'Store'); diff --git a/src/Cache/CacheFactoryInterface.php b/src/Cache/CacheFactoryInterface.php index 2386566..37c1171 100644 --- a/src/Cache/CacheFactoryInterface.php +++ b/src/Cache/CacheFactoryInterface.php @@ -8,5 +8,10 @@ interface CacheFactoryInterface { + /** + * Get a cache store instance by name. + * + * @param array $storeArgs + */ public static function create(?string $name = null, array $storeArgs = []): StoreInterface; } \ No newline at end of file diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php index d40c3a9..bb5e1a7 100644 --- a/src/Cache/Stores/FileStore.php +++ b/src/Cache/Stores/FileStore.php @@ -36,8 +36,10 @@ public static function getDefaultCachePath(): string /** * Fetches an item from the cache. + * + * @param array $metadata */ - public function get(string $key, callable $callback, float $beta = null, array &$metadata = null): mixed + public function get(string $key, callable $callback, float $beta = null, ?array &$metadata = null): mixed { return $this->filesystemAdapter->get($key, $callback, $beta, $metadata); } From 2e3b1734e144c3c156073d2e9ff4973d64217412 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Fri, 10 Jan 2025 09:05:44 +0000 Subject: [PATCH 07/22] feature/initial-cache-implementation - initial aspell cache implementation --- src/Cache/Stores/FileStore.php | 15 ++++++++ src/Spellchecker/Aspell.php | 55 ++++++++++++++++++++-------- tests/Cache/Stores/FileStoreTest.php | 37 +++++++++++++++++++ tests/Spellchecker/AspellTest.php | 2 +- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php index bb5e1a7..076964a 100644 --- a/src/Cache/Stores/FileStore.php +++ b/src/Cache/Stores/FileStore.php @@ -44,6 +44,21 @@ public function get(string $key, callable $callback, float $beta = null, ?array return $this->filesystemAdapter->get($key, $callback, $beta, $metadata); } + /** + * Set the value of the given key in the cache. + */ + public function set(string $key, mixed $value, ?int $lifetime = null): void + { + $item = $this->filesystemAdapter->getItem($key); + $item->set($value); + + if ($lifetime !== null) { + $item->expiresAfter($lifetime); + } + + $this->filesystemAdapter->save($item); + } + /** * Removed an item from the pool. */ diff --git a/src/Spellchecker/Aspell.php b/src/Spellchecker/Aspell.php index 70aead7..777c68c 100644 --- a/src/Spellchecker/Aspell.php +++ b/src/Spellchecker/Aspell.php @@ -4,6 +4,8 @@ namespace PhpSpellcheck\Spellchecker; +use PhpSpellcheck\Cache\Cache; +use PhpSpellcheck\Cache\Stores\StoreInterface; use PhpSpellcheck\Exception\ProcessHasErrorOutputException; use PhpSpellcheck\Utils\CommandLine; use PhpSpellcheck\Utils\IspellParser; @@ -13,39 +15,62 @@ class Aspell implements SpellcheckerInterface { - /** - * @var CommandLine - */ - private $binaryPath; + private StoreInterface $cache; - public function __construct(CommandLine $binaryPath) + public function __construct(private CommandLine $binaryPath) { - $this->binaryPath = $binaryPath; + $this->cache = Cache::create(storeArgs: ['namespace' => 'Aspell']); } public function check(string $text, array $languages = [], array $context = []): iterable + { + $output = $this->processText($text, $languages); + + return IspellParser::parseMisspellingsFromOutput($output, $context); + } + + /** + * Process the text with Aspell spellchecker. + * + * @param array $languages + */ + protected function processText(string $text, array $languages): string { Assert::maxCount($languages, 1, 'Aspell spellchecker doesn\'t support multiple languages check'); $cmd = $this->binaryPath->addArgs(['--encoding', 'utf-8']); $cmd = $cmd->addArg('-a'); - if (!empty($languages)) { - $cmd = $cmd->addArg('--lang=' . implode(',', $languages)); + if (! empty($languages)) { + $cmd = $cmd->addArg('--lang='.implode(',', $languages)); } $process = new Process($cmd->getArgs()); // Add prefix characters putting Ispell's type of spellcheckers in terse-mode, // ignoring correct words and thus speeding up the execution - $process->setInput('!' . PHP_EOL . IspellParser::adaptInputForTerseModeProcessing($text) . PHP_EOL . '%'); + $process->setInput('!'.PHP_EOL.IspellParser::adaptInputForTerseModeProcessing($text).PHP_EOL.'%'); - $output = ProcessRunner::run($process)->getOutput(); + $cacheKey = $this->getCacheKey($text, $languages); - if ($process->getErrorOutput() !== '') { - throw new ProcessHasErrorOutputException($process->getErrorOutput(), $text, $process->getCommandLine()); - } + return $this->cache->get($cacheKey, function () use ($process, $text) { + $process = ProcessRunner::run($process); - return IspellParser::parseMisspellingsFromOutput($output, $context); + if ($process->getErrorOutput() !== '') { + throw new ProcessHasErrorOutputException($process->getErrorOutput(), $text, $process->getCommandLine()); + } + + return $process->getOutput(); + }); + } + + /** + * Get the cache key for the given text and languages. + * + * @param array $languages + */ + protected function getCacheKey(string $text, array $languages): string + { + return md5(sprintf('%s_%s', $text, implode('_', $languages))); } public function getBinaryPath(): CommandLine @@ -69,7 +94,7 @@ public function getSupportedLanguages(): iterable $languages[$name] = true; } $languages = array_keys($languages); - \Safe\sort($languages); + sort($languages); return $languages; } diff --git a/tests/Cache/Stores/FileStoreTest.php b/tests/Cache/Stores/FileStoreTest.php index 4a0ed9c..4c27b90 100644 --- a/tests/Cache/Stores/FileStoreTest.php +++ b/tests/Cache/Stores/FileStoreTest.php @@ -40,4 +40,41 @@ public function testGetCallbackStoresAndReturnsValue() { $this->assertSame($this->store->get('key', fn () => 'value'), 'value'); } + + public function testSetMethodStoresValue() + { + $this->store->set('key', 'value'); + + $this->assertSame($this->store->get('key', fn () => 'default'), 'value'); + } + + public function testItReturnsNullWhenKeyDoesNotExist() + { + $this->assertNull($this->store->getItem('non-existent-key')->get()); + } + + public function testClearMethodRemovesAllItems() + { + $this->store->set('key1', 'value1'); + $this->store->set('key2', 'value2'); + + $this->store->clear(); + + $this->assertNull($this->store->getItem('key1')->get()); + $this->assertNull($this->store->getItem('key2')->get()); + } + + public function testDeleteMethodRemovesSpecificItem() + { + $this->store->set('key', 'value'); + $this->store->delete('key'); + + $this->assertNull($this->store->getItem('key')->get()); + } + + public function testClearMethodReturnsBoolean() + { + $this->store->set('key', 'value'); + $this->assertTrue($this->store->clear()); + } } \ No newline at end of file diff --git a/tests/Spellchecker/AspellTest.php b/tests/Spellchecker/AspellTest.php index 38d9269..0480c59 100644 --- a/tests/Spellchecker/AspellTest.php +++ b/tests/Spellchecker/AspellTest.php @@ -124,4 +124,4 @@ private function assertWorkingSpellcheckRUText($binaries): void $this->assertSame(1, $misspellings[1]->getLineNumber()); $this->assertSame(94, $misspellings[1]->getOffset()); } -} +} \ No newline at end of file From 8554e454144a210472b2a5cde02260a2c109abce Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Fri, 10 Jan 2025 10:47:53 +0000 Subject: [PATCH 08/22] feature/initial-cache-implementation - refactor cache methods and instantiation into trait + rename to for clarity --- .gitignore | 3 +- src/Cache/Cache.php | 20 ++++++------ src/Cache/CacheFactoryInterface.php | 4 +-- src/Spellchecker/Aspell.php | 12 ++++--- src/Traits/SpellcheckerCacheTrait.php | 46 +++++++++++++++++++++++++++ tests/Cache/CacheTest.php | 4 +-- 6 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 src/Traits/SpellcheckerCacheTrait.php diff --git a/.gitignore b/.gitignore index a618a1e..ee0b38e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ vendor .php-cs-fixer.cache .phpunit.result.cache .phpspellcheck.cache -composer/ \ No newline at end of file +composer/ +.DS_Store \ No newline at end of file diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index dbe5d3e..e5a17d7 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -10,28 +10,28 @@ class Cache implements CacheFactoryInterface { /** - * Get a cache store instance by name. + * Get a cache store instance by driver. * - * @param array $storeArgs + * @param array $config */ - public static function create(?string $name = null, array $storeArgs = []): StoreInterface + public static function create(?string $driver = null, array $config = []): StoreInterface { - $name = $name ?? self::getDefaultDriver(); + $driver = $driver ?? self::getDefaultDriver(); - $class = self::resolveStoreClassName($name); + $class = self::resolveStoreClass($driver); - return $class::create(...$storeArgs); + return $class::create(...$config); } /** * Resolve the cache store class. */ - public static function resolveStoreClassName(string $name): string + public static function resolveStoreClass(string $driver): string { - $class = sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($name), 'Store'); + $class = sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); if (! class_exists($class)) { - throw new InvalidArgumentException("Cache store [{$name}] is not defined."); + throw new InvalidArgumentException("Cache store [{$driver}] is not defined."); } return $class; @@ -40,7 +40,7 @@ public static function resolveStoreClassName(string $name): string /** * Get the default cache driver name. */ - public static function getDefaultDriver(): string + private static function getDefaultDriver(): string { return 'file'; } diff --git a/src/Cache/CacheFactoryInterface.php b/src/Cache/CacheFactoryInterface.php index 37c1171..5c10791 100644 --- a/src/Cache/CacheFactoryInterface.php +++ b/src/Cache/CacheFactoryInterface.php @@ -11,7 +11,7 @@ interface CacheFactoryInterface /** * Get a cache store instance by name. * - * @param array $storeArgs + * @param array $config */ - public static function create(?string $name = null, array $storeArgs = []): StoreInterface; + public static function create(?string $name = null, array $config = []): StoreInterface; } \ No newline at end of file diff --git a/src/Spellchecker/Aspell.php b/src/Spellchecker/Aspell.php index 777c68c..c4e475b 100644 --- a/src/Spellchecker/Aspell.php +++ b/src/Spellchecker/Aspell.php @@ -4,9 +4,8 @@ namespace PhpSpellcheck\Spellchecker; -use PhpSpellcheck\Cache\Cache; -use PhpSpellcheck\Cache\Stores\StoreInterface; use PhpSpellcheck\Exception\ProcessHasErrorOutputException; +use PhpSpellcheck\Traits\SpellcheckerCacheTrait; use PhpSpellcheck\Utils\CommandLine; use PhpSpellcheck\Utils\IspellParser; use PhpSpellcheck\Utils\ProcessRunner; @@ -15,13 +14,16 @@ class Aspell implements SpellcheckerInterface { - private StoreInterface $cache; + use SpellcheckerCacheTrait; public function __construct(private CommandLine $binaryPath) { - $this->cache = Cache::create(storeArgs: ['namespace' => 'Aspell']); + $this->initCache(); } + /** + * {@inheritdoc} + */ public function check(string $text, array $languages = [], array $context = []): iterable { $output = $this->processText($text, $languages); @@ -52,7 +54,7 @@ protected function processText(string $text, array $languages): string $cacheKey = $this->getCacheKey($text, $languages); - return $this->cache->get($cacheKey, function () use ($process, $text) { + return $this->cache->get($cacheKey, function () use (&$process, $text) { $process = ProcessRunner::run($process); if ($process->getErrorOutput() !== '') { diff --git a/src/Traits/SpellcheckerCacheTrait.php b/src/Traits/SpellcheckerCacheTrait.php new file mode 100644 index 0000000..bf8ec41 --- /dev/null +++ b/src/Traits/SpellcheckerCacheTrait.php @@ -0,0 +1,46 @@ + $config + */ + private function initCache(array $config = []): void + { + $config['namespace'] ??= $this->getCacheNamespace(); + + $this->cache = Cache::create(config: $config); + } + + /** + * Get the cache key for the given text and languages. + * + * @param array $languages + */ + private function getCacheKey(string $text, array $languages): string + { + return md5(sprintf('%s_%s', $text, implode('_', $languages))); + } + + /** + * Get the cache namespace. + */ + private function getCacheNamespace(): string + { + $parts = explode('\\', get_class($this)); + + return end($parts); + } +} \ No newline at end of file diff --git a/tests/Cache/CacheTest.php b/tests/Cache/CacheTest.php index ab6b4e8..9b7a9cc 100644 --- a/tests/Cache/CacheTest.php +++ b/tests/Cache/CacheTest.php @@ -29,7 +29,7 @@ protected function tearDown(): void protected function getStoreInstance(): StoreInterface { - return Cache::create(storeArgs: ['namespace' => 'CacheTest']); + return Cache::create(config: ['namespace' => 'CacheTest']); } public function testInstanceOfCacheInterface() @@ -64,6 +64,6 @@ public function testInvalidStoreNameThrowsException() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache store [invalid] is not defined.'); - Cache::resolveStoreClassName('invalid'); + Cache::resolveStoreClass('invalid'); } } \ No newline at end of file From eb2d58d4dea951338e05544fa0bc0a94423f622e Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Fri, 10 Jan 2025 11:29:24 +0000 Subject: [PATCH 09/22] feature/initial-cache-implementation - phpcs fixer --- src/Cache/Cache.php | 8 +-- src/Cache/CacheFactoryInterface.php | 2 +- src/Cache/Stores/FileStore.php | 4 +- src/Cache/Stores/StoreInterface.php | 2 +- src/MisspellingHandler/EchoHandler.php | 2 +- src/Spellchecker/Aspell.php | 76 +++++++++++--------------- src/Traits/SpellcheckerCacheTrait.php | 10 ++-- tests/Cache/CacheTest.php | 12 ++-- tests/Cache/Stores/FileStoreTest.php | 12 ++-- tests/Spellchecker/AspellTest.php | 2 +- tests/bootstrap.php | 4 +- 11 files changed, 63 insertions(+), 71 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index e5a17d7..8d6491c 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -16,7 +16,7 @@ class Cache implements CacheFactoryInterface */ public static function create(?string $driver = null, array $config = []): StoreInterface { - $driver = $driver ?? self::getDefaultDriver(); + $driver ??= self::getDefaultDriver(); $class = self::resolveStoreClass($driver); @@ -28,9 +28,9 @@ public static function create(?string $driver = null, array $config = []): Store */ public static function resolveStoreClass(string $driver): string { - $class = sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); + $class = \sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); - if (! class_exists($class)) { + if (!class_exists($class)) { throw new InvalidArgumentException("Cache store [{$driver}] is not defined."); } @@ -44,4 +44,4 @@ private static function getDefaultDriver(): string { return 'file'; } -} \ No newline at end of file +} diff --git a/src/Cache/CacheFactoryInterface.php b/src/Cache/CacheFactoryInterface.php index 5c10791..132f47d 100644 --- a/src/Cache/CacheFactoryInterface.php +++ b/src/Cache/CacheFactoryInterface.php @@ -14,4 +14,4 @@ interface CacheFactoryInterface * @param array $config */ public static function create(?string $name = null, array $config = []): StoreInterface; -} \ No newline at end of file +} diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php index 076964a..c7df179 100644 --- a/src/Cache/Stores/FileStore.php +++ b/src/Cache/Stores/FileStore.php @@ -31,7 +31,7 @@ public static function create(string $namespace = '', int $defaultLifetime = 360 */ public static function getDefaultCachePath(): string { - return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; + return \dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; } /** @@ -82,4 +82,4 @@ public function clear(string $prefix = ''): bool { return $this->filesystemAdapter->clear($prefix); } -} \ No newline at end of file +} diff --git a/src/Cache/Stores/StoreInterface.php b/src/Cache/Stores/StoreInterface.php index e0af3e0..6da8d8b 100644 --- a/src/Cache/Stores/StoreInterface.php +++ b/src/Cache/Stores/StoreInterface.php @@ -23,4 +23,4 @@ public function getItem(string $key): ItemInterface; * Create a new cache store instance. */ public static function create(string $namespace = '', int $defaultLifetime = 3600, ?string $cacheDirectory = null): self; -} \ No newline at end of file +} diff --git a/src/MisspellingHandler/EchoHandler.php b/src/MisspellingHandler/EchoHandler.php index a28b1f4..cde7720 100644 --- a/src/MisspellingHandler/EchoHandler.php +++ b/src/MisspellingHandler/EchoHandler.php @@ -14,7 +14,7 @@ class EchoHandler implements MisspellingHandlerInterface public function handle(iterable $misspellings): void { foreach ($misspellings as $misspelling) { - $output = sprintf( + $output = \sprintf( 'word: %s | line: %d | offset: %d | suggestions: %s | context: %s' . PHP_EOL, $misspelling->getWord(), $misspelling->getLineNumber(), diff --git a/src/Spellchecker/Aspell.php b/src/Spellchecker/Aspell.php index c4e475b..49ccedb 100644 --- a/src/Spellchecker/Aspell.php +++ b/src/Spellchecker/Aspell.php @@ -31,10 +31,41 @@ public function check(string $text, array $languages = [], array $context = []): return IspellParser::parseMisspellingsFromOutput($output, $context); } + public function getBinaryPath(): CommandLine + { + return $this->binaryPath; + } + + public function getSupportedLanguages(): iterable + { + $languages = []; + $cmd = $this->binaryPath->addArgs(['dump', 'dicts']); + $process = new Process($cmd->getArgs()); + $output = explode(PHP_EOL, ProcessRunner::run($process)->getOutput()); + + foreach ($output as $line) { + $name = trim($line); + if (strpos($name, '-variant') !== false || $name === '') { + // Skip variants + continue; + } + $languages[$name] = true; + } + $languages = array_keys($languages); + sort($languages); + + return $languages; + } + + public static function create(?string $binaryPathAsString = null): self + { + return new self(new CommandLine($binaryPathAsString ?? 'aspell')); + } + /** * Process the text with Aspell spellchecker. * - * @param array $languages + * @param array $languages */ protected function processText(string $text, array $languages): string { @@ -43,7 +74,7 @@ protected function processText(string $text, array $languages): string $cmd = $this->binaryPath->addArgs(['--encoding', 'utf-8']); $cmd = $cmd->addArg('-a'); - if (! empty($languages)) { + if (!empty($languages)) { $cmd = $cmd->addArg('--lang='.implode(',', $languages)); } @@ -64,45 +95,4 @@ protected function processText(string $text, array $languages): string return $process->getOutput(); }); } - - /** - * Get the cache key for the given text and languages. - * - * @param array $languages - */ - protected function getCacheKey(string $text, array $languages): string - { - return md5(sprintf('%s_%s', $text, implode('_', $languages))); - } - - public function getBinaryPath(): CommandLine - { - return $this->binaryPath; - } - - public function getSupportedLanguages(): iterable - { - $languages = []; - $cmd = $this->binaryPath->addArgs(['dump', 'dicts']); - $process = new Process($cmd->getArgs()); - $output = explode(PHP_EOL, ProcessRunner::run($process)->getOutput()); - - foreach ($output as $line) { - $name = trim($line); - if (strpos($name, '-variant') !== false || $name === '') { - // Skip variants - continue; - } - $languages[$name] = true; - } - $languages = array_keys($languages); - sort($languages); - - return $languages; - } - - public static function create(?string $binaryPathAsString = null): self - { - return new self(new CommandLine($binaryPathAsString ?? 'aspell')); - } } diff --git a/src/Traits/SpellcheckerCacheTrait.php b/src/Traits/SpellcheckerCacheTrait.php index bf8ec41..b59edaf 100644 --- a/src/Traits/SpellcheckerCacheTrait.php +++ b/src/Traits/SpellcheckerCacheTrait.php @@ -1,5 +1,7 @@ $languages + * @param array $languages */ private function getCacheKey(string $text, array $languages): string { - return md5(sprintf('%s_%s', $text, implode('_', $languages))); + return md5(\sprintf('%s_%s', $text, implode('_', $languages))); } /** @@ -39,8 +41,8 @@ private function getCacheKey(string $text, array $languages): string */ private function getCacheNamespace(): string { - $parts = explode('\\', get_class($this)); + $parts = explode('\\', \get_class($this)); return end($parts); } -} \ No newline at end of file +} diff --git a/tests/Cache/CacheTest.php b/tests/Cache/CacheTest.php index 9b7a9cc..e70519b 100644 --- a/tests/Cache/CacheTest.php +++ b/tests/Cache/CacheTest.php @@ -27,11 +27,6 @@ protected function tearDown(): void $this->store->clear(); } - protected function getStoreInstance(): StoreInterface - { - return Cache::create(config: ['namespace' => 'CacheTest']); - } - public function testInstanceOfCacheInterface() { $this->assertInstanceOf(StoreInterface::class, $this->store); @@ -66,4 +61,9 @@ public function testInvalidStoreNameThrowsException() Cache::resolveStoreClass('invalid'); } -} \ No newline at end of file + + protected function getStoreInstance(): StoreInterface + { + return Cache::create(config: ['namespace' => 'CacheTest']); + } +} diff --git a/tests/Cache/Stores/FileStoreTest.php b/tests/Cache/Stores/FileStoreTest.php index 4c27b90..7a622de 100644 --- a/tests/Cache/Stores/FileStoreTest.php +++ b/tests/Cache/Stores/FileStoreTest.php @@ -26,11 +26,6 @@ protected function tearDown(): void $this->store->clear(); } - protected function getFileStoreInstance(): CacheInterface - { - return FileStore::create(namespace: 'FileStoreTest'); - } - public function testInstanceOfCacheInterface() { $this->assertInstanceOf(CacheInterface::class, $this->store); @@ -77,4 +72,9 @@ public function testClearMethodReturnsBoolean() $this->store->set('key', 'value'); $this->assertTrue($this->store->clear()); } -} \ No newline at end of file + + protected function getFileStoreInstance(): CacheInterface + { + return FileStore::create(namespace: 'FileStoreTest'); + } +} diff --git a/tests/Spellchecker/AspellTest.php b/tests/Spellchecker/AspellTest.php index 0480c59..38d9269 100644 --- a/tests/Spellchecker/AspellTest.php +++ b/tests/Spellchecker/AspellTest.php @@ -124,4 +124,4 @@ private function assertWorkingSpellcheckRUText($binaries): void $this->assertSame(1, $misspellings[1]->getLineNumber()); $this->assertSame(94, $misspellings[1]->getOffset()); } -} \ No newline at end of file +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a5c26bd..a579b48 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,12 +10,12 @@ ]; foreach ($dependencies as $dependency) { - echo sprintf('Waiting "%s" dependency'.PHP_EOL, $dependency); + echo \sprintf('Waiting "%s" dependency'.PHP_EOL, $dependency); for (; ;) { $url = parse_url(getenv($dependency)); if ($socket = @fsockopen($url['host'], $url['port'])) { - echo sprintf('"%s" dependency is up'.PHP_EOL, $dependency).PHP_EOL; + echo \sprintf('"%s" dependency is up'.PHP_EOL, $dependency).PHP_EOL; fclose($socket); break; From 9e6f7b32fc3ad38e7b8700c5f30bde8143ac8c46 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Fri, 10 Jan 2025 11:41:16 +0000 Subject: [PATCH 10/22] feature/initial-cache-implementation - fix phpcs deprecation warning --- .github/workflows/php-cs-fixer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index 457c6fa..7af5785 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -13,7 +13,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none tools: php-cs-fixer:3.54.x, cs2pr From 7e4e17d8aa0eb985352d73148b40a8dbbd9d9b32 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Fri, 10 Jan 2025 11:42:48 +0000 Subject: [PATCH 11/22] feature/initial-cache-implementation - fix phpcs deprecation warning --- .github/workflows/php-cs-fixer.yml | 2 +- .php-cs-fixer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index 7af5785..457c6fa 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -13,7 +13,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.1 coverage: none tools: php-cs-fixer:3.54.x, cs2pr diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index e57561f..d870b1c 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -25,7 +25,7 @@ 'method_chaining_indentation' => true, 'native_function_casing' => true, 'native_function_invocation' => ['include' => ['@compiler_optimized']], - 'new_with_braces' => true, + 'new_with_parentheses' => true, 'modernize_types_casting' => true, 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 'no_empty_statement' => true, From 0b87946058d3952344c4636c997edc4367a277fd Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Fri, 10 Jan 2025 12:01:08 +0000 Subject: [PATCH 12/22] feature/initial-cache-implementation - revert phpcs native_function_invocation changes --- src/Cache/Cache.php | 2 +- src/Cache/Stores/FileStore.php | 2 +- src/MisspellingHandler/EchoHandler.php | 2 +- src/Traits/SpellcheckerCacheTrait.php | 4 ++-- tests/bootstrap.php | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 8d6491c..d2b16b7 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -28,7 +28,7 @@ public static function create(?string $driver = null, array $config = []): Store */ public static function resolveStoreClass(string $driver): string { - $class = \sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); + $class = sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); if (!class_exists($class)) { throw new InvalidArgumentException("Cache store [{$driver}] is not defined."); diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php index c7df179..835904f 100644 --- a/src/Cache/Stores/FileStore.php +++ b/src/Cache/Stores/FileStore.php @@ -31,7 +31,7 @@ public static function create(string $namespace = '', int $defaultLifetime = 360 */ public static function getDefaultCachePath(): string { - return \dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; + return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; } /** diff --git a/src/MisspellingHandler/EchoHandler.php b/src/MisspellingHandler/EchoHandler.php index cde7720..a28b1f4 100644 --- a/src/MisspellingHandler/EchoHandler.php +++ b/src/MisspellingHandler/EchoHandler.php @@ -14,7 +14,7 @@ class EchoHandler implements MisspellingHandlerInterface public function handle(iterable $misspellings): void { foreach ($misspellings as $misspelling) { - $output = \sprintf( + $output = sprintf( 'word: %s | line: %d | offset: %d | suggestions: %s | context: %s' . PHP_EOL, $misspelling->getWord(), $misspelling->getLineNumber(), diff --git a/src/Traits/SpellcheckerCacheTrait.php b/src/Traits/SpellcheckerCacheTrait.php index b59edaf..273f3ca 100644 --- a/src/Traits/SpellcheckerCacheTrait.php +++ b/src/Traits/SpellcheckerCacheTrait.php @@ -33,7 +33,7 @@ private function initCache(array $config = []): void */ private function getCacheKey(string $text, array $languages): string { - return md5(\sprintf('%s_%s', $text, implode('_', $languages))); + return md5(sprintf('%s_%s', $text, implode('_', $languages))); } /** @@ -41,7 +41,7 @@ private function getCacheKey(string $text, array $languages): string */ private function getCacheNamespace(): string { - $parts = explode('\\', \get_class($this)); + $parts = explode('\\', get_class($this)); return end($parts); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a579b48..a5c26bd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,12 +10,12 @@ ]; foreach ($dependencies as $dependency) { - echo \sprintf('Waiting "%s" dependency'.PHP_EOL, $dependency); + echo sprintf('Waiting "%s" dependency'.PHP_EOL, $dependency); for (; ;) { $url = parse_url(getenv($dependency)); if ($socket = @fsockopen($url['host'], $url['port'])) { - echo \sprintf('"%s" dependency is up'.PHP_EOL, $dependency).PHP_EOL; + echo sprintf('"%s" dependency is up'.PHP_EOL, $dependency).PHP_EOL; fclose($socket); break; From fc88d4cbecd710a7a2242fb8211c333bc4477b1d Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Fri, 10 Jan 2025 12:20:39 +0000 Subject: [PATCH 13/22] feature/initial-cache-implementation - re-ran phpcs fixer --- src/Cache/Cache.php | 2 +- src/Cache/Stores/FileStore.php | 2 +- src/MisspellingHandler/EchoHandler.php | 2 +- src/Spellchecker/Aspell.php | 2 +- src/Traits/SpellcheckerCacheTrait.php | 4 ++-- tests/bootstrap.php | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index d2b16b7..8d6491c 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -28,7 +28,7 @@ public static function create(?string $driver = null, array $config = []): Store */ public static function resolveStoreClass(string $driver): string { - $class = sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); + $class = \sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); if (!class_exists($class)) { throw new InvalidArgumentException("Cache store [{$driver}] is not defined."); diff --git a/src/Cache/Stores/FileStore.php b/src/Cache/Stores/FileStore.php index 835904f..c7df179 100644 --- a/src/Cache/Stores/FileStore.php +++ b/src/Cache/Stores/FileStore.php @@ -31,7 +31,7 @@ public static function create(string $namespace = '', int $defaultLifetime = 360 */ public static function getDefaultCachePath(): string { - return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; + return \dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; } /** diff --git a/src/MisspellingHandler/EchoHandler.php b/src/MisspellingHandler/EchoHandler.php index a28b1f4..cde7720 100644 --- a/src/MisspellingHandler/EchoHandler.php +++ b/src/MisspellingHandler/EchoHandler.php @@ -14,7 +14,7 @@ class EchoHandler implements MisspellingHandlerInterface public function handle(iterable $misspellings): void { foreach ($misspellings as $misspelling) { - $output = sprintf( + $output = \sprintf( 'word: %s | line: %d | offset: %d | suggestions: %s | context: %s' . PHP_EOL, $misspelling->getWord(), $misspelling->getLineNumber(), diff --git a/src/Spellchecker/Aspell.php b/src/Spellchecker/Aspell.php index 49ccedb..b8afe0e 100644 --- a/src/Spellchecker/Aspell.php +++ b/src/Spellchecker/Aspell.php @@ -52,7 +52,7 @@ public function getSupportedLanguages(): iterable $languages[$name] = true; } $languages = array_keys($languages); - sort($languages); + \Safe\sort($languages); return $languages; } diff --git a/src/Traits/SpellcheckerCacheTrait.php b/src/Traits/SpellcheckerCacheTrait.php index 273f3ca..b59edaf 100644 --- a/src/Traits/SpellcheckerCacheTrait.php +++ b/src/Traits/SpellcheckerCacheTrait.php @@ -33,7 +33,7 @@ private function initCache(array $config = []): void */ private function getCacheKey(string $text, array $languages): string { - return md5(sprintf('%s_%s', $text, implode('_', $languages))); + return md5(\sprintf('%s_%s', $text, implode('_', $languages))); } /** @@ -41,7 +41,7 @@ private function getCacheKey(string $text, array $languages): string */ private function getCacheNamespace(): string { - $parts = explode('\\', get_class($this)); + $parts = explode('\\', \get_class($this)); return end($parts); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a5c26bd..a579b48 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,12 +10,12 @@ ]; foreach ($dependencies as $dependency) { - echo sprintf('Waiting "%s" dependency'.PHP_EOL, $dependency); + echo \sprintf('Waiting "%s" dependency'.PHP_EOL, $dependency); for (; ;) { $url = parse_url(getenv($dependency)); if ($socket = @fsockopen($url['host'], $url['port'])) { - echo sprintf('"%s" dependency is up'.PHP_EOL, $dependency).PHP_EOL; + echo \sprintf('"%s" dependency is up'.PHP_EOL, $dependency).PHP_EOL; fclose($socket); break; From f1b8fface23f57f403ca3a66a317f4799a4e5d2d Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Sat, 11 Jan 2025 22:37:53 +0000 Subject: [PATCH 14/22] feature/initial-cache-implementation - replace symfony/cache with psr-16 and initial impl of filecache --- composer.json | 2 +- src/Cache/Cache.php | 47 ------ src/Cache/CacheFactoryInterface.php | 17 --- src/Cache/CacheInterface.php | 12 ++ src/Cache/CacheValue.php | 26 ++++ src/Cache/FileCache.php | 169 +++++++++++++++++++++ src/Cache/Stores/FileStore.php | 85 ----------- src/Cache/Stores/StoreInterface.php | 26 ---- src/Spellchecker/Aspell.php | 67 +++----- src/Spellchecker/CacheableSpellchecker.php | 48 ++++++ src/Traits/SpellcheckerCacheTrait.php | 48 ------ tests/Cache/CacheTest.php | 69 --------- tests/Cache/FileCacheTest.php | 159 +++++++++++++++++++ tests/Cache/Stores/FileStoreTest.php | 80 ---------- 14 files changed, 440 insertions(+), 415 deletions(-) delete mode 100644 src/Cache/Cache.php delete mode 100644 src/Cache/CacheFactoryInterface.php create mode 100644 src/Cache/CacheInterface.php create mode 100644 src/Cache/CacheValue.php create mode 100644 src/Cache/FileCache.php delete mode 100644 src/Cache/Stores/FileStore.php delete mode 100644 src/Cache/Stores/StoreInterface.php create mode 100644 src/Spellchecker/CacheableSpellchecker.php delete mode 100644 src/Traits/SpellcheckerCacheTrait.php delete mode 100644 tests/Cache/CacheTest.php create mode 100644 tests/Cache/FileCacheTest.php delete mode 100644 tests/Cache/Stores/FileStoreTest.php diff --git a/composer.json b/composer.json index c78766c..d7e7fe2 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "nyholm/psr7": "^1.3", "psr/http-client": "^1.0", "symfony/process": "^4.4.30 | ^5.0 |^6.0 | ^7.0", - "symfony/cache": "^4.4.30 | ^5.0 |^6.0 | ^7.0", + "psr/simple-cache": "^3.0", "thecodingmachine/safe": "^1.0 | ^2.0", "webmozart/assert": "^1.11" }, diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php deleted file mode 100644 index 8d6491c..0000000 --- a/src/Cache/Cache.php +++ /dev/null @@ -1,47 +0,0 @@ - $config - */ - public static function create(?string $driver = null, array $config = []): StoreInterface - { - $driver ??= self::getDefaultDriver(); - - $class = self::resolveStoreClass($driver); - - return $class::create(...$config); - } - - /** - * Resolve the cache store class. - */ - public static function resolveStoreClass(string $driver): string - { - $class = \sprintf('%s\%s\%s%s', __NAMESPACE__, 'Stores', ucfirst($driver), 'Store'); - - if (!class_exists($class)) { - throw new InvalidArgumentException("Cache store [{$driver}] is not defined."); - } - - return $class; - } - - /** - * Get the default cache driver name. - */ - private static function getDefaultDriver(): string - { - return 'file'; - } -} diff --git a/src/Cache/CacheFactoryInterface.php b/src/Cache/CacheFactoryInterface.php deleted file mode 100644 index 132f47d..0000000 --- a/src/Cache/CacheFactoryInterface.php +++ /dev/null @@ -1,17 +0,0 @@ - $config - */ - public static function create(?string $name = null, array $config = []): StoreInterface; -} diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php new file mode 100644 index 0000000..1877f29 --- /dev/null +++ b/src/Cache/CacheInterface.php @@ -0,0 +1,12 @@ +expiresAt !== null && $this->expiresAt < time(); + } + + public function isValid(): bool + { + return !$this->isExpired(); + } + + public function serialize(): string + { + return serialize($this); + } +} \ No newline at end of file diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php new file mode 100644 index 0000000..a73c144 --- /dev/null +++ b/src/Cache/FileCache.php @@ -0,0 +1,169 @@ +getDefaultDirectory(); + } + + if (strlen($namespace) > 0) { + $this->validateNamespace($namespace); + $directory .= DIRECTORY_SEPARATOR . $namespace; + } else { + $directory .= DIRECTORY_SEPARATOR . '@'; + } + + if (!is_dir($directory)) { + \Safe\mkdir($directory, 0755, true); + } + + $this->directory = $directory .= DIRECTORY_SEPARATOR; + } + + public function getDefaultDirectory(): string + { + return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; + } + + public function get(string $key, mixed $default = null): mixed + { + if (!$this->has($key)) { + return $default; + } + + return $this->getValueObject($key)?->value; + } + + private function getValueObject(string $key): ?CacheValue + { + try { + $value = unserialize(\Safe\file_get_contents($this->getFilePath($key))); + + return $value instanceof CacheValue ? $value : null; + } catch (\Throwable) { + return null; + } + } + + public function has(string $key): bool + { + if (!file_exists($this->getFilePath($key))) { + return false; + } + + $object = $this->getValueObject($key); + + return $object !== null && $object->isValid(); + } + + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + $this->validateKey($key); + + $ttl ??= $this->defaultLifetime; + + if ($ttl instanceof \DateInterval) { + $expiresAt = (new \Safe\DateTime())->add($ttl)->getTimestamp(); + } else { + $expiresAt = $ttl > 0 ? time() + $ttl : null; + } + + $data = new CacheValue($value, $expiresAt); + + return (bool) \Safe\file_put_contents($this->getFilePath($key), $data->serialize(), LOCK_EX); + } + + public function delete(string $key): bool + { + if (!$this->has($key)) { + return false; + } + + \Safe\unlink($this->getFilePath($key)); + + return true; + } + + public function clear(): bool + { + $files = \Safe\glob($this->directory.'*'); + + if (empty($files)) { + return false; + } + + foreach ($files as $file) { + \Safe\unlink($file); + } + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get($key, $default); + } + } + + /** + * @param iterable $values + */ + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + { + $result = true; + foreach ($values as $key => $value) { + if (is_string($key)) { + $result = $this->set($key, $value, $ttl) && $result; + } + } + + return $result; + } + + public function deleteMultiple(iterable $keys): bool + { + $result = true; + foreach ($keys as $key) { + $result = $this->delete($key) && $result; + } + + return $result; + } + + public function getFilePath(string $key): string + { + return $this->directory . $key; + } + + private function validateNamespace(string $namespace): void + { + if (\Safe\preg_match('#[^-+_.A-Za-z0-9]#', $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 (\Safe\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/Stores/FileStore.php b/src/Cache/Stores/FileStore.php deleted file mode 100644 index c7df179..0000000 --- a/src/Cache/Stores/FileStore.php +++ /dev/null @@ -1,85 +0,0 @@ - $metadata - */ - public function get(string $key, callable $callback, float $beta = null, ?array &$metadata = null): mixed - { - return $this->filesystemAdapter->get($key, $callback, $beta, $metadata); - } - - /** - * Set the value of the given key in the cache. - */ - public function set(string $key, mixed $value, ?int $lifetime = null): void - { - $item = $this->filesystemAdapter->getItem($key); - $item->set($value); - - if ($lifetime !== null) { - $item->expiresAfter($lifetime); - } - - $this->filesystemAdapter->save($item); - } - - /** - * Removed an item from the pool. - */ - public function delete(string $key): bool - { - return $this->filesystemAdapter->delete($key); - } - - /** - * Fetches an item from the cache. - */ - public function getItem(string $key): ItemInterface - { - return $this->filesystemAdapter->getItem($key); - } - - /** - * Clear data from cache. - */ - public function clear(string $prefix = ''): bool - { - return $this->filesystemAdapter->clear($prefix); - } -} diff --git a/src/Cache/Stores/StoreInterface.php b/src/Cache/Stores/StoreInterface.php deleted file mode 100644 index 6da8d8b..0000000 --- a/src/Cache/Stores/StoreInterface.php +++ /dev/null @@ -1,26 +0,0 @@ -initCache(); + $this->binaryPath = $binaryPath; } - /** - * {@inheritdoc} - */ public function check(string $text, array $languages = [], array $context = []): iterable { - $output = $this->processText($text, $languages); + Assert::maxCount($languages, 1, 'Aspell spellchecker doesn\'t support multiple languages check'); + + $cmd = $this->binaryPath->addArgs(['--encoding', 'utf-8']); + $cmd = $cmd->addArg('-a'); + + if (!empty($languages)) { + $cmd = $cmd->addArg('--lang=' . implode(',', $languages)); + } + + $process = new Process($cmd->getArgs()); + // Add prefix characters putting Ispell's type of spellcheckers in terse-mode, + // ignoring correct words and thus speeding up the execution + $process->setInput('!' . PHP_EOL . IspellParser::adaptInputForTerseModeProcessing($text) . PHP_EOL . '%'); + + $output = ProcessRunner::run($process)->getOutput(); + + if ($process->getErrorOutput() !== '') { + throw new ProcessHasErrorOutputException($process->getErrorOutput(), $text, $process->getCommandLine()); + } return IspellParser::parseMisspellingsFromOutput($output, $context); } @@ -61,38 +78,4 @@ public static function create(?string $binaryPathAsString = null): self { return new self(new CommandLine($binaryPathAsString ?? 'aspell')); } - - /** - * Process the text with Aspell spellchecker. - * - * @param array $languages - */ - protected function processText(string $text, array $languages): string - { - Assert::maxCount($languages, 1, 'Aspell spellchecker doesn\'t support multiple languages check'); - - $cmd = $this->binaryPath->addArgs(['--encoding', 'utf-8']); - $cmd = $cmd->addArg('-a'); - - if (!empty($languages)) { - $cmd = $cmd->addArg('--lang='.implode(',', $languages)); - } - - $process = new Process($cmd->getArgs()); - // Add prefix characters putting Ispell's type of spellcheckers in terse-mode, - // ignoring correct words and thus speeding up the execution - $process->setInput('!'.PHP_EOL.IspellParser::adaptInputForTerseModeProcessing($text).PHP_EOL.'%'); - - $cacheKey = $this->getCacheKey($text, $languages); - - return $this->cache->get($cacheKey, function () use (&$process, $text) { - $process = ProcessRunner::run($process); - - if ($process->getErrorOutput() !== '') { - throw new ProcessHasErrorOutputException($process->getErrorOutput(), $text, $process->getCommandLine()); - } - - return $process->getOutput(); - }); - } } diff --git a/src/Spellchecker/CacheableSpellchecker.php b/src/Spellchecker/CacheableSpellchecker.php new file mode 100644 index 0000000..e1b3231 --- /dev/null +++ b/src/Spellchecker/CacheableSpellchecker.php @@ -0,0 +1,48 @@ + + */ + public function check(string $text, array $languages = [], array $context = []): iterable + { + $key = $this->generateCacheKey($text, $languages); + + $result = $this->cache->get($key); + + if ($result === null) { + $result = $this->spellChecker->check($text, $languages, $context); + $this->cache->set($key, $result); + + return $result; + } + + return (new ArrayObject((array) $result))->getIterator(); + } + + /** + * @param array $languages + */ + private function generateCacheKey(string $text, array $languages = []): string + { + return md5(sprintf('%s_%s', $text, implode('_', $languages))); + } + + public function getSupportedLanguages(): iterable + { + return $this->spellChecker->getSupportedLanguages(); + } +} \ No newline at end of file diff --git a/src/Traits/SpellcheckerCacheTrait.php b/src/Traits/SpellcheckerCacheTrait.php deleted file mode 100644 index b59edaf..0000000 --- a/src/Traits/SpellcheckerCacheTrait.php +++ /dev/null @@ -1,48 +0,0 @@ - $config - */ - private function initCache(array $config = []): void - { - $config['namespace'] ??= $this->getCacheNamespace(); - - $this->cache = Cache::create(config: $config); - } - - /** - * Get the cache key for the given text and languages. - * - * @param array $languages - */ - private function getCacheKey(string $text, array $languages): string - { - return md5(\sprintf('%s_%s', $text, implode('_', $languages))); - } - - /** - * Get the cache namespace. - */ - private function getCacheNamespace(): string - { - $parts = explode('\\', \get_class($this)); - - return end($parts); - } -} diff --git a/tests/Cache/CacheTest.php b/tests/Cache/CacheTest.php deleted file mode 100644 index e70519b..0000000 --- a/tests/Cache/CacheTest.php +++ /dev/null @@ -1,69 +0,0 @@ -store = $this->getStoreInstance(); - - $this->store->clear(); - } - - protected function tearDown(): void - { - parent::tearDown(); - - $this->store->clear(); - } - - public function testInstanceOfCacheInterface() - { - $this->assertInstanceOf(StoreInterface::class, $this->store); - } - - public function testGetCallbackStoresAndReturnsValue() - { - $this->assertSame($this->store->get('key', fn () => 'value'), 'value'); - } - - public function testGetItemReturnsItemInterface() - { - $this->assertInstanceOf(ItemInterface::class, $this->store->getItem('key')); - } - - public function testDeleteReturnsBoolean() - { - $this->store->get('key', fn () => 'value'); - $this->assertTrue($this->store->delete('key')); - } - - public function testClearReturnsBoolean() - { - $this->store->get('key', fn () => 'value'); - $this->assertTrue($this->store->clear()); - } - - public function testInvalidStoreNameThrowsException() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cache store [invalid] is not defined.'); - - Cache::resolveStoreClass('invalid'); - } - - protected function getStoreInstance(): StoreInterface - { - return Cache::create(config: ['namespace' => 'CacheTest']); - } -} diff --git a/tests/Cache/FileCacheTest.php b/tests/Cache/FileCacheTest.php new file mode 100644 index 0000000..2d236f3 --- /dev/null +++ b/tests/Cache/FileCacheTest.php @@ -0,0 +1,159 @@ +cache = new FileCache('FileCacheTest'); + // $this->cache->clear(); + } + + public function tearDown(): void + { + // $this->cache->clear(); + } + + public function testGetReturnsNullWhenNotSet(): void + { + $this->assertNull($this->cache->get('foo')); + } + + public function testGetReturnsValueWhenSet(): void + { + $this->cache->set('foo', 'bar'); + + $this->assertSame('bar', $this->cache->get('foo')); + } + + public function testGetReturnsDefaultWhenNotSet(): void + { + $this->assertSame('bar', $this->cache->get('foo', 'bar')); + } + + public function testCacheWithLifetime(): void + { + $this->cache->set('foo', 'bar', 1); + + $this->assertSame('bar', $this->cache->get('foo', 'baz')); + + sleep(2); + + $this->assertSame('baz', $this->cache->get('foo', 'baz')); + } + + public function testHas(): void + { + $this->assertFalse($this->cache->has('foo')); + + $this->cache->set('foo', 'bar'); + + $this->assertTrue($this->cache->has('foo')); + } + + public function testDelete(): void + { + $this->cache->set('foo', 'bar'); + + $this->assertTrue($this->cache->has('foo')); + + $this->cache->delete('foo'); + + $this->assertFalse($this->cache->has('foo')); + } + + public function testClear(): void + { + $this->cache->set('foo', 'bar'); + $this->cache->set('baz', 'qux'); + + $this->assertTrue($this->cache->has('foo')); + $this->assertTrue($this->cache->has('baz')); + + $this->cache->clear(); + + $this->assertFalse($this->cache->has('foo')); + $this->assertFalse($this->cache->has('baz')); + } + + public function testGetMultiple(): void + { + $this->cache->set('foo', 'bar'); + $this->cache->set('baz', 'qux'); + + $this->assertSame(['foo' => 'bar', 'baz' => 'qux'], iterator_to_array($this->cache->getMultiple(['foo', 'baz']))); + } + + public function testSetMultiple(): void + { + $this->assertTrue($this->cache->setMultiple(['foo' => 'bar', 'baz' => 'qux'])); + + $this->assertSame('bar', $this->cache->get('foo')); + $this->assertSame('qux', $this->cache->get('baz')); + } + + public function testDeleteMultiple(): void + { + $this->cache->set('foo', 'bar'); + $this->cache->set('baz', 'qux'); + + $this->assertTrue($this->cache->deleteMultiple(['foo', 'baz'])); + + $this->assertFalse($this->cache->has('foo')); + $this->assertFalse($this->cache->has('baz')); + } + + public function testSetMultipleWithTtl(): void + { + $this->assertTrue($this->cache->setMultiple(['foo' => 'bar', 'baz' => 'qux'], 1)); + + $this->assertSame('bar', $this->cache->get('foo')); + $this->assertSame('qux', $this->cache->get('baz')); + + sleep(2); + + $this->assertNull($this->cache->get('foo')); + $this->assertNull($this->cache->get('baz')); + } + + public function testSetMultipleWithDateInterval(): void + { + $this->assertTrue($this->cache->setMultiple(['foo' => 'bar', 'baz' => 'qux'], new DateInterval('PT1S'))); + + $this->assertSame('bar', $this->cache->get('foo')); + $this->assertSame('qux', $this->cache->get('baz')); + + sleep(2); + + $this->assertNull($this->cache->get('foo')); + $this->assertNull($this->cache->get('baz')); + } + + public function testThrowsExceptionOnInvalidNamespace(): void + { + $this->expectException(InvalidArgumentException::class); + + new FileCache('InvalidNamespace/WithSlash'); + } + + public function testThrowsExceptionOnInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->cache->set('InvalidKey/WithSlash', 'bar'); + } + + public function testThrowsExceptionOnInvalidKeyInSetMultiple(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->cache->setMultiple(['InvalidKey/WithSlash' => 'bar']); + } +} \ No newline at end of file diff --git a/tests/Cache/Stores/FileStoreTest.php b/tests/Cache/Stores/FileStoreTest.php deleted file mode 100644 index 7a622de..0000000 --- a/tests/Cache/Stores/FileStoreTest.php +++ /dev/null @@ -1,80 +0,0 @@ -store = $this->getFileStoreInstance(); - - $this->store->clear(); - } - - protected function tearDown(): void - { - parent::tearDown(); - - $this->store->clear(); - } - - public function testInstanceOfCacheInterface() - { - $this->assertInstanceOf(CacheInterface::class, $this->store); - } - - public function testGetCallbackStoresAndReturnsValue() - { - $this->assertSame($this->store->get('key', fn () => 'value'), 'value'); - } - - public function testSetMethodStoresValue() - { - $this->store->set('key', 'value'); - - $this->assertSame($this->store->get('key', fn () => 'default'), 'value'); - } - - public function testItReturnsNullWhenKeyDoesNotExist() - { - $this->assertNull($this->store->getItem('non-existent-key')->get()); - } - - public function testClearMethodRemovesAllItems() - { - $this->store->set('key1', 'value1'); - $this->store->set('key2', 'value2'); - - $this->store->clear(); - - $this->assertNull($this->store->getItem('key1')->get()); - $this->assertNull($this->store->getItem('key2')->get()); - } - - public function testDeleteMethodRemovesSpecificItem() - { - $this->store->set('key', 'value'); - $this->store->delete('key'); - - $this->assertNull($this->store->getItem('key')->get()); - } - - public function testClearMethodReturnsBoolean() - { - $this->store->set('key', 'value'); - $this->assertTrue($this->store->clear()); - } - - protected function getFileStoreInstance(): CacheInterface - { - return FileStore::create(namespace: 'FileStoreTest'); - } -} From 3ea7c6703d7cf167311028e7abb81db0a9698637 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Sat, 11 Jan 2025 22:57:35 +0000 Subject: [PATCH 15/22] feature/initial-cache-implementation - update Safe method namespace --- src/Cache/FileCache.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php index a73c144..193bb79 100644 --- a/src/Cache/FileCache.php +++ b/src/Cache/FileCache.php @@ -26,7 +26,7 @@ public function __construct( } if (!is_dir($directory)) { - \Safe\mkdir($directory, 0755, true); + mkdir($directory, 0755, true); } $this->directory = $directory .= DIRECTORY_SEPARATOR; @@ -49,7 +49,7 @@ public function get(string $key, mixed $default = null): mixed private function getValueObject(string $key): ?CacheValue { try { - $value = unserialize(\Safe\file_get_contents($this->getFilePath($key))); + $value = unserialize(\PhpSpellcheck\file_get_contents($this->getFilePath($key))); return $value instanceof CacheValue ? $value : null; } catch (\Throwable) { @@ -75,14 +75,14 @@ public function set(string $key, mixed $value, null|int|\DateInterval $ttl = nul $ttl ??= $this->defaultLifetime; if ($ttl instanceof \DateInterval) { - $expiresAt = (new \Safe\DateTime())->add($ttl)->getTimestamp(); + $expiresAt = (new \DateTime())->add($ttl)->getTimestamp(); } else { $expiresAt = $ttl > 0 ? time() + $ttl : null; } $data = new CacheValue($value, $expiresAt); - return (bool) \Safe\file_put_contents($this->getFilePath($key), $data->serialize(), LOCK_EX); + return (bool) \PhpSpellcheck\file_put_contents($this->getFilePath($key), $data->serialize(), LOCK_EX); } public function delete(string $key): bool @@ -91,21 +91,21 @@ public function delete(string $key): bool return false; } - \Safe\unlink($this->getFilePath($key)); + unlink($this->getFilePath($key)); return true; } public function clear(): bool { - $files = \Safe\glob($this->directory.'*'); + $files = glob($this->directory.'*'); if (empty($files)) { return false; } foreach ($files as $file) { - \Safe\unlink($file); + unlink($file); } return true; @@ -150,14 +150,14 @@ public function getFilePath(string $key): string private function validateNamespace(string $namespace): void { - if (\Safe\preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match) === 1) { + if (\PhpSpellcheck\preg_match('#[^-+_.A-Za-z0-9]#', $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 (\Safe\preg_match('/^[a-zA-Z0-9_\.]+$/', $key) === 0) { + 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 (.).', From 4d3103377a009506ded012afe97ded97fd89f5fd Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Sun, 12 Jan 2025 08:49:17 +0000 Subject: [PATCH 16/22] feature/initial-cache-implementation - minor changes --- src/Cache/FileCache.php | 16 +++++++----- src/Spellchecker/CacheableSpellchecker.php | 20 +++++++-------- tests/Cache/FileCacheTest.php | 13 ++++++++-- .../CacheableSpellcheckerTest.php | 25 +++++++++++++++++++ 4 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 tests/Spellchecker/CacheableSpellcheckerTest.php diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php index 193bb79..f9dff92 100644 --- a/src/Cache/FileCache.php +++ b/src/Cache/FileCache.php @@ -32,6 +32,11 @@ public function __construct( $this->directory = $directory .= DIRECTORY_SEPARATOR; } + public static function create(string $namespace = '', int $defaultLifetime = 0, ?string $directory = null): CacheInterface + { + return new self($namespace, $defaultLifetime, $directory); + } + public function getDefaultDirectory(): string { return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; @@ -91,24 +96,23 @@ public function delete(string $key): bool return false; } - unlink($this->getFilePath($key)); - - return true; + return unlink($this->getFilePath($key)); } public function clear(): bool { $files = glob($this->directory.'*'); - if (empty($files)) { + if ($files === false || empty($files)) { return false; } + $result = true; foreach ($files as $file) { - unlink($file); + $result = unlink($file); } - return true; + return $result; } public function getMultiple(iterable $keys, mixed $default = null): iterable diff --git a/src/Spellchecker/CacheableSpellchecker.php b/src/Spellchecker/CacheableSpellchecker.php index e1b3231..f9a661b 100644 --- a/src/Spellchecker/CacheableSpellchecker.php +++ b/src/Spellchecker/CacheableSpellchecker.php @@ -4,19 +4,15 @@ namespace PhpSpellcheck\Spellchecker; -use ArrayObject; use PhpSpellcheck\Cache\CacheInterface; -final class CacheableSpellchecker implements SpellcheckerInterface +final readonly class CacheableSpellchecker implements SpellcheckerInterface { public function __construct( - private readonly SpellcheckerInterface $spellChecker, - private readonly CacheInterface $cache + private SpellcheckerInterface $spellChecker, + private CacheInterface $cache ) {} - /** - * @return iterable<\PhpSpellcheck\MisspellingInterface> - */ public function check(string $text, array $languages = [], array $context = []): iterable { $key = $this->generateCacheKey($text, $languages); @@ -25,12 +21,16 @@ public function check(string $text, array $languages = [], array $context = []): if ($result === null) { $result = $this->spellChecker->check($text, $languages, $context); - $this->cache->set($key, $result); - return $result; + $resultArray = iterator_to_array($result); + + $this->cache->set($key, $resultArray); + + return $resultArray; } - return (new ArrayObject((array) $result))->getIterator(); + // @todo Convert array to iterable + return $result; } /** diff --git a/tests/Cache/FileCacheTest.php b/tests/Cache/FileCacheTest.php index 2d236f3..71d5987 100644 --- a/tests/Cache/FileCacheTest.php +++ b/tests/Cache/FileCacheTest.php @@ -13,12 +13,12 @@ class FileCacheTest extends TestCase public function setUp(): void { $this->cache = new FileCache('FileCacheTest'); - // $this->cache->clear(); + $this->cache->clear(); } public function tearDown(): void { - // $this->cache->clear(); + $this->cache->clear(); } public function testGetReturnsNullWhenNotSet(): void @@ -156,4 +156,13 @@ public function testThrowsExceptionOnInvalidKeyInSetMultiple(): void $this->cache->setMultiple(['InvalidKey/WithSlash' => 'bar']); } + + public function testCachesInvalidCharactersPassesWithMd5(): void + { + $key = md5('InvalidKey/WithSlash'); + + $this->cache->set($key, 'bar'); + + $this->assertSame('bar', $this->cache->get($key)); + } } \ No newline at end of file diff --git a/tests/Spellchecker/CacheableSpellcheckerTest.php b/tests/Spellchecker/CacheableSpellcheckerTest.php new file mode 100644 index 0000000..d4494d3 --- /dev/null +++ b/tests/Spellchecker/CacheableSpellcheckerTest.php @@ -0,0 +1,25 @@ +check('hello'); + + $this->assertCount(6, iterator_to_array($result)); + } +} \ No newline at end of file From c596eeccc7ff2bdbf74e616457d1440fd57d4317 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Sun, 12 Jan 2025 12:25:22 +0000 Subject: [PATCH 17/22] feature/initial-cache-implementation - minor changes --- src/Spellchecker/CacheableSpellchecker.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Spellchecker/CacheableSpellchecker.php b/src/Spellchecker/CacheableSpellchecker.php index f9a661b..460ccbc 100644 --- a/src/Spellchecker/CacheableSpellchecker.php +++ b/src/Spellchecker/CacheableSpellchecker.php @@ -4,6 +4,7 @@ namespace PhpSpellcheck\Spellchecker; +use ArrayIterator; use PhpSpellcheck\Cache\CacheInterface; final readonly class CacheableSpellchecker implements SpellcheckerInterface @@ -23,14 +24,13 @@ public function check(string $text, array $languages = [], array $context = []): $result = $this->spellChecker->check($text, $languages, $context); $resultArray = iterator_to_array($result); - $this->cache->set($key, $resultArray); - return $resultArray; + return $result; } // @todo Convert array to iterable - return $result; + return $result; } /** From 5665dd3b4bcf977c50a2be90b737985e049bb750 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Tue, 14 Jan 2025 08:11:43 +0000 Subject: [PATCH 18/22] feature/initial-cache-implementation - refactor to psr/cache implementation --- docker-compose.yml | 1 + src/Cache/CacheInterface.php | 12 -- src/Cache/CacheItem.php | 76 +++++++ src/Cache/CacheValue.php | 26 --- src/Cache/FileCache.php | 188 +++++++++++------- src/Cache/FileCacheInterface.php | 10 + src/Spellchecker/CacheableSpellchecker.php | 57 +++--- tests/Cache/FileCacheTest.php | 177 ++++++++--------- .../CacheableSpellcheckerTest.php | 50 ++++- 9 files changed, 361 insertions(+), 236 deletions(-) delete mode 100644 src/Cache/CacheInterface.php create mode 100644 src/Cache/CacheItem.php delete mode 100644 src/Cache/CacheValue.php create mode 100644 src/Cache/FileCacheInterface.php diff --git a/docker-compose.yml b/docker-compose.yml index 053bcd9..5d2fc23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: php: image: tigitz/phpspellchecker:${PHP_VERSION:-8.4} + user: 1001:1001 build: context: docker/php args: diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php deleted file mode 100644 index 1877f29..0000000 --- a/src/Cache/CacheInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -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; + } +} \ No newline at end of file diff --git a/src/Cache/CacheValue.php b/src/Cache/CacheValue.php deleted file mode 100644 index 4bd1846..0000000 --- a/src/Cache/CacheValue.php +++ /dev/null @@ -1,26 +0,0 @@ -expiresAt !== null && $this->expiresAt < time(); - } - - public function isValid(): bool - { - return !$this->isExpired(); - } - - public function serialize(): string - { - return serialize($this); - } -} \ No newline at end of file diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php index f9dff92..3ec9cc7 100644 --- a/src/Cache/FileCache.php +++ b/src/Cache/FileCache.php @@ -4,13 +4,22 @@ namespace PhpSpellcheck\Cache; +use Psr\Cache\CacheItemInterface; use Composer\Autoload\ClassLoader; +use PhpSpellcheck\Exception\RuntimeException; use PhpSpellcheck\Exception\InvalidArgumentException; -final class FileCache implements CacheInterface +final class FileCache implements FileCacheInterface { + 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 string $namespace = '@', private readonly int $defaultLifetime = 0, private ?string $directory = null, ) { @@ -18,133 +27,166 @@ public function __construct( $directory = $this->getDefaultDirectory(); } - if (strlen($namespace) > 0) { - $this->validateNamespace($namespace); - $directory .= DIRECTORY_SEPARATOR . $namespace; - } else { - $directory .= DIRECTORY_SEPARATOR . '@'; - } + $this->validateNamespace($namespace); - if (!is_dir($directory)) { - mkdir($directory, 0755, true); + $directory .= DIRECTORY_SEPARATOR . $namespace; + + if (!is_dir($directory) && !@mkdir($directory, 0777, 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): CacheInterface - { + public static function create( + string $namespace = '@', + int $defaultLifetime = 0, + ?string $directory = null + ): self { return new self($namespace, $defaultLifetime, $directory); } - public function getDefaultDirectory(): string + public function getItem(string $key): CacheItemInterface { - return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; - } + $this->validateKey($key); + $filepath = $this->getFilePath($key); - public function get(string $key, mixed $default = null): mixed - { - if (!$this->has($key)) { - return $default; + $item = new CacheItem($key); + + if (!file_exists($filepath)) { + return $item; } - return $this->getValueObject($key)?->value; + $handle = fopen($filepath, 'r'); + if (flock($handle, LOCK_SH)) { // Shared lock for reading + try { + $data = fread($handle, filesize($filepath)); + $value = unserialize($data); + + if ($value && (!$value->expiresAt || $value->expiresAt > time())) { + $item->set($value->data); + $item->setIsHit(true); + if ($value->expiresAt) { + $item->expiresAt(new \DateTime('@' . $value->expiresAt)); + } + } + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + return $item; } - private function getValueObject(string $key): ?CacheValue + public function getItems(array $keys = []): iterable { - try { - $value = unserialize(\PhpSpellcheck\file_get_contents($this->getFilePath($key))); + return array_map(fn ($key) => $this->getItem($key), $keys); + } - return $value instanceof CacheValue ? $value : null; - } catch (\Throwable) { - return null; - } + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); } - public function has(string $key): bool + public function clear(): bool { - if (!file_exists($this->getFilePath($key))) { + $this->deferred = []; + $files = glob($this->directory.'*'); + + if ($files === false || empty($files)) { return false; } - $object = $this->getValueObject($key); + $result = true; + foreach ($files as $file) { + $result = unlink($file) && $result; + } - return $object !== null && $object->isValid(); + return $result; } - public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + public function deleteItem(string $key): bool { $this->validateKey($key); + unset($this->deferred[$key]); - $ttl ??= $this->defaultLifetime; - - if ($ttl instanceof \DateInterval) { - $expiresAt = (new \DateTime())->add($ttl)->getTimestamp(); - } else { - $expiresAt = $ttl > 0 ? time() + $ttl : null; + if (!file_exists($this->getFilePath($key))) { + return true; } - $data = new CacheValue($value, $expiresAt); - - return (bool) \PhpSpellcheck\file_put_contents($this->getFilePath($key), $data->serialize(), LOCK_EX); + return unlink($this->getFilePath($key)); } - public function delete(string $key): bool + public function deleteItems(array $keys): bool { - if (!$this->has($key)) { - return false; + $result = true; + foreach ($keys as $key) { + $result = $this->deleteItem($key) && $result; } - return unlink($this->getFilePath($key)); + return $result; } - public function clear(): bool + public function save(CacheItemInterface $item): bool { - $files = glob($this->directory.'*'); + $this->validateKey($item->getKey()); - if ($files === false || empty($files)) { + $expiresAt = null; + if ($item->expiry) { + $expiresAt = $item->expiry->getTimestamp(); + } elseif ($this->defaultLifetime > 0) { + $expiresAt = time() + $this->defaultLifetime; + } + + $value = (object) [ + 'data' => $item->get(), + 'expiresAt' => $expiresAt, + ]; + + $serialized = serialize($value); + $filepath = $this->getFilePath($item->getKey()); + + $handle = fopen($filepath, 'w'); + if (!$handle) { return false; } - $result = true; - foreach ($files as $file) { - $result = unlink($file); + $success = false; + if (flock($handle, LOCK_EX)) { // Exclusive lock for writing + try { + $success = fwrite($handle, $serialized) !== false; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } } - return $result; + return $success; } - public function getMultiple(iterable $keys, mixed $default = null): iterable + public function saveDeferred(CacheItemInterface $item): bool { - foreach ($keys as $key) { - yield $key => $this->get($key, $default); - } + $this->validateKey($item->getKey()); + $this->deferred[$item->getKey()] = $item; + + return true; } - /** - * @param iterable $values - */ - public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + public function commit(): bool { - $result = true; - foreach ($values as $key => $value) { - if (is_string($key)) { - $result = $this->set($key, $value, $ttl) && $result; - } + $success = true; + foreach ($this->deferred as $item) { + $success = $this->save($item) && $success; } + $this->deferred = []; - return $result; + return $success; } - public function deleteMultiple(iterable $keys): bool + private function getDefaultDirectory(): string { - $result = true; - foreach ($keys as $key) { - $result = $this->delete($key) && $result; - } - - return $result; + return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; } public function getFilePath(string $key): string diff --git a/src/Cache/FileCacheInterface.php b/src/Cache/FileCacheInterface.php new file mode 100644 index 0000000..6d9412b --- /dev/null +++ b/src/Cache/FileCacheInterface.php @@ -0,0 +1,10 @@ +generateCacheKey($text, $languages); - - $result = $this->cache->get($key); + private readonly CacheItemPoolInterface $cache, + private readonly SpellcheckerInterface $spellchecker + ) { + } - if ($result === null) { - $result = $this->spellChecker->check($text, $languages, $context); + public function check( + string $text, + array $languages = [], + array $context = [] + ): iterable { + $cacheKey = md5(serialize([$this->spellchecker, $text, $languages, $context])); - $resultArray = iterator_to_array($result); - $this->cache->set($key, $resultArray); + $cacheItem = $this->cache->getItem($cacheKey); - return $result; + if ($cacheItem->isHit()) { + yield from $cacheItem->get(); + return; } - // @todo Convert array to iterable - return $result; - } + $misspellings = iterator_to_array($this->spellchecker->check($text, $languages, $context)); + $this->cache->save($cacheItem->set($misspellings)); - /** - * @param array $languages - */ - private function generateCacheKey(string $text, array $languages = []): string - { - return md5(sprintf('%s_%s', $text, implode('_', $languages))); + yield from $misspellings; } public function getSupportedLanguages(): iterable { - return $this->spellChecker->getSupportedLanguages(); + $cacheKey = md5(serialize([$this->spellchecker])); + + $cacheItem = $this->cache->getItem($cacheKey); + + if ($cacheItem->isHit()) { + yield from $cacheItem->get(); + return; + } + + $languages = iterator_to_array($this->spellchecker->getSupportedLanguages()); + $this->cache->save($cacheItem->set($languages)); + + yield from $languages; } } \ No newline at end of file diff --git a/tests/Cache/FileCacheTest.php b/tests/Cache/FileCacheTest.php index 71d5987..f0a434b 100644 --- a/tests/Cache/FileCacheTest.php +++ b/tests/Cache/FileCacheTest.php @@ -4,11 +4,11 @@ use PHPUnit\Framework\TestCase; use PhpSpellcheck\Cache\FileCache; -use PhpSpellcheck\Cache\CacheInterface; +use PhpSpellcheck\Cache\FileCacheInterface; class FileCacheTest extends TestCase { - protected CacheInterface $cache; + protected FileCacheInterface $cache; public function setUp(): void { @@ -21,148 +21,143 @@ public function tearDown(): void $this->cache->clear(); } - public function testGetReturnsNullWhenNotSet(): void + public function testCreateReturnsFileCacheInstance(): void { - $this->assertNull($this->cache->get('foo')); + $cache = FileCache::create('FileCacheTest'); + $this->assertInstanceOf(FileCache::class, $cache); } - public function testGetReturnsValueWhenSet(): void + public function testGetItemReturnsNonExistentItem(): void { - $this->cache->set('foo', 'bar'); - - $this->assertSame('bar', $this->cache->get('foo')); - } - - public function testGetReturnsDefaultWhenNotSet(): void - { - $this->assertSame('bar', $this->cache->get('foo', 'bar')); + $item = $this->cache->getItem('key1'); + $this->assertFalse($item->isHit()); + $this->assertNull($item->get()); } - public function testCacheWithLifetime(): void + public function testSaveAndGetItem(): void { - $this->cache->set('foo', 'bar', 1); - - $this->assertSame('bar', $this->cache->get('foo', 'baz')); + $item = $this->cache->getItem('key2'); + $item->set('value2'); - sleep(2); + $this->cache->save($item); - $this->assertSame('baz', $this->cache->get('foo', 'baz')); + $newItem = $this->cache->getItem('key2'); + $this->assertTrue($newItem->isHit()); + $this->assertEquals('value2', $newItem->get()); } - public function testHas(): void + public function testDeleteItem(): void { - $this->assertFalse($this->cache->has('foo')); + $item = $this->cache->getItem('key3'); + $item->set('value3'); + $this->cache->save($item); - $this->cache->set('foo', 'bar'); - - $this->assertTrue($this->cache->has('foo')); + $this->assertTrue($this->cache->deleteItem('key3')); + $this->assertFalse($this->cache->hasItem('key3')); } - public function testDelete(): void + public function testSaveDeferred(): void { - $this->cache->set('foo', 'bar'); - - $this->assertTrue($this->cache->has('foo')); + $item = $this->cache->getItem('key4'); + $item->set('value4'); - $this->cache->delete('foo'); + $this->cache->saveDeferred($item); + $this->assertFalse($this->cache->hasItem('key4')); - $this->assertFalse($this->cache->has('foo')); + $this->cache->commit(); + $this->assertTrue($this->cache->hasItem('key4')); } - public function testClear(): void + public function testClearCache(): void { - $this->cache->set('foo', 'bar'); - $this->cache->set('baz', 'qux'); - - $this->assertTrue($this->cache->has('foo')); - $this->assertTrue($this->cache->has('baz')); - - $this->cache->clear(); - - $this->assertFalse($this->cache->has('foo')); - $this->assertFalse($this->cache->has('baz')); - } + $item1 = $this->cache->getItem('key5'); + $item1->set('value5'); + $this->cache->save($item1); - public function testGetMultiple(): void - { - $this->cache->set('foo', 'bar'); - $this->cache->set('baz', 'qux'); + $item2 = $this->cache->getItem('key6'); + $item2->set('value6'); + $this->cache->save($item2); - $this->assertSame(['foo' => 'bar', 'baz' => 'qux'], iterator_to_array($this->cache->getMultiple(['foo', 'baz']))); + $this->assertTrue($this->cache->clear()); + $this->assertFalse($this->cache->hasItem('key5')); + $this->assertFalse($this->cache->hasItem('key6')); } - public function testSetMultiple(): void + public function testGetItems(): void { - $this->assertTrue($this->cache->setMultiple(['foo' => 'bar', 'baz' => 'qux'])); + $keys = ['key7', 'key8']; + $items = $this->cache->getItems($keys); - $this->assertSame('bar', $this->cache->get('foo')); - $this->assertSame('qux', $this->cache->get('baz')); + foreach ($items as $item) { + $this->assertFalse($item->isHit()); + } } - public function testDeleteMultiple(): void + public function testDeleteItems(): void { - $this->cache->set('foo', 'bar'); - $this->cache->set('baz', 'qux'); + $item1 = $this->cache->getItem('key9'); + $item1->set('value9'); + $this->cache->save($item1); - $this->assertTrue($this->cache->deleteMultiple(['foo', 'baz'])); + $item2 = $this->cache->getItem('key10'); + $item2->set('value10'); + $this->cache->save($item2); - $this->assertFalse($this->cache->has('foo')); - $this->assertFalse($this->cache->has('baz')); + $this->assertTrue($this->cache->deleteItems(['key9', 'key10'])); + $this->assertFalse($this->cache->hasItem('key9')); + $this->assertFalse($this->cache->hasItem('key10')); } - public function testSetMultipleWithTtl(): void + public function testItemExpiration(): void { - $this->assertTrue($this->cache->setMultiple(['foo' => 'bar', 'baz' => 'qux'], 1)); - - $this->assertSame('bar', $this->cache->get('foo')); - $this->assertSame('qux', $this->cache->get('baz')); + $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->assertNull($this->cache->get('foo')); - $this->assertNull($this->cache->get('baz')); + $this->assertFalse($this->cache->getItem('expiring_key')->isHit()); } - public function testSetMultipleWithDateInterval(): void + public function testInvalidNamespaceThrowsException(): void { - $this->assertTrue($this->cache->setMultiple(['foo' => 'bar', 'baz' => 'qux'], new DateInterval('PT1S'))); - - $this->assertSame('bar', $this->cache->get('foo')); - $this->assertSame('qux', $this->cache->get('baz')); - - sleep(2); - - $this->assertNull($this->cache->get('foo')); - $this->assertNull($this->cache->get('baz')); + $this->expectException(\PhpSpellcheck\Exception\InvalidArgumentException::class); + new FileCache('Invalid/Namespace'); } - public function testThrowsExceptionOnInvalidNamespace(): void + public function testInvalidKeyThrowsException(): void { - $this->expectException(InvalidArgumentException::class); - - new FileCache('InvalidNamespace/WithSlash'); + $this->expectException(\PhpSpellcheck\Exception\InvalidArgumentException::class); + $this->cache->getItem('invalid/key'); } - public function testThrowsExceptionOnInvalidKey(): void + public function testDefaultLifetime(): void { - $this->expectException(InvalidArgumentException::class); + $cache = new FileCache('FileCacheTest', 1); + $item = $cache->getItem('key'); + $item->set('value'); + $cache->save($item); - $this->cache->set('InvalidKey/WithSlash', 'bar'); + $this->assertTrue($cache->hasItem('key')); + sleep(2); + $this->assertFalse($cache->getItem('key')->isHit()); } - public function testThrowsExceptionOnInvalidKeyInSetMultiple(): void + public function testCustomDirectory(): void { - $this->expectException(InvalidArgumentException::class); + $cache = new FileCache('FileCacheTest', 0, '/tmp'); + $item = $cache->getItem('key'); + $item->set('value'); + $cache->save($item); - $this->cache->setMultiple(['InvalidKey/WithSlash' => 'bar']); + $this->assertTrue(file_exists('/tmp/FileCacheTest/key')); + $cache->clear(); } - public function testCachesInvalidCharactersPassesWithMd5(): void + public function testUnwriteableDirectoryThrowsException(): void { - $key = md5('InvalidKey/WithSlash'); - - $this->cache->set($key, 'bar'); - - $this->assertSame('bar', $this->cache->get($key)); + $this->expectException(\PhpSpellcheck\Exception\RuntimeException::class); + new FileCache('FileCacheTest', 0, '/root/.cache'); } } \ No newline at end of file diff --git a/tests/Spellchecker/CacheableSpellcheckerTest.php b/tests/Spellchecker/CacheableSpellcheckerTest.php index d4494d3..7809083 100644 --- a/tests/Spellchecker/CacheableSpellcheckerTest.php +++ b/tests/Spellchecker/CacheableSpellcheckerTest.php @@ -2,24 +2,58 @@ declare(strict_types=1); -use PhpSpellcheck\Cache\FileCache; use PHPUnit\Framework\TestCase; +use PhpSpellcheck\Cache\FileCache; use PhpSpellcheck\Spellchecker\Aspell; +use PhpSpellcheck\Cache\FileCacheInterface; use PhpSpellcheck\Spellchecker\CacheableSpellchecker; class CacheableSpellcheckerTest extends TestCase { private const FAKE_BINARIES_PATH = __DIR__ . '/../Fixtures/Aspell/bin/aspell.sh'; - public function testAspellSpellcheck(): void + protected FileCacheInterface $cache; + protected CacheableSpellchecker $cacheableSpellchecker; + + public function setUp(): void { - $checker = new CacheableSpellchecker( - Aspell::create(self::FAKE_BINARIES_PATH), - FileCache::create('CacheableSpellcheckerTest') - ); + $this->cache = new FileCache('CacheableSpellcheckerTest'); + $this->cache->clear(); + + $spellchecker = Aspell::create(self::FAKE_BINARIES_PATH); + $this->cacheableSpellchecker = new CacheableSpellchecker($this->cache, $spellchecker); + } - $result = $checker->check('hello'); + 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'])); - $this->assertCount(6, iterator_to_array($result)); + foreach ($result1 as $misspelling) { + $this->assertNotSame($misspelling, $result2); + } } } \ No newline at end of file From 9a64c36f32c15c9fc10a8a5bebcdc44d0345765d Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Tue, 14 Jan 2025 08:13:25 +0000 Subject: [PATCH 19/22] feature/initial-cache-implementation - refactor to psr/cache implementation --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d3db558..1e55e70 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "php": "^8.2", "nyholm/psr7": "^1.3", "psr/http-client": "^1.0", - "psr/simple-cache": "^3.0", + "psr/cache": "^3.0", "symfony/process": "^6.4 | ^7", "webmozart/assert": "^1.11" }, From 51ab8f3cc4848a5ff220e67a5a2cf2da732a6301 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Wed, 15 Jan 2025 09:31:02 +0000 Subject: [PATCH 20/22] feature/initial-cache-implementation - phpstan fixes --- docker-compose.yml | 1 - src/Cache/CacheItem.php | 2 +- src/Cache/FileCache.php | 88 ++++++++++++---------- src/Spellchecker/CacheableSpellchecker.php | 13 +++- tests/Cache/FileCacheTest.php | 16 +++- 5 files changed, 74 insertions(+), 46 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5d2fc23..053bcd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: php: image: tigitz/phpspellchecker:${PHP_VERSION:-8.4} - user: 1001:1001 build: context: docker/php args: diff --git a/src/Cache/CacheItem.php b/src/Cache/CacheItem.php index 493e3d5..2b3c332 100644 --- a/src/Cache/CacheItem.php +++ b/src/Cache/CacheItem.php @@ -14,7 +14,7 @@ public function __construct( private readonly string $key, private mixed $value = null, public ?DateTimeInterface $expiry = null, - private ?bool $isHit = false + private bool $isHit = false ) { } diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php index 3ec9cc7..9b24c6d 100644 --- a/src/Cache/FileCache.php +++ b/src/Cache/FileCache.php @@ -11,6 +11,9 @@ final class FileCache implements FileCacheInterface { + /** + * @var array + */ private array $deferred = []; /** @@ -27,7 +30,7 @@ public function __construct( $directory = $this->getDefaultDirectory(); } - $this->validateNamespace($namespace); + $this->validateNamespace(); $directory .= DIRECTORY_SEPARATOR . $namespace; @@ -57,31 +60,46 @@ public function getItem(string $key): CacheItemInterface return $item; } - $handle = fopen($filepath, 'r'); - if (flock($handle, LOCK_SH)) { // Shared lock for reading - try { - $data = fread($handle, filesize($filepath)); - $value = unserialize($data); + $data = \PhpSpellcheck\file_get_contents($filepath); + + if ($data === '') { + return $item; + } + + $value = unserialize($data); - if ($value && (!$value->expiresAt || $value->expiresAt > time())) { - $item->set($value->data); - $item->setIsHit(true); - if ($value->expiresAt) { - $item->expiresAt(new \DateTime('@' . $value->expiresAt)); - } - } - } finally { - flock($handle, LOCK_UN); - fclose($handle); - } + 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) => $this->getItem($key), $keys); + return array_map(fn ($key): CacheItemInterface => $this->getItem($key), $keys); } public function hasItem(string $key): bool @@ -132,13 +150,16 @@ public function save(CacheItemInterface $item): bool { $this->validateKey($item->getKey()); - $expiresAt = null; - if ($item->expiry) { - $expiresAt = $item->expiry->getTimestamp(); - } elseif ($this->defaultLifetime > 0) { - $expiresAt = time() + $this->defaultLifetime; + 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, @@ -147,22 +168,11 @@ public function save(CacheItemInterface $item): bool $serialized = serialize($value); $filepath = $this->getFilePath($item->getKey()); - $handle = fopen($filepath, 'w'); - if (!$handle) { + try { + return (bool) \PhpSpellcheck\file_put_contents($filepath, $serialized, LOCK_EX); + } catch (\Exception $e) { return false; } - - $success = false; - if (flock($handle, LOCK_EX)) { // Exclusive lock for writing - try { - $success = fwrite($handle, $serialized) !== false; - } finally { - flock($handle, LOCK_UN); - fclose($handle); - } - } - - return $success; } public function saveDeferred(CacheItemInterface $item): bool @@ -194,9 +204,9 @@ public function getFilePath(string $key): string return $this->directory . $key; } - private function validateNamespace(string $namespace): void + private function validateNamespace(): void { - if (\PhpSpellcheck\preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match) === 1) { + 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])); } } diff --git a/src/Spellchecker/CacheableSpellchecker.php b/src/Spellchecker/CacheableSpellchecker.php index 7ffa2a0..6023b76 100644 --- a/src/Spellchecker/CacheableSpellchecker.php +++ b/src/Spellchecker/CacheableSpellchecker.php @@ -4,6 +4,7 @@ namespace PhpSpellcheck\Spellchecker; +use PhpSpellcheck\MisspellingInterface; use Psr\Cache\CacheItemPoolInterface; final readonly class CacheableSpellchecker implements SpellcheckerInterface @@ -24,7 +25,11 @@ public function check( $cacheItem = $this->cache->getItem($cacheKey); if ($cacheItem->isHit()) { - yield from $cacheItem->get(); + foreach ((array) $cacheItem->get() as $misspelling) { + if ($misspelling instanceof MisspellingInterface) { + yield $misspelling; + } + } return; } @@ -41,7 +46,11 @@ public function getSupportedLanguages(): iterable $cacheItem = $this->cache->getItem($cacheKey); if ($cacheItem->isHit()) { - yield from $cacheItem->get(); + foreach ((array) $cacheItem->get() as $language) { + if (is_string($language)) { + yield $language; + } + } return; } diff --git a/tests/Cache/FileCacheTest.php b/tests/Cache/FileCacheTest.php index f0a434b..e9d0e0a 100644 --- a/tests/Cache/FileCacheTest.php +++ b/tests/Cache/FileCacheTest.php @@ -42,6 +42,7 @@ public function testSaveAndGetItem(): void $this->cache->save($item); $newItem = $this->cache->getItem('key2'); + $this->assertTrue($newItem->isHit()); $this->assertEquals('value2', $newItem->get()); } @@ -155,9 +156,18 @@ public function testCustomDirectory(): void $cache->clear(); } - public function testUnwriteableDirectoryThrowsException(): void + public function testExpiredCachedFileIsDeletedWhenCallingGetItem(): void { - $this->expectException(\PhpSpellcheck\Exception\RuntimeException::class); - new FileCache('FileCacheTest', 0, '/root/.cache'); + $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->assertFalse(file_exists('/tmp/FileCacheTest/unlinked_key')); } } \ No newline at end of file From bd2f4c490593ed8bb6ecd7b93f90f2554a12886c Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Wed, 15 Jan 2025 09:38:25 +0000 Subject: [PATCH 21/22] feature/initial-cache-implementation - phpstan fixes --- src/Cache/CacheItem.php | 4 +-- src/Cache/FileCache.php | 29 ++++++++++--------- src/Cache/FileCacheInterface.php | 4 ++- src/Spellchecker/CacheableSpellchecker.php | 6 ++-- tests/Cache/FileCacheTest.php | 6 ++-- .../CacheableSpellcheckerTest.php | 3 +- 6 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/Cache/CacheItem.php b/src/Cache/CacheItem.php index 2b3c332..1ef43c0 100644 --- a/src/Cache/CacheItem.php +++ b/src/Cache/CacheItem.php @@ -55,7 +55,7 @@ public function expiresAfter(DateInterval|int|null $time): static return $this; } - if (is_int($time)) { + if (\is_int($time)) { $this->expiry = new \DateTime('@' . (time() + $time)); return $this; @@ -73,4 +73,4 @@ public function setIsHit(bool $hit): void { $this->isHit = $hit; } -} \ No newline at end of file +} diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php index 9b24c6d..5125376 100644 --- a/src/Cache/FileCache.php +++ b/src/Cache/FileCache.php @@ -19,7 +19,7 @@ final class FileCache implements FileCacheInterface /** * $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 + * $directory - Optional custom directory path for cache storage. */ public function __construct( private readonly string $namespace = '@', @@ -34,8 +34,8 @@ public function __construct( $directory .= DIRECTORY_SEPARATOR . $namespace; - if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) { - throw new RuntimeException(sprintf('Directory "%s" could not be created', $directory)); + 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; @@ -68,9 +68,9 @@ public function getItem(string $key): CacheItemInterface $value = unserialize($data); - if (! is_object($value) - || ! property_exists($value, 'data') - || ! property_exists($value, 'expiresAt') + if (!\is_object($value) + || !property_exists($value, 'data') + || !property_exists($value, 'expiresAt') ) { return $item; } @@ -86,7 +86,7 @@ public function getItem(string $key): CacheItemInterface $item->set($value->data)->setIsHit(true); - if (is_int($value->expiresAt) && $value->expiresAt > 0) { + if (\is_int($value->expiresAt) && $value->expiresAt > 0) { $item->expiresAt(new \DateTime('@' . $value->expiresAt)); } @@ -95,6 +95,7 @@ public function getItem(string $key): CacheItemInterface /** * @param array $keys + * * @return iterable */ public function getItems(array $keys = []): iterable @@ -150,7 +151,7 @@ public function save(CacheItemInterface $item): bool { $this->validateKey($item->getKey()); - if (! property_exists($item, 'expiry')) { + if (!property_exists($item, 'expiry')) { throw new InvalidArgumentException('CacheItem expiry property is required'); } @@ -194,20 +195,20 @@ public function commit(): bool return $success; } - private function getDefaultDirectory(): string + public function getFilePath(string $key): string { - return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache'; + return $this->directory . $key; } - public function getFilePath(string $key): string + private function getDefaultDirectory(): string { - return $this->directory . $key; + 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])); + throw new InvalidArgumentException(\sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); } } @@ -215,7 +216,7 @@ private function validateKey(string $key): void { if (\PhpSpellcheck\preg_match('/^[a-zA-Z0-9_\.]+$/', $key) === 0) { throw new InvalidArgumentException( - sprintf( + \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 index 6d9412b..2175270 100644 --- a/src/Cache/FileCacheInterface.php +++ b/src/Cache/FileCacheInterface.php @@ -1,5 +1,7 @@ isHit()) { foreach ((array) $cacheItem->get() as $language) { - if (is_string($language)) { + if (\is_string($language)) { yield $language; } } + return; } @@ -59,4 +61,4 @@ public function getSupportedLanguages(): iterable yield from $languages; } -} \ No newline at end of file +} diff --git a/tests/Cache/FileCacheTest.php b/tests/Cache/FileCacheTest.php index e9d0e0a..f4c95b9 100644 --- a/tests/Cache/FileCacheTest.php +++ b/tests/Cache/FileCacheTest.php @@ -152,7 +152,7 @@ public function testCustomDirectory(): void $item->set('value'); $cache->save($item); - $this->assertTrue(file_exists('/tmp/FileCacheTest/key')); + $this->assertFileExists('/tmp/FileCacheTest/key'); $cache->clear(); } @@ -168,6 +168,6 @@ public function testExpiredCachedFileIsDeletedWhenCallingGetItem(): void $cache->getItem('unlinked_key'); - $this->assertFalse(file_exists('/tmp/FileCacheTest/unlinked_key')); + $this->assertFileDoesNotExist('/tmp/FileCacheTest/unlinked_key'); } -} \ No newline at end of file +} diff --git a/tests/Spellchecker/CacheableSpellcheckerTest.php b/tests/Spellchecker/CacheableSpellcheckerTest.php index 7809083..c9517ee 100644 --- a/tests/Spellchecker/CacheableSpellcheckerTest.php +++ b/tests/Spellchecker/CacheableSpellcheckerTest.php @@ -13,6 +13,7 @@ class CacheableSpellcheckerTest extends TestCase private const FAKE_BINARIES_PATH = __DIR__ . '/../Fixtures/Aspell/bin/aspell.sh'; protected FileCacheInterface $cache; + protected CacheableSpellchecker $cacheableSpellchecker; public function setUp(): void @@ -56,4 +57,4 @@ public function testCheckWithDifferentParameters(): void $this->assertNotSame($misspelling, $result2); } } -} \ No newline at end of file +} From f21ccb5a60275949e3d6d24149d4cef68afb40e9 Mon Sep 17 00:00:00 2001 From: Chris Keller Date: Wed, 15 Jan 2025 09:58:07 +0000 Subject: [PATCH 22/22] feature/initial-cache-implementation - remove readonly class for php 8.1 compatability --- src/Spellchecker/CacheableSpellchecker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Spellchecker/CacheableSpellchecker.php b/src/Spellchecker/CacheableSpellchecker.php index 759d8b4..029a0b6 100644 --- a/src/Spellchecker/CacheableSpellchecker.php +++ b/src/Spellchecker/CacheableSpellchecker.php @@ -7,7 +7,7 @@ use PhpSpellcheck\MisspellingInterface; use Psr\Cache\CacheItemPoolInterface; -final readonly class CacheableSpellchecker implements SpellcheckerInterface +final class CacheableSpellchecker implements SpellcheckerInterface { public function __construct( private readonly CacheItemPoolInterface $cache,