PathsRegistry.php

PHP

Path: src/Util/PathsRegistry.php

<?php

namespace mini\Util;

/**
 * Registry for managing multiple paths with priority-based file resolution
 *
 * The primary path is always checked first, followed by fallback paths in reverse order
 * of addition (most recently added fallback takes precedence over earlier fallbacks).
 *
 * This design works naturally with Composer's dependency graph loading order:
 * - Dependencies load first (e.g., fubber/mini)
 * - Then packages that depend on them (e.g., fubber/some-bundle)
 * - Finally the application itself
 *
 * When each loads and calls addPath(), the most recent (application) will be checked
 * before earlier ones (bundle before framework), allowing natural override cascading.
 *
 * Example:
 * ```php
 * $registry = new PathsRegistry('/app/resources');      // Primary path (app)
 * $registry->addPath('/vendor/mini/resources');         // Framework fallback
 * $registry->addPath('/vendor/some-bundle/resources');  // Bundle fallback
 *
 * // Resolution order: /app/resources → /vendor/some-bundle/resources → /vendor/mini/resources
 * // App overrides bundle, bundle overrides framework
 * ```
 *
 * Results are cached per filename until addPath() is called again.
 */
class PathsRegistry
{
    private string $primaryPath;
    /** @var list<string> Fallback paths in reverse order (most recent first) */
    private array $fallbackPaths = [];
    private array $cacheFirst = [];
    private array $cacheAll = [];
    private string $cachePrefix;

    /**
     * Create a new paths registry with a primary path
     *
     * @param string $primaryPath The primary path that is always checked first
     */
    public function __construct(string $primaryPath)
    {
        $this->primaryPath = rtrim($primaryPath, '/');
        $this->updateCachePrefix();
    }

    /**
     * Update cache prefix after paths change
     */
    private function updateCachePrefix(): void
    {
        $this->cachePrefix = 'path:' . md5($this->primaryPath . ':' . implode(':', $this->fallbackPaths)) . ':';
    }

    /**
     * Add a fallback path
     *
     * Fallback paths are prepended, so the most recently added fallback is checked
     * before earlier fallbacks. Duplicate paths and paths matching the primary path
     * are silently ignored.
     *
     * Cache is cleared when a new path is added.
     *
     * @param string $path The fallback path to add
     */
    public function addPath(string $path): void
    {
        $this->cacheFirst = [];
        $this->cacheAll = [];
        $path = rtrim($path, '/');
        if ($path === $this->primaryPath || in_array($path, $this->fallbackPaths)) {
            return;
        }
        // Prepend to fallback paths so most recent additions are checked first
        array_unshift($this->fallbackPaths, $path);
        $this->updateCachePrefix();
    }

    /**
     * Find the first occurrence of a file across all paths
     *
     * Searches in priority order: primary path first, then fallback paths from most
     * recently added to earliest. Returns the full path to the first match found,
     * or null if the file doesn't exist in any path.
     *
     * Results are cached in-memory (per-request) and in APCu (across requests).
     *
     * @param string $filename Relative filename to search for
     * @return string|null Full path to first match, or null if not found
     */
    public function findFirst(string $filename): ?string
    {
        // L1: In-memory cache (fastest)
        if (isset($this->cacheFirst[$filename]) || \array_key_exists($filename, $this->cacheFirst)) {
            return $this->cacheFirst[$filename];
        }

        // Resolver function - used by both APCu and direct call paths
        $resolver = function() use ($filename) {
            // Check primary path first
            $fullPath = $this->primaryPath . '/' . ltrim($filename, '/');
            if (file_exists($fullPath)) {
                return $fullPath;
            }

            // Then check fallback paths (most recent first)
            foreach ($this->fallbackPaths as $path) {
                $fullPath = $path . '/' . ltrim($filename, '/');
                if (file_exists($fullPath)) {
                    return $fullPath;
                }
            }

            return null;
        };

        $result = apcu_entry($this->cachePrefix . $filename, $resolver, ttl: 1);

        // Note; probably a PHP bug (or poor design choice) - apcu_entry callback
        // not called if APCu disabled
        if ($result === null && !apcu_enabled()) {
            $result = $resolver();
        }

        $this->cacheFirst[$filename] = $result;
        return $result;
    }

    /**
     * Find all occurrences of a file across all paths
     *
     * Searches in priority order: primary path first, then fallback paths from most
     * recently added to earliest. Returns an array of all full paths where the file
     * exists, in priority order.
     *
     * Results are cached in-memory (per-request) and in APCu (across requests).
     *
     * @param string $filename Relative filename to search for
     * @return list<string> All matching file paths in priority order
     */
    public function findAll(string $filename): array
    {
        // L1: In-memory cache (fastest)
        if (isset($this->cacheAll[$filename])) {
            return $this->cacheAll[$filename];
        }

        $resolver = function() use ($filename) {
            $found = [];

            // Check primary path first
            $fullPath = $this->primaryPath . '/' . ltrim($filename, '/');
            if (file_exists($fullPath)) {
                $found[] = $fullPath;
            }

            // Then check fallback paths (most recent first)
            foreach ($this->fallbackPaths as $path) {
                $fullPath = $path . '/' . ltrim($filename, '/');
                if (file_exists($fullPath)) {
                    $found[] = $fullPath;
                }
            }

            return $found;
        };

        // L2: APCu cache (survives across requests, 1s TTL for hot paths)
        $result = apcu_entry($this->cachePrefix . 'all:' . $filename, $resolver, ttl: 1);

        if ($result === null && !apcu_enabled()) {
            $result = $resolver();
        }

        $this->cacheAll[$filename] = $result;
        return $result;
    }

    /**
     * Get all registered paths in resolution order
     *
     * @return list<string> All paths in resolution order (primary first, then fallbacks from most recent to earliest)
     */
    public function getPaths(): array
    {
        return array_merge([$this->primaryPath], $this->fallbackPaths);
    }
}