Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 4ae6befc7b
422 changed files with 47225 additions and 0 deletions

View File

@@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
namespace KTXF\Cache\Store;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\CacheScope;
/**
* File-based Blob Cache Implementation
*
* Stores binary/media data with metadata in the storage directory.
* Directory structure: storage/{tid}/cache/{usage}/ or storage/{tid}/{uid}/cache/{usage}/
*/
class FileBlobCache implements BlobCacheInterface
{
private string $basePath;
private ?string $tenantId = null;
private ?string $userId = null;
public function __construct(string $projectDir)
{
$this->basePath = rtrim($projectDir, '/') . '/storage';
}
/**
* Set the tenant context for scoped operations
*/
public function setTenantContext(?string $tenantId): void
{
$this->tenantId = $tenantId;
}
/**
* Set the user context for scoped operations
*/
public function setUserContext(?string $userId): void
{
$this->userId = $userId;
}
/**
* @inheritDoc
*/
public function get(string $key, CacheScope $scope, string $usage): ?string
{
$path = $this->buildPath($key, $scope, $usage);
if (!$this->isValid($path)) {
return null;
}
$content = @file_get_contents($path);
return $content !== false ? $content : null;
}
/**
* @inheritDoc
*/
public function getStream(string $key, CacheScope $scope, string $usage)
{
$path = $this->buildPath($key, $scope, $usage);
if (!$this->isValid($path)) {
return null;
}
$handle = @fopen($path, 'rb');
return $handle !== false ? $handle : null;
}
/**
* @inheritDoc
*/
public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool
{
$path = $this->buildPath($key, $scope, $usage);
$dir = dirname($path);
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
}
// Write data file
$tempPath = $path . '.tmp.' . getmypid();
if (file_put_contents($tempPath, $data, LOCK_EX) === false) {
return false;
}
chmod($tempPath, 0600);
if (!rename($tempPath, $path)) {
@unlink($tempPath);
return false;
}
// Write metadata
$this->writeMetadata($path, [
'mimeType' => $mimeType,
'size' => strlen($data),
'createdAt' => time(),
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
]);
return true;
}
/**
* @inheritDoc
*/
public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool
{
$path = $this->buildPath($key, $scope, $usage);
$dir = dirname($path);
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
}
// Write data file from stream
$tempPath = $path . '.tmp.' . getmypid();
$dest = @fopen($tempPath, 'wb');
if ($dest === false) {
return false;
}
$size = 0;
while (!feof($stream)) {
$chunk = fread($stream, 8192);
if ($chunk === false) {
fclose($dest);
@unlink($tempPath);
return false;
}
$written = fwrite($dest, $chunk);
if ($written === false) {
fclose($dest);
@unlink($tempPath);
return false;
}
$size += $written;
}
fclose($dest);
chmod($tempPath, 0600);
if (!rename($tempPath, $path)) {
@unlink($tempPath);
return false;
}
// Write metadata
$this->writeMetadata($path, [
'mimeType' => $mimeType,
'size' => $size,
'createdAt' => time(),
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
]);
return true;
}
/**
* @inheritDoc
*/
public function has(string $key, CacheScope $scope, string $usage): bool
{
$path = $this->buildPath($key, $scope, $usage);
return $this->isValid($path);
}
/**
* @inheritDoc
*/
public function delete(string $key, CacheScope $scope, string $usage): bool
{
$path = $this->buildPath($key, $scope, $usage);
$metaPath = $path . '.meta';
$result = true;
if (file_exists($path)) {
$result = @unlink($path);
}
if (file_exists($metaPath)) {
@unlink($metaPath);
}
return $result;
}
/**
* @inheritDoc
*/
public function getPath(string $key, CacheScope $scope, string $usage): ?string
{
$path = $this->buildPath($key, $scope, $usage);
if (!$this->isValid($path)) {
return null;
}
return $path;
}
/**
* @inheritDoc
*/
public function getMetadata(string $key, CacheScope $scope, string $usage): ?array
{
$path = $this->buildPath($key, $scope, $usage);
if (!file_exists($path)) {
return null;
}
return $this->readMetadata($path);
}
/**
* @inheritDoc
*/
public function clear(CacheScope $scope, string $usage): int
{
$dir = $this->buildDir($scope, $usage);
if (!is_dir($dir)) {
return 0;
}
$count = 0;
$files = glob($dir . '/*');
foreach ($files as $file) {
if (is_file($file) && !str_ends_with($file, '.meta')) {
if (@unlink($file)) {
$count++;
// Also remove metadata file
@unlink($file . '.meta');
}
}
}
return $count;
}
/**
* @inheritDoc
*/
public function cleanup(): int
{
$count = 0;
$now = time();
// Scan all tenant directories
$tenantDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
foreach ($tenantDirs as $tenantDir) {
$files = $this->findBlobFiles($tenantDir);
foreach ($files as $file) {
$meta = $this->readMetadata($file);
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < $now) {
if (@unlink($file)) {
@unlink($file . '.meta');
$count++;
}
}
}
}
return $count;
}
/**
* Check if a blob is valid (exists and not expired)
*/
private function isValid(string $path): bool
{
if (!file_exists($path)) {
return false;
}
$meta = $this->readMetadata($path);
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < time()) {
@unlink($path);
@unlink($path . '.meta');
return false;
}
return true;
}
/**
* Build the full path for a blob
*/
private function buildPath(string $key, CacheScope $scope, string $usage): string
{
$dir = $this->buildDir($scope, $usage);
$hash = $this->hashKey($key);
return $dir . '/' . $hash;
}
/**
* Build the directory path for a scope/usage combination
*/
private function buildDir(CacheScope $scope, string $usage): string
{
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
return match ($scope) {
CacheScope::Global => $this->basePath . '/_global/cache/' . $usage,
CacheScope::Tenant => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/cache/' . $usage,
CacheScope::User => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/' . ($this->userId ?? '_unknown') . '/cache/' . $usage,
};
}
/**
* Hash a cache key for filesystem safety
*/
private function hashKey(string $key): string
{
// Extract extension if present in key
$ext = '';
if (preg_match('/\.([a-zA-Z0-9]{2,5})$/', $key, $matches)) {
$ext = '.' . strtolower($matches[1]);
}
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
$hash = substr(hash('sha256', $key), 0, 16);
return $safe . '_' . $hash . $ext;
}
/**
* Read metadata for a blob
*/
private function readMetadata(string $path): ?array
{
$metaPath = $path . '.meta';
if (!file_exists($metaPath)) {
// Return basic metadata from file stats
if (file_exists($path)) {
$stat = stat($path);
return [
'mimeType' => null,
'size' => $stat['size'] ?? 0,
'createdAt' => $stat['ctime'] ?? time(),
'expiresAt' => null,
];
}
return null;
}
$content = @file_get_contents($metaPath);
if ($content === false) {
return null;
}
$meta = @unserialize($content);
return is_array($meta) ? $meta : null;
}
/**
* Write metadata for a blob
*/
private function writeMetadata(string $path, array $metadata): bool
{
$metaPath = $path . '.meta';
return file_put_contents($metaPath, serialize($metadata), LOCK_EX) !== false;
}
/**
* Recursively find all blob files in a directory
*/
private function findBlobFiles(string $dir): array
{
$files = [];
$cacheDirs = glob($dir . '/cache/*', GLOB_ONLYDIR) ?: [];
$cacheDirs = array_merge($cacheDirs, glob($dir . '/*/cache/*', GLOB_ONLYDIR) ?: []);
foreach ($cacheDirs as $cacheDir) {
$blobFiles = glob($cacheDir . '/*');
foreach ($blobFiles as $file) {
if (is_file($file) && !str_ends_with($file, '.meta')) {
$files[] = $file;
}
}
}
return $files;
}
}

View File

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace KTXF\Cache\Store;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
/**
* File-based Ephemeral Cache Implementation
*
* Stores cache entries as serialized files with expiration metadata.
* Directory structure: var/cache/{scope}/{usage}/{key_hash}.cache
*/
class FileEphemeralCache implements EphemeralCacheInterface
{
private string $basePath;
private ?string $tenantId = null;
private ?string $userId = null;
public function __construct(string $projectDir)
{
$this->basePath = rtrim($projectDir, '/') . '/var/cache';
}
/**
* Set the tenant context for scoped operations
*/
public function setTenantContext(?string $tenantId): void
{
$this->tenantId = $tenantId;
}
/**
* Set the user context for scoped operations
*/
public function setUserContext(?string $userId): void
{
$this->userId = $userId;
}
/**
* @inheritDoc
*/
public function get(string $key, CacheScope $scope, string $usage): mixed
{
$path = $this->buildPath($key, $scope, $usage);
if (!file_exists($path)) {
return null;
}
$entry = $this->readEntry($path);
if ($entry === null) {
return null;
}
// Check expiration
if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) {
@unlink($path);
return null;
}
return $entry['value'];
}
/**
* @inheritDoc
*/
public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool
{
$path = $this->buildPath($key, $scope, $usage);
$dir = dirname($path);
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
}
$ttl = $ttl ?? self::DEFAULT_TTL;
$entry = [
'key' => $key,
'value' => $value,
'createdAt' => time(),
'expiresAt' => $ttl > 0 ? time() + $ttl : 0,
];
return $this->writeEntry($path, $entry);
}
/**
* @inheritDoc
*/
public function has(string $key, CacheScope $scope, string $usage): bool
{
return $this->get($key, $scope, $usage) !== null;
}
/**
* @inheritDoc
*/
public function delete(string $key, CacheScope $scope, string $usage): bool
{
$path = $this->buildPath($key, $scope, $usage);
if (file_exists($path)) {
return @unlink($path);
}
return true;
}
/**
* @inheritDoc
*/
public function clear(CacheScope $scope, string $usage): int
{
$dir = $this->buildDir($scope, $usage);
if (!is_dir($dir)) {
return 0;
}
$count = 0;
$files = glob($dir . '/*.cache');
foreach ($files as $file) {
if (@unlink($file)) {
$count++;
}
}
return $count;
}
/**
* @inheritDoc
*/
public function cleanup(): int
{
$count = 0;
$now = time();
// Scan all scope directories
$scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
foreach ($scopeDirs as $scopeDir) {
$files = $this->findCacheFiles($scopeDir);
foreach ($files as $file) {
$entry = $this->readEntry($file);
if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) {
if (@unlink($file)) {
$count++;
}
}
}
}
return $count;
}
/**
* @inheritDoc
*/
public function remember(string $key, callable $callback, CacheScope $scope, string $usage, ?int $ttl = null): mixed
{
$value = $this->get($key, $scope, $usage);
if ($value !== null) {
return $value;
}
$value = $callback();
$this->set($key, $value, $scope, $usage, $ttl);
return $value;
}
/**
* @inheritDoc
*/
public function increment(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false
{
$path = $this->buildPath($key, $scope, $usage);
// Use file locking for atomic increment
$handle = @fopen($path, 'c+');
if ($handle === false) {
// File doesn't exist, create with initial value
$dir = dirname($path);
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
}
$handle = @fopen($path, 'c+');
if ($handle === false) {
return false;
}
}
if (!flock($handle, LOCK_EX)) {
fclose($handle);
return false;
}
try {
$content = stream_get_contents($handle);
$entry = $content ? @unserialize($content) : null;
if ($entry === null || ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time())) {
// Initialize new entry
$newValue = $amount;
$entry = [
'key' => $key,
'value' => $newValue,
'createdAt' => time(),
'expiresAt' => time() + self::DEFAULT_TTL,
];
} else {
$newValue = (int)$entry['value'] + $amount;
$entry['value'] = $newValue;
}
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, serialize($entry));
return $newValue;
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
/**
* @inheritDoc
*/
public function decrement(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false
{
return $this->increment($key, $scope, $usage, -$amount);
}
/**
* Build the full path for a cache entry
*/
private function buildPath(string $key, CacheScope $scope, string $usage): string
{
$dir = $this->buildDir($scope, $usage);
$hash = $this->hashKey($key);
return $dir . '/' . $hash . '.cache';
}
/**
* Build the directory path for a scope/usage combination
*/
private function buildDir(CacheScope $scope, string $usage): string
{
$prefix = $scope->buildPrefix($this->tenantId, $this->userId);
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
return $this->basePath . '/' . $prefix . '/' . $usage;
}
/**
* Hash a cache key for filesystem safety
*/
private function hashKey(string $key): string
{
// Use a prefix of the original key for debugging + hash for uniqueness
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
$hash = substr(hash('sha256', $key), 0, 16);
return $safe . '_' . $hash;
}
/**
* Read and unserialize a cache entry
*/
private function readEntry(string $path): ?array
{
$content = @file_get_contents($path);
if ($content === false) {
return null;
}
$entry = @unserialize($content);
if (!is_array($entry) || !isset($entry['value'])) {
return null;
}
return $entry;
}
/**
* Serialize and write a cache entry atomically
*/
private function writeEntry(string $path, array $entry): bool
{
$content = serialize($entry);
$tempPath = $path . '.tmp.' . getmypid();
if (file_put_contents($tempPath, $content, LOCK_EX) === false) {
return false;
}
chmod($tempPath, 0600);
if (!rename($tempPath, $path)) {
@unlink($tempPath);
return false;
}
return true;
}
/**
* Recursively find all cache files in a directory
*/
private function findCacheFiles(string $dir): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'cache') {
$files[] = $file->getPathname();
}
}
return $files;
}
}

View File

@@ -0,0 +1,433 @@
<?php
declare(strict_types=1);
namespace KTXF\Cache\Store;
use KTXF\Cache\CacheScope;
use KTXF\Cache\PersistentCacheInterface;
/**
* File-based Persistent Cache Implementation
*
* Stores long-lived cache entries with support for tagging and versioning.
* Directory structure: var/cache/{scope}/{usage}/{key_hash}.cache
*/
class FilePersistentCache implements PersistentCacheInterface
{
private string $basePath;
private ?string $tenantId = null;
private ?string $userId = null;
public function __construct(string $projectDir)
{
$this->basePath = rtrim($projectDir, '/') . '/var/cache';
}
/**
* Set the tenant context for scoped operations
*/
public function setTenantContext(?string $tenantId): void
{
$this->tenantId = $tenantId;
}
/**
* Set the user context for scoped operations
*/
public function setUserContext(?string $userId): void
{
$this->userId = $userId;
}
/**
* @inheritDoc
*/
public function get(string $key, CacheScope $scope, string $usage): mixed
{
$path = $this->buildPath($key, $scope, $usage);
if (!file_exists($path)) {
return null;
}
$entry = $this->readEntry($path);
if ($entry === null) {
return null;
}
// Check expiration (0 = never expires)
if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) {
@unlink($path);
$this->removeFromTagIndex($key, $scope, $usage, $entry['tags'] ?? []);
return null;
}
return $entry['value'];
}
/**
* @inheritDoc
*/
public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool
{
return $this->setWithTags($key, $value, $scope, $usage, [], $ttl);
}
/**
* @inheritDoc
*/
public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool
{
$path = $this->buildPath($key, $scope, $usage);
$dir = dirname($path);
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
}
// Remove from old tags if entry exists
$existingEntry = $this->readEntry($path);
if ($existingEntry !== null && !empty($existingEntry['tags'])) {
$this->removeFromTagIndex($key, $scope, $usage, $existingEntry['tags']);
}
$ttl = $ttl ?? self::DEFAULT_TTL;
$entry = [
'key' => $key,
'value' => $value,
'tags' => $tags,
'createdAt' => time(),
'expiresAt' => $ttl > 0 ? time() + $ttl : 0,
];
$result = $this->writeEntry($path, $entry);
if ($result && !empty($tags)) {
$this->addToTagIndex($key, $scope, $usage, $tags);
}
return $result;
}
/**
* @inheritDoc
*/
public function has(string $key, CacheScope $scope, string $usage): bool
{
return $this->get($key, $scope, $usage) !== null;
}
/**
* @inheritDoc
*/
public function delete(string $key, CacheScope $scope, string $usage): bool
{
$path = $this->buildPath($key, $scope, $usage);
if (file_exists($path)) {
$entry = $this->readEntry($path);
if ($entry !== null && !empty($entry['tags'])) {
$this->removeFromTagIndex($key, $scope, $usage, $entry['tags']);
}
return @unlink($path);
}
return true;
}
/**
* @inheritDoc
*/
public function clear(CacheScope $scope, string $usage): int
{
$dir = $this->buildDir($scope, $usage);
if (!is_dir($dir)) {
return 0;
}
$count = 0;
$files = glob($dir . '/*.cache');
foreach ($files as $file) {
if (@unlink($file)) {
$count++;
}
}
// Clear tag index
$tagIndexPath = $dir . '/.tags';
if (file_exists($tagIndexPath)) {
@unlink($tagIndexPath);
}
return $count;
}
/**
* @inheritDoc
*/
public function cleanup(): int
{
$count = 0;
$now = time();
// Scan all scope directories
$scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
foreach ($scopeDirs as $scopeDir) {
$files = $this->findCacheFiles($scopeDir);
foreach ($files as $file) {
$entry = $this->readEntry($file);
if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) {
if (@unlink($file)) {
$count++;
}
}
}
}
return $count;
}
/**
* @inheritDoc
*/
public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int
{
$tagIndex = $this->readTagIndex($scope, $usage);
if (!isset($tagIndex[$tag])) {
return 0;
}
$count = 0;
$keys = $tagIndex[$tag];
foreach ($keys as $key) {
if ($this->delete($key, $scope, $usage)) {
$count++;
}
}
return $count;
}
/**
* @inheritDoc
*/
public function getVersion(string $key, CacheScope $scope, string $usage): ?int
{
$path = $this->buildPath($key, $scope, $usage);
if (!file_exists($path)) {
return null;
}
$entry = $this->readEntry($path);
return $entry['createdAt'] ?? null;
}
/**
* @inheritDoc
*/
public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool
{
$version = $this->getVersion($key, $scope, $usage);
if ($version === null) {
return true;
}
return $version < $reference;
}
/**
* Build the full path for a cache entry
*/
private function buildPath(string $key, CacheScope $scope, string $usage): string
{
$dir = $this->buildDir($scope, $usage);
$hash = $this->hashKey($key);
return $dir . '/' . $hash . '.cache';
}
/**
* Build the directory path for a scope/usage combination
*/
private function buildDir(CacheScope $scope, string $usage): string
{
$prefix = $scope->buildPrefix($this->tenantId, $this->userId);
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
return $this->basePath . '/' . $prefix . '/' . $usage;
}
/**
* Hash a cache key for filesystem safety
*/
private function hashKey(string $key): string
{
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
$hash = substr(hash('sha256', $key), 0, 16);
return $safe . '_' . $hash;
}
/**
* Read and unserialize a cache entry
*/
private function readEntry(string $path): ?array
{
$content = @file_get_contents($path);
if ($content === false) {
return null;
}
$entry = @unserialize($content);
if (!is_array($entry) || !isset($entry['value'])) {
return null;
}
return $entry;
}
/**
* Serialize and write a cache entry atomically
*/
private function writeEntry(string $path, array $entry): bool
{
$content = serialize($entry);
$tempPath = $path . '.tmp.' . getmypid();
if (file_put_contents($tempPath, $content, LOCK_EX) === false) {
return false;
}
chmod($tempPath, 0600);
if (!rename($tempPath, $path)) {
@unlink($tempPath);
return false;
}
return true;
}
/**
* Read the tag index for a usage bucket
*/
private function readTagIndex(CacheScope $scope, string $usage): array
{
$path = $this->buildDir($scope, $usage) . '/.tags';
if (!file_exists($path)) {
return [];
}
$content = @file_get_contents($path);
if ($content === false) {
return [];
}
$index = @unserialize($content);
return is_array($index) ? $index : [];
}
/**
* Write the tag index for a usage bucket
*/
private function writeTagIndex(CacheScope $scope, string $usage, array $index): bool
{
$path = $this->buildDir($scope, $usage) . '/.tags';
$dir = dirname($path);
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
}
return file_put_contents($path, serialize($index), LOCK_EX) !== false;
}
/**
* Add a key to the tag index
*/
private function addToTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void
{
$index = $this->readTagIndex($scope, $usage);
foreach ($tags as $tag) {
if (!isset($index[$tag])) {
$index[$tag] = [];
}
if (!in_array($key, $index[$tag], true)) {
$index[$tag][] = $key;
}
}
$this->writeTagIndex($scope, $usage, $index);
}
/**
* Remove a key from the tag index
*/
private function removeFromTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void
{
$index = $this->readTagIndex($scope, $usage);
$changed = false;
foreach ($tags as $tag) {
if (isset($index[$tag])) {
$pos = array_search($key, $index[$tag], true);
if ($pos !== false) {
unset($index[$tag][$pos]);
$index[$tag] = array_values($index[$tag]);
$changed = true;
if (empty($index[$tag])) {
unset($index[$tag]);
}
}
}
}
if ($changed) {
$this->writeTagIndex($scope, $usage, $index);
}
}
/**
* Recursively find all cache files in a directory
*/
private function findCacheFiles(string $dir): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'cache') {
$files[] = $file->getPathname();
}
}
return $files;
}
}