From 869e7df6f441c77e71baa4a9579a1ffdbb5cc874 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 7 Jul 2026 16:54:16 +0600 Subject: [PATCH] updated lazy + env --- docs/config.rst | 258 +++++++++++++----- docs/facade.rst | 5 + docs/index.rst | 1 + docs/lazy-config.rst | 209 ++++++++++++++ docs/quick-usage.rst | 38 +++ docs/rule-reference.rst | 3 + examples/basic-usage.php | 16 -- src/ArrayKit.php | 12 + src/Collection/Collection.php | 1 + .../{ => Concerns}/BaseCollectionTrait.php | 3 +- src/Collection/HookedCollection.php | 2 +- src/Concerns/HookTrait.php | 54 ++++ src/Config/{ => Concerns}/BaseConfigTrait.php | 44 ++- .../Concerns/LazyFileConfigCacheTrait.php | 2 +- src/Config/Config.php | 3 +- src/Config/EnvParser.php | 98 +++++++ src/Config/Support/EnvLineParser.php | 210 ++++++++++++++ src/Config/Support/EnvReference.php | 18 ++ src/Config/Support/EnvValueResolver.php | 212 ++++++++++++++ src/Config/Support/Environment.php | 64 +++++ src/{traits => DTO/Concerns}/DTOTrait.php | 19 +- src/DTO/GenericDTO.php | 2 +- src/functions.php | 23 ++ src/namespaced-functions.php | 17 ++ src/traits/HookTrait.php | 100 ------- tests/Feature/ConfigTest.php | 33 +++ tests/Feature/DTOTraitTest.php | 2 +- tests/Feature/EnvParserTest.php | 167 ++++++++++++ tests/Feature/LazyFileConfigTest.php | 25 ++ 29 files changed, 1430 insertions(+), 211 deletions(-) create mode 100644 docs/lazy-config.rst delete mode 100644 examples/basic-usage.php rename src/Collection/{ => Concerns}/BaseCollectionTrait.php (99%) create mode 100644 src/Concerns/HookTrait.php rename src/Config/{ => Concerns}/BaseConfigTrait.php (93%) create mode 100644 src/Config/EnvParser.php create mode 100644 src/Config/Support/EnvLineParser.php create mode 100644 src/Config/Support/EnvReference.php create mode 100644 src/Config/Support/EnvValueResolver.php create mode 100644 src/Config/Support/Environment.php rename src/{traits => DTO/Concerns}/DTOTrait.php (92%) delete mode 100644 src/traits/HookTrait.php create mode 100644 tests/Feature/EnvParserTest.php diff --git a/docs/config.rst b/docs/config.rst index 33391d4..cd3b06b 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -6,16 +6,17 @@ ArrayKit configuration objects provide dot-notation access to nested settings. Classes: - ``Infocyph\ArrayKit\Config\Config`` -- ``Infocyph\ArrayKit\Config\LazyFileConfig`` ``Config`` supports optional hooks via explicit ``getWithHooks()``, ``setWithHooks()``, and ``fillWithHooks()`` methods. -``LazyFileConfig`` loads namespace files only on first keyed access. + +For split per-namespace config files, see :doc:`lazy-config`. Loading Configuration --------------------- -You can load config from an array or a PHP file that returns an array. +You can load config from an array, a PHP file that returns an array, or a +``.env`` file. .. code-block:: php @@ -32,6 +33,9 @@ You can load config from an array or a PHP file that returns an array. // Or from file: // $ok = $config->loadFile(__DIR__.'/config.php'); + // Or from a .env file: + // $ok = $config->loadEnvFile(__DIR__.'/.env'); + Important behavior: - ``loadArray()`` and ``loadFile()`` only load when config is currently empty. @@ -40,8 +44,177 @@ Important behavior: - ``reload()`` replaces from array or readable file path. - ``exportCache()`` writes a compiled PHP cache file of current items. - ``loadCache()`` loads a compiled PHP cache file through the normal file loader. +- ``loadEnvFile()`` loads parsed ``.env`` values only when config is empty. +- ``mergeEnvFile()`` merges parsed ``.env`` values into existing config. - Facade-based config creation is documented in :doc:`facade`. +Environment Values and .env Parsing +----------------------------------- + +ArrayKit separates runtime environment reads from ``.env`` file parsing. + +Runtime environment reads use ``env()`` or ``ArrayKit::env()``. They check +``$_ENV`` first, then non-HTTP ``$_SERVER`` values, then ``getenv()``. + +.. code-block:: php + + get('APP_DEBUG', false); + $hasSecret = ArrayKit::env()->has('APP_SECRET'); + +``.env`` file parsing uses ``dotenv()``, ``ArrayKit::dotenv()``, or +``EnvParser`` directly. + +.. code-block:: php + + parseFile(__DIR__.'/.env'); + $rawValues = dotenv()->parseRaw("URL=https://example.com/\${APP_ENV}\n"); + +``env()`` is not the ``.env`` parser. It reads values already available in the +runtime environment. + +Environment References in Config +-------------------------------- + +Config arrays can contain environment values in three different ways. + +Immediate resolution happens when the config PHP file is included: + +.. code-block:: php + + [ + 'port' => env('DB_PORT', 3306), + ], + ]; + +Delayed environment resolution uses ``Environment::ref()``. This keeps an +explicit env reference in config memory until a cache file is exported or a lazy +namespace cache is warmed. + +.. code-block:: php + + [ + 'host' => Environment::ref('DB_HOST', 'localhost'), + 'port' => Environment::ref('DB_PORT', 3306), + ], + ]; + +Closures are also supported for custom delayed logic: + +.. code-block:: php + + [ + 'url' => fn () => sprintf( + 'mysql://%s:%s@%s/%s', + env('DB_USER', 'root'), + env('DB_PASSWORD', ''), + env('DB_HOST', 'localhost'), + env('DB_NAME', 'app'), + ), + ], + ]; + +Use ``Environment::ref()`` for simple environment lookups. Use closures only +when you need custom computation. + +Full Process: .env to Config Cache +---------------------------------- + +This example shows a typical bootstrap flow: + +1. Parse ``.env`` values. +2. Build runtime config using env values. +3. Export a compiled config cache. +4. Load config from cache on later requests. + +Example ``.env`` file: + +.. code-block:: dotenv + + APP_NAME=ArrayKit Demo + APP_ENV=local + APP_DEBUG=true + DB_HOST=127.0.0.1 + DB_PORT=3306 + DB_DATABASE=arraykit + +Example bootstrap file: + +.. code-block:: php + + loadCache($cacheFile); + } else { + // First boot, local development, or after cache clear. + $config->loadEnvFile($basePath.'/.env'); + + $config->merge([ + 'app' => [ + 'name' => env('APP_NAME', 'ArrayKit'), + 'env' => env('APP_ENV', 'production'), + 'debug' => filter_var(env('APP_DEBUG', false), FILTER_VALIDATE_BOOL), + ], + 'db' => [ + // Resolved only when exportCache() writes the cache file. + 'host' => Environment::ref('DB_HOST', 'localhost'), + 'port' => fn () => (int) env('DB_PORT', 3306), + 'database' => Environment::ref('DB_DATABASE', 'app'), + ], + ]); + + $config->exportCache($cacheFile); + } + + $appName = $config->getString('app.name'); + $dbHost = $config->getString('db.host'); + $dbPort = $config->getInt('db.port'); + +Important details: + +- ``loadEnvFile()`` stores parsed ``.env`` entries as config items. +- ``env()`` reads the current runtime environment. It does not parse files. +- ``Environment::ref()`` and closures are materialized by ``exportCache()``. +- The generated cache file contains concrete resolved values only. +- If an environment value changes after the cache file exists, rebuild the cache + before expecting config reads to change. +- On cached boot, prefer ``loadCache()`` first. ``loadEnvFile()`` fills an empty + config object, so calling ``loadEnvFile()`` before ``loadCache()`` makes + ``loadCache()`` return ``false`` unless you are using a separate config object. + +If you want to parse ``.env`` without loading it into ``Config``: + +.. code-block:: php + + parseFile(__DIR__.'/.env'); + $rawValues = dotenv()->parseFileRaw(__DIR__.'/.env'); + Reading Values -------------- @@ -227,14 +400,20 @@ Compiled Cache + Read Memoization - in-memory read memoization for repeated dot-path lookups - compiled cache export/load through PHP files +- cache materialization of ``Environment::ref()`` values and closures .. code-block:: php loadArray([ 'app' => ['name' => 'ArrayKit'], - 'db' => ['host' => 'localhost'], + 'db' => [ + 'host' => Environment::ref('DB_HOST', 'localhost'), + 'port' => fn () => env('DB_PORT', 3306), + ], ]); $config->readCache(); // enabled by default @@ -242,59 +421,11 @@ Compiled Cache + Read Memoization $cached = new Config(); $cached->loadCache(__DIR__.'/bootstrap/cache/config.php'); - $name = $cached->get('app.name'); + $host = $cached->get('db.host'); -LazyFileConfig --------------- - -Use ``LazyFileConfig`` when configuration is split into top-level namespace files -like ``db.php``, ``cache.php``, ``queue.php``. - -Rules: - -- Key format is ``namespace.path.to.key``. -- On first access, only ``{directory}/{namespace}.php`` is loaded. -- Remaining key segments are resolved using dot notation. - -.. code-block:: php - - get('db.host', '127.0.0.1'); - - // Optional warm-up: - $config->preload(['db', 'cache']); - - $loaded = $config->loadedNamespaces(); // ['db', 'cache'] - $isLoaded = $config->loaded('db'); // alias of isLoaded() - -Important behavior: - -- ``get()`` requires at least one key. -- ``all()`` is intentionally disabled and throws. -- Namespace file must return an array. -- Missing namespace file returns the provided default. -- ``replace()`` and ``reload()`` reset resolved-namespace tracking. -- read-only mode applies to ``set/fill/forget/replace/reload``-style mutators. -- ``namespaceCache()`` configures an optional per-namespace cache directory. -- ``warmNamespaceCache()`` writes cached namespace files and a shared ``__flat.php`` exact-leaf index. -- Exact-key scalar reads check ``__flat.php`` first; structural, wildcard, and namespace reads fall back to namespace cache files. -- Runtime writes only affect in-memory state until cache files are explicitly rebuilt. - -.. code-block:: php - - warmNamespaceCache(['db', 'cache']); - $host = $config->get('db.host'); +When ``exportCache()`` writes the PHP cache file, ``Environment::ref()`` values +and closures are recursively resolved first. The generated cache contains only +the resolved values, not closures or reference objects. Method Summary -------------- @@ -308,25 +439,14 @@ Config methods: - ``prepend()``, ``append()`` - ``replace()``, ``reload()`` - ``exportCache()``, ``loadCache()`` +- ``loadEnvFile()``, ``mergeEnvFile()`` - ``readCache()``, ``readCacheEnabled()``, ``flushReadCache()`` - ``getString()/getInt()/getFloat()/getBool()/getArray()/getList()/getEnum()`` - ``merge()``, ``overlay()`` - ``snapshot()``, ``restore()``, ``changed()`` - ``readonly()``, ``isReadonly()`` -LazyFileConfig methods: - -- ``get()`` (requires key) -- ``has()``, ``hasAny()`` -- ``set()``, ``fill()``, ``forget()`` -- ``preload()``, ``isLoaded()``, ``loaded()``, ``loadedNamespaces()`` -- ``namespaceCache()``, ``namespaceCacheDirectory()`` -- ``warmNamespaceCache()``, ``flushNamespaceCache()`` -- ``replace()``, ``reload()`` -- ``exportCache()``, ``loadCache()`` -- ``all()`` (throws by design) - -Hook-aware methods (Config and LazyFileConfig): +Hook-aware methods: - ``getWithHooks()`` - ``setWithHooks()`` diff --git a/docs/facade.rst b/docs/facade.rst index fae20f7..de2d75f 100644 --- a/docs/facade.rst +++ b/docs/facade.rst @@ -27,6 +27,8 @@ These methods return a lightweight ``ModuleProxy`` that forwards calls to static $flat = ArrayKit::multi()->flatten([[1], [2, [3]]]); $wrapped = ArrayKit::helper()->wrap('x'); $name = ArrayKit::dot()->get(['user' => ['name' => 'Alice']], 'user.name'); + $env = ArrayKit::env()->get('APP_ENV', 'local'); + $dotenv = ArrayKit::dotenv()->parseFile(__DIR__.'/.env'); Factory Entry Points -------------------- @@ -50,6 +52,8 @@ Behavior Notes -------------- - ``single()``, ``multi()``, ``helper()``, and ``dot()`` return cached proxies. +- ``env()`` reads the current runtime environment. +- ``dotenv()`` exposes the ``.env`` file parser. - Proxy calls map directly to target static methods. - Calling a missing method via proxy throws ``BadMethodCallException``. @@ -60,3 +64,4 @@ Related Guides - Dot notation: :doc:`dot-notation` - Collections: :doc:`collection` - Configuration: :doc:`config` +- Lazy file configuration: :doc:`lazy-config` diff --git a/docs/index.rst b/docs/index.rst index 49d5d31..8bc6e8a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ Contents dot-notation collection config + lazy-config traits-and-helpers migration rule-reference diff --git a/docs/lazy-config.rst b/docs/lazy-config.rst new file mode 100644 index 0000000..d17ec91 --- /dev/null +++ b/docs/lazy-config.rst @@ -0,0 +1,209 @@ +Lazy File Configuration +======================= + +Use ``LazyFileConfig`` when configuration is split into top-level namespace files +like ``db.php``, ``cache.php``, ``queue.php``. + +Class: + +- ``Infocyph\ArrayKit\Config\LazyFileConfig`` + +For single in-memory config arrays and compiled whole-config cache files, see +:doc:`config`. + +Basic Usage +----------- + +Rules: + +- Key format is ``namespace.path.to.key``. +- On first access, only ``{directory}/{namespace}.php`` is loaded. +- Remaining key segments are resolved using dot notation. + +.. code-block:: php + + get('db.host', '127.0.0.1'); + + // Optional warm-up: + $config->preload(['db', 'cache']); + + $loaded = $config->loadedNamespaces(); // ['db', 'cache'] + $isLoaded = $config->loaded('db'); // alias of isLoaded() + +Important Behavior +------------------ + +- ``get()`` requires at least one key. +- ``all()`` is intentionally disabled and throws. +- Namespace file must return an array. +- Missing namespace file returns the provided default. +- ``replace()`` and ``reload()`` reset resolved-namespace tracking. +- read-only mode applies to ``set/fill/forget/replace/reload``-style mutators. +- Runtime writes only affect in-memory state until cache files are explicitly rebuilt. + +Namespace Cache +--------------- + +``LazyFileConfig`` can warm one cache file per namespace plus a shared exact-leaf +index. + +.. code-block:: php + + warmNamespaceCache(['db', 'cache']); + $host = $config->get('db.host'); + +Cache behavior: + +- ``namespaceCache()`` configures an optional per-namespace cache directory. +- ``warmNamespaceCache()`` writes cached namespace files and a shared ``__flat.php`` exact-leaf index. +- Exact-key scalar reads check ``__flat.php`` first. +- Structural, wildcard, and namespace reads fall back to namespace cache files. +- ``Environment::ref()`` values and closures are resolved before namespace cache files are written. + +Environment Values in Namespace Files +------------------------------------- + +Example namespace file with delayed environment values: + +.. code-block:: php + + Environment::ref('DB_HOST', 'localhost'), + 'port' => fn () => env('DB_PORT', 3306), + ]; + +``warmNamespaceCache('db')`` resolves those values before writing +``bootstrap/cache/config/db.php`` and before adding scalar leaves to +``bootstrap/cache/config/__flat.php``. + +Generated namespace cache file: + +.. code-block:: php + + 'localhost', + 'port' => 3306, + ]; + +Generated flat leaf index: + +.. code-block:: php + + 'localhost', + 'db.port' => 3306, + ]; + +Full LazyFileConfig Process +--------------------------- + +For split config files, cache warming is per namespace. First request or +deployment warm-up reads namespace files, resolves env references/closures, and +writes cache files. Later requests can read from the namespace cache without the +original namespace file. + +Example ``config/db.php``: + +.. code-block:: php + + Environment::ref('DB_HOST', 'localhost'), + 'port' => fn () => (int) env('DB_PORT', 3306), + 'database' => Environment::ref('DB_DATABASE', 'app'), + ]; + +Warm the lazy cache during deployment or first boot: + +.. code-block:: php + + warmNamespaceCache('db'); + +Read from the warmed cache on later requests: + +.. code-block:: php + + get('db.host'); + + // Structural reads load bootstrap/cache/config/db.php when available. + $database = $config->get('db'); + +Important lazy-cache details: + +- ``warmNamespaceCache('db')`` writes ``bootstrap/cache/config/db.php``. +- ``warmNamespaceCache(['db', 'cache'])`` writes one file per namespace. +- ``__flat.php`` stores exact scalar/null leaves such as ``db.host``. +- Original source files are preferred only when namespace cache files do not + exist or are not readable. +- If env values change, rerun ``warmNamespaceCache()`` or flush and rebuild the + namespace cache. + +Method Summary +-------------- + +LazyFileConfig methods: + +- ``get()`` (requires key) +- ``has()``, ``hasAny()`` +- ``set()``, ``fill()``, ``forget()`` +- ``preload()``, ``isLoaded()``, ``loaded()``, ``loadedNamespaces()`` +- ``namespaceCache()``, ``namespaceCacheDirectory()`` +- ``warmNamespaceCache()``, ``flushNamespaceCache()`` +- ``replace()``, ``reload()`` +- ``exportCache()``, ``loadCache()`` +- ``all()`` (throws by design) + +Hook-aware methods: + +- ``getWithHooks()`` +- ``setWithHooks()`` +- ``fillWithHooks()`` +- ``onGet()``, ``onSet()`` diff --git a/docs/quick-usage.rst b/docs/quick-usage.rst index 7f4a71a..bc6f37f 100644 --- a/docs/quick-usage.rst +++ b/docs/quick-usage.rst @@ -17,6 +17,8 @@ ArrayKit Facade Example $config = ArrayKit::config(['app' => ['env' => 'local']]); $env = $config->get('app.env'); + $runtimeEnv = ArrayKit::env()->get('APP_ENV', 'local'); + $dotenvValues = ArrayKit::dotenv()->parse("APP_ENV=local\n"); ArraySingle Example ------------------- @@ -101,6 +103,42 @@ Config + Hooks Example $config->merge(['app' => ['env' => 'production']]); $config->restore('before'); +Config Env Cache Example +------------------------ + +.. code-block:: php + + loadArray([ + 'db' => [ + 'host' => Environment::ref('DB_HOST', 'localhost'), + 'port' => fn () => env('DB_PORT', 3306), + ], + ]); + + // Writes concrete resolved values, not closures or EnvReference objects. + $config->exportCache(__DIR__.'/bootstrap/cache/config.php'); + +Lazy File Config Example +------------------------ + +.. code-block:: php + + warmNamespaceCache('db'); + $host = $config->get('db.host'); + LazyCollection Example ---------------------- diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index f8f1e62..4414c04 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -28,6 +28,9 @@ Object-style data pipeline and fluent transformations: Configuration storage with optional get/set hooks: :doc:`config` +Lazy per-namespace configuration files: + :doc:`lazy-config` + Single facade entrypoint for modules and factories: :doc:`facade` diff --git a/examples/basic-usage.php b/examples/basic-usage.php deleted file mode 100644 index fcd70e2..0000000 --- a/examples/basic-usage.php +++ /dev/null @@ -1,16 +0,0 @@ -isList([1, 2, 3]); -$name = ArrayKit::dot()->get(['user' => ['name' => 'Alice']], 'user.name'); - -$config = ArrayKit::config(['app' => ['env' => 'local']]); -$env = $config->get('app.env'); - -$collection = ArrayKit::collection([1, 2, 3, 4]); -$sum = $collection->process()->sum(); - -unset($isList, $name, $env, $sum); diff --git a/src/ArrayKit.php b/src/ArrayKit.php index 0b06b62..cee3c3f 100644 --- a/src/ArrayKit.php +++ b/src/ArrayKit.php @@ -13,7 +13,9 @@ use Infocyph\ArrayKit\Collection\LazyCollection; use Infocyph\ArrayKit\Collection\Pipeline; use Infocyph\ArrayKit\Config\Config; +use Infocyph\ArrayKit\Config\EnvParser; use Infocyph\ArrayKit\Config\LazyFileConfig; +use Infocyph\ArrayKit\Config\Support\Environment; use Infocyph\ArrayKit\Facade\ModuleProxy; /** @@ -57,6 +59,16 @@ public static function dot(): ModuleProxy return self::proxy(DotNotation::class); } + public static function dotenv(): ModuleProxy + { + return self::proxy(EnvParser::class); + } + + public static function env(): ModuleProxy + { + return self::proxy(Environment::class); + } + public static function helper(): ModuleProxy { return self::proxy(BaseArrayHelper::class); diff --git a/src/Collection/Collection.php b/src/Collection/Collection.php index 106d6ec..bcc7b80 100644 --- a/src/Collection/Collection.php +++ b/src/Collection/Collection.php @@ -6,6 +6,7 @@ use ArrayAccess; use Countable; +use Infocyph\ArrayKit\Collection\Concerns\BaseCollectionTrait; use IteratorAggregate; use JsonSerializable; diff --git a/src/Collection/BaseCollectionTrait.php b/src/Collection/Concerns/BaseCollectionTrait.php similarity index 99% rename from src/Collection/BaseCollectionTrait.php rename to src/Collection/Concerns/BaseCollectionTrait.php index 74f1455..2cf1609 100644 --- a/src/Collection/BaseCollectionTrait.php +++ b/src/Collection/Concerns/BaseCollectionTrait.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Infocyph\ArrayKit\Collection; +namespace Infocyph\ArrayKit\Collection\Concerns; use ArrayIterator; use BadMethodCallException; use Infocyph\ArrayKit\Array\DotNotation; +use Infocyph\ArrayKit\Collection\Pipeline; use JsonSerializable; use Traversable; diff --git a/src/Collection/HookedCollection.php b/src/Collection/HookedCollection.php index 0304bf6..2411bcb 100644 --- a/src/Collection/HookedCollection.php +++ b/src/Collection/HookedCollection.php @@ -5,7 +5,7 @@ namespace Infocyph\ArrayKit\Collection; use Infocyph\ArrayKit\Array\DotNotation; -use Infocyph\ArrayKit\traits\HookTrait; +use Infocyph\ArrayKit\Concerns\HookTrait; /** * Class HookedCollection diff --git a/src/Concerns/HookTrait.php b/src/Concerns/HookTrait.php new file mode 100644 index 0000000..e6eeb7d --- /dev/null +++ b/src/Concerns/HookTrait.php @@ -0,0 +1,54 @@ + + */ + protected array $hooks = []; + + public function onGet(string $offset, callable $callback): static + { + return $this->addHook($offset, 'get', $callback); + } + + public function onSet(string $offset, callable $callback): static + { + return $this->addHook($offset, 'set', $callback); + } + + protected function addHook(string|int $offset, string $direction, callable $callback): static + { + $name = $this->getHookName((string) $offset, $direction); + + if (!in_array($callback, $this->hooks[$name] ?? [], true)) { + $this->hooks[$name][] = $callback; + } + + return $this; + } + + protected function getHookName(string $hook, string $direction): string + { + return $hook . '-' . $direction; + } + + protected function processValue(string|int $offset, mixed $value, string $direction): mixed + { + $name = $this->getHookName((string) $offset, $direction); + $hooks = $this->hooks[$name] ?? []; + + foreach ($hooks as $hook) { + $value = $hook($value); + } + + return $value; + } +} diff --git a/src/Config/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php similarity index 93% rename from src/Config/BaseConfigTrait.php rename to src/Config/Concerns/BaseConfigTrait.php index 82b0b75..ab09704 100644 --- a/src/Config/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -2,13 +2,16 @@ declare(strict_types=1); -namespace Infocyph\ArrayKit\Config; +namespace Infocyph\ArrayKit\Config\Concerns; use Infocyph\ArrayKit\Array\DotNotation; +use Infocyph\ArrayKit\Config\EnvParser; +use Infocyph\ArrayKit\Config\Support\EnvReference; use InvalidArgumentException; use OutOfBoundsException; use RuntimeException; use UnexpectedValueException; +use UnitEnum as TEnum; trait BaseConfigTrait { @@ -81,7 +84,7 @@ public function exportCache(string $path): bool return false; } - $export = var_export($this->items, true); + $export = var_export($this->materializeCacheValue($this->items), true); return file_put_contents($path, "|null $key * @param class-string $enumClass - * @param TEnum|null $default - * @return TEnum|null */ public function getEnum(string|int|array|null $key, string $enumClass, ?\UnitEnum $default = null): ?\UnitEnum { @@ -341,6 +342,17 @@ public function loadCache(string $path): bool return $this->loadFile($path); } + public function loadEnvFile(string $path): bool + { + $this->assertWritable(); + + if (count($this->items) !== 0) { + return false; + } + + return $this->loadArray(EnvParser::parseFile($path)); + } + /** * Load configuration from a specified file path (PHP returning array). * @@ -380,6 +392,11 @@ public function merge(array $items): bool return true; } + public function mergeEnvFile(string $path): bool + { + return $this->merge(EnvParser::parseFile($path)); + } + /** * Overlay another config array on top of current items. * @@ -543,6 +560,25 @@ protected function hasResolvedValue(int|string $key): bool return $this->resolveRawValue($key) !== $this->missingValueMarker(); } + protected function materializeCacheValue(mixed $value): mixed + { + if ($value instanceof EnvReference) { + return $this->materializeCacheValue($value->resolve()); + } + + if ($value instanceof \Closure) { + return $this->materializeCacheValue($value()); + } + + if (is_array($value)) { + foreach ($value as $key => $entry) { + $value[$key] = $this->materializeCacheValue($entry); + } + } + + return $value; + } + protected function missingValueMarker(): object { static $missing; diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index f34b1d1..a644c0b 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -82,7 +82,7 @@ public function warmNamespaceCache(string|array|null $namespaces = null): static throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); } - $export = var_export($this->items[$namespace], true); + $export = var_export($this->materializeCacheValue($this->items[$namespace]), true); $path = $this->cachedNamespacePath($namespace); if ($path === null || file_put_contents($path, " + */ + public static function parse(string $contents): array + { + self::assertSafeContents($contents, '.env'); + + return EnvValueResolver::resolve(EnvLineParser::parseContents($contents)); + } + + /** + * @return array + */ + public static function parseFile(string $path): array + { + return self::parse(self::readFile($path)); + } + + /** + * @return array + */ + public static function parseFileRaw(string $path): array + { + return self::parseRaw(self::readFile($path)); + } + + /** + * @param iterable $lines + * @return array + */ + public static function parseLines(iterable $lines): array + { + return EnvValueResolver::resolve(EnvLineParser::parseLines($lines)); + } + + /** + * @param iterable $lines + * @return array + */ + public static function parseLinesRaw(iterable $lines): array + { + return EnvValueResolver::restoreLiteralDollars(EnvLineParser::parseLines($lines)); + } + + /** + * Parse values without expanding variable references. + * + * @return array + */ + public static function parseRaw(string $contents): array + { + self::assertSafeContents($contents, '.env'); + + return EnvValueResolver::restoreLiteralDollars(EnvLineParser::parseContents($contents)); + } + + private static function assertSafeContents(string $contents, string $path): void + { + if (str_starts_with($contents, "\xEF\xBB\xBF")) { + throw new UnexpectedValueException("Environment file [{$path}] must not start with a byte-order mark."); + } + + if (str_contains($contents, "\0")) { + throw new UnexpectedValueException("Environment file [{$path}] must not contain NUL bytes."); + } + } + + private static function readFile(string $path): string + { + if (!is_file($path) || !is_readable($path)) { + throw new InvalidArgumentException("Environment file [{$path}] is not readable."); + } + + $contents = file_get_contents($path); + if ($contents === false) { + throw new InvalidArgumentException("Environment file [{$path}] could not be read."); + } + + self::assertSafeContents($contents, $path); + + return $contents; + } +} diff --git a/src/Config/Support/EnvLineParser.php b/src/Config/Support/EnvLineParser.php new file mode 100644 index 0000000..07fa0a1 --- /dev/null +++ b/src/Config/Support/EnvLineParser.php @@ -0,0 +1,210 @@ + + */ + public static function parseContents(string $contents): array + { + return self::parseLines(preg_split('/\r\n|\r|\n/', $contents) ?: []); + } + + /** + * @param iterable $lines + * @return array + */ + public static function parseLines(iterable $lines): array + { + $items = []; + + foreach (self::collectLogicalLines($lines) as $number => $line) { + $line = rtrim($line, "\r\n"); + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + + $line = str_starts_with($trimmed, 'export ') + ? ltrim(substr($trimmed, 7)) + : $trimmed; + + if (!preg_match('/^([A-Za-z_][A-Za-z0-9_]*)(?:=(.*))?$/s', $line, $matches)) { + throw new UnexpectedValueException('Invalid environment entry at line ' . ($number + 1) . '.'); + } + + $items[$matches[1]] = array_key_exists(2, $matches) + ? self::parseValue($matches[2]) + : null; + } + + return $items; + } + + /** + * @param iterable $lines + * @return array + */ + private static function collectLogicalLines(iterable $lines): array + { + $logical = []; + $buffer = null; + $startLine = 0; + $quote = null; + + foreach ($lines as $lineNumber => $line) { + if ($buffer === null) { + $buffer = rtrim($line, "\r\n"); + $startLine = $lineNumber; + $quote = self::openValueQuote($buffer); + + if ($quote === null || self::quotedValueIsClosed($buffer, $quote)) { + $logical[$startLine] = $buffer; + $buffer = null; + $quote = null; + } + + continue; + } + + $buffer .= "\n" . rtrim($line, "\r\n"); + if ($quote !== null && self::quotedValueIsClosed($buffer, $quote)) { + $logical[$startLine] = $buffer; + $buffer = null; + $quote = null; + } + } + + if ($buffer !== null) { + $logical[$startLine] = $buffer; + } + + return $logical; + } + + private static function openValueQuote(string $line): ?string + { + $trimmed = trim($line); + if (str_starts_with($trimmed, 'export ')) { + $trimmed = ltrim(substr($trimmed, 7)); + } + + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*=(.*)$/', $trimmed, $matches)) { + return null; + } + + $value = $matches[1]; + + return $value !== '' && ($value[0] === '"' || $value[0] === '\'') ? $value[0] : null; + } + + private static function parseQuotedValue(string $value, string $quote): string + { + $length = strlen($value); + $result = ''; + + for ($index = 1; $index < $length; $index++) { + $char = $value[$index]; + + if ($char === $quote) { + $remainder = trim(substr($value, $index + 1)); + if ($remainder !== '' && !str_starts_with($remainder, '#')) { + throw new UnexpectedValueException('Unexpected characters after quoted environment value.'); + } + + return $result; + } + + if ($quote === '"' && $char === '\\' && $index + 1 < $length) { + $escaped = $value[++$index]; + $result .= match ($escaped) { + 'n' => "\n", + 'r' => "\r", + 't' => "\t", + '$' => EnvValueResolver::LITERAL_DOLLAR, + '"', '\\' => $escaped, + default => '\\' . $escaped, + }; + + continue; + } + + $result .= $quote === '\'' && $char === '$' ? EnvValueResolver::LITERAL_DOLLAR : $char; + } + + throw new UnexpectedValueException('Unclosed quoted environment value.'); + } + + private static function parseValue(string $value): string + { + if ($value === '') { + return ''; + } + + if ($value[0] === ' ' || $value[0] === "\t") { + throw new UnexpectedValueException('Whitespace before environment values is not supported.'); + } + + $quote = $value[0]; + if ($quote === '\'' || $quote === '"') { + return self::parseQuotedValue($value, $quote); + } + + $value = rtrim(self::stripInlineComment($value)); + if (preg_match('/\s+/', $value) && !str_contains($value, '$')) { + throw new UnexpectedValueException('Environment values containing spaces must be quoted.'); + } + + return EnvValueResolver::protectEscapedDollars($value); + } + + private static function quotedValueIsClosed(string $line, string $quote): bool + { + $offset = strpos($line, '='); + if ($offset === false) { + return true; + } + + $escaped = false; + $length = strlen($line); + for ($index = $offset + 2; $index < $length; $index++) { + $char = $line[$index]; + if ($quote === '"' && $char === '\\' && !$escaped) { + $escaped = true; + + continue; + } + + if ($char === $quote && !$escaped) { + return true; + } + + $escaped = false; + } + + return false; + } + + private static function stripInlineComment(string $value): string + { + $length = strlen($value); + for ($index = 0; $index < $length; $index++) { + if ($value[$index] === '#' && $index > 0 && ctype_space($value[$index - 1])) { + return substr($value, 0, $index); + } + } + + return $value; + } +} diff --git a/src/Config/Support/EnvReference.php b/src/Config/Support/EnvReference.php new file mode 100644 index 0000000..eff90d7 --- /dev/null +++ b/src/Config/Support/EnvReference.php @@ -0,0 +1,18 @@ +key, $this->default); + } +} diff --git a/src/Config/Support/EnvValueResolver.php b/src/Config/Support/EnvValueResolver.php new file mode 100644 index 0000000..968c95c --- /dev/null +++ b/src/Config/Support/EnvValueResolver.php @@ -0,0 +1,212 @@ + $items + * @return array + */ + public static function resolve(array $items): array + { + $resolved = []; + foreach (array_keys($items) as $name) { + $resolved[$name] = self::resolveName($name, $items, $resolved, []); + } + + return array_intersect_key($resolved, $items); + } + + /** + * @param array $items + * @return array + */ + public static function restoreLiteralDollars(array $items): array + { + foreach ($items as $name => $value) { + if ($value !== null) { + $items[$name] = str_replace(self::LITERAL_DOLLAR, '$', $value); + } + } + + return $items; + } + + private static function assertClosedVariableExpansions(string $value): void + { + $length = strlen($value); + for ($index = 0; $index < $length - 1; $index++) { + if ($value[$index] !== '$' || $value[$index + 1] !== '{') { + continue; + } + + if (self::isEscapedAt($value, $index) || str_contains(substr($value, $index + 2), '}')) { + continue; + } + + throw new UnexpectedValueException('Unclosed braces on environment variable expansion.'); + } + } + + private static function externalValueToString(mixed $value): ?string + { + if (is_scalar($value) || $value instanceof Stringable) { + return (string) $value; + } + + return null; + } + + private static function isEscapedAt(string $value, int $index): bool + { + $backslashes = 0; + for ($cursor = $index - 1; $cursor >= 0 && $value[$cursor] === '\\'; $cursor--) { + $backslashes++; + } + + return $backslashes % 2 === 1; + } + + private static function lookupExternalVariable(string $name): ?string + { + if (array_key_exists($name, $_ENV)) { + return self::externalValueToString($_ENV[$name]); + } + + if (array_key_exists($name, $_SERVER) && !str_starts_with($name, 'HTTP_')) { + return self::externalValueToString($_SERVER[$name]); + } + + $value = getenv($name); + + return $value === false ? null : $value; + } + + /** + * @param array $items + * @param array $resolved + * @param array $resolving + */ + private static function lookupVariable(string $name, array $items, array &$resolved, array $resolving): ?string + { + if (array_key_exists($name, $items)) { + if (isset($resolving[$name])) { + throw new UnexpectedValueException("Circular environment variable reference detected for [{$name}]."); + } + + return self::resolveName($name, $items, $resolved, $resolving); + } + + if (array_key_exists($name, $resolved)) { + return $resolved[$name]; + } + + return self::lookupExternalVariable($name); + } + + /** + * @param array $matches + * @param array $items + * @param array $resolved + * @param array $resolving + */ + private static function resolveMatch(array $matches, array $items, array &$resolved, array $resolving): string + { + $slashes = self::stringMatch($matches, 'slashes'); + if (strlen($slashes) % 2 === 1) { + return substr(self::stringMatch($matches, 0), 1); + } + + $name = self::stringMatch($matches, 'brace') ?: self::stringMatch($matches, 'plain'); + $defaultOperator = self::stringMatch($matches, 'operator'); + $value = self::lookupVariable($name, $items, $resolved, $resolving); + + if (($value === null || $value === '') && $defaultOperator !== '') { + $value = self::resolveVariables(self::stringMatch($matches, 'default'), $items, $resolved, $resolving); + if ($defaultOperator === ':=') { + $resolved[$name] = $value; + } + } + + return $slashes . ($value ?? ''); + } + + /** + * @param array $items + * @param array $resolved + * @param array $resolving + */ + private static function resolveName(string $name, array $items, array &$resolved, array $resolving): ?string + { + if (array_key_exists($name, $resolved)) { + return $resolved[$name]; + } + + if (isset($resolving[$name])) { + throw new UnexpectedValueException("Circular environment variable reference detected for [{$name}]."); + } + + $value = $items[$name] ?? null; + if ($value === null) { + return $resolved[$name] = null; + } + + $resolving[$name] = true; + + return $resolved[$name] = self::resolveVariables($value, $items, $resolved, $resolving); + } + + /** + * @param array $items + * @param array $resolved + * @param array $resolving + */ + private static function resolveVariables(string $value, array $items, array &$resolved, array $resolving): string + { + self::assertClosedVariableExpansions($value); + + $value = preg_replace_callback( + '/(?\\\\*)\$(?:\{(?P[A-Za-z_][A-Za-z0-9_]*)(?:(?P:-|:=)(?P[^}]*))?\}|(?P[A-Za-z_][A-Za-z0-9_]*))/', + static function (array $matches) use ($items, &$resolved, $resolving): string { + return self::resolveMatch($matches, $items, $resolved, $resolving); + }, + $value, + ); + + return str_replace(self::LITERAL_DOLLAR, '$', $value ?? ''); + } + + /** + * @param array $matches + * @param array-key $key + */ + private static function stringMatch(array $matches, int|string $key): string + { + return isset($matches[$key]) && is_string($matches[$key]) ? $matches[$key] : ''; + } +} diff --git a/src/Config/Support/Environment.php b/src/Config/Support/Environment.php new file mode 100644 index 0000000..bd9be98 --- /dev/null +++ b/src/Config/Support/Environment.php @@ -0,0 +1,64 @@ + + */ + public static function all(bool $includeHttpServerValues = false): array + { + $server = array_filter( + $_SERVER, + fn($key) => is_string($key) + && ($includeHttpServerValues || !str_starts_with($key, 'HTTP_')), + ARRAY_FILTER_USE_KEY, + ); + + $env = array_filter($_ENV, is_string(...), ARRAY_FILTER_USE_KEY); + + return $env + $server; + } + + public static function get(?string $key = null, mixed $default = null): mixed + { + if ($key === null) { + return self::all(); + } + + if (array_key_exists($key, $_ENV)) { + return $_ENV[$key]; + } + + if (array_key_exists($key, $_SERVER) && !str_starts_with($key, 'HTTP_')) { + return $_SERVER[$key]; + } + + $value = getenv($key); + + return $value === false ? $default : $value; + } + + public static function has(string $key): bool + { + if (array_key_exists($key, $_ENV)) { + return true; + } + + if (array_key_exists($key, $_SERVER) && !str_starts_with($key, 'HTTP_')) { + return true; + } + + return getenv($key) !== false; + } + + public static function ref(string $key, mixed $default = null): EnvReference + { + return new EnvReference($key, $default); + } +} diff --git a/src/traits/DTOTrait.php b/src/DTO/Concerns/DTOTrait.php similarity index 92% rename from src/traits/DTOTrait.php rename to src/DTO/Concerns/DTOTrait.php index 867a541..18f450e 100644 --- a/src/traits/DTOTrait.php +++ b/src/DTO/Concerns/DTOTrait.php @@ -2,26 +2,13 @@ declare(strict_types=1); -namespace Infocyph\ArrayKit\traits; +namespace Infocyph\ArrayKit\DTO\Concerns; use ReflectionNamedType; use ReflectionProperty; /** - * Trait DTOTrait - * - * Provides a quick way to create an object from an associative array - * and to convert an object's public properties to an array. - * - * Usage Example: - * class MyDTO { - * use DTOTrait; - * - * public string $name; - * public int $age; - * } - * - * $dto = MyDTO::create(['name' => 'Alice', 'age' => 30]); + * Provides DTO hydration and array export helpers. */ trait DTOTrait { @@ -102,7 +89,7 @@ public function replaceFromArray(array $values, array $mapping = [], bool $coerc } /** - * Convert the current object’s public properties into an array. + * Convert the current object's public properties into an array. * * @return array */ diff --git a/src/DTO/GenericDTO.php b/src/DTO/GenericDTO.php index 64a5044..dd13b6d 100644 --- a/src/DTO/GenericDTO.php +++ b/src/DTO/GenericDTO.php @@ -4,7 +4,7 @@ namespace Infocyph\ArrayKit\DTO; -use Infocyph\ArrayKit\traits\DTOTrait; +use Infocyph\ArrayKit\DTO\Concerns\DTOTrait; final class GenericDTO { diff --git a/src/functions.php b/src/functions.php index 816af6c..ed6ab07 100644 --- a/src/functions.php +++ b/src/functions.php @@ -4,12 +4,15 @@ use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Collection\Pipeline; +use Infocyph\ArrayKit\Facade\ModuleProxy; use function Infocyph\ArrayKit\array_get as arraykit_array_get; use function Infocyph\ArrayKit\array_set as arraykit_array_set; use function Infocyph\ArrayKit\chain as arraykit_chain; use function Infocyph\ArrayKit\collect as arraykit_collect; use function Infocyph\ArrayKit\compare as arraykit_compare; +use function Infocyph\ArrayKit\dotenv as arraykit_dotenv; +use function Infocyph\ArrayKit\env as arraykit_env; // Optional global helpers: // this file is intentionally not autoloaded by default to avoid global symbol pressure. @@ -93,3 +96,23 @@ function chain(mixed $data): Pipeline return arraykit_chain($data); } } + +if (!function_exists('env')) { + /** + * Read the current environment from $_ENV, $_SERVER, or getenv(). + */ + function env(?string $key = null, mixed $default = null): mixed + { + return arraykit_env($key, $default); + } +} + +if (!function_exists('dotenv')) { + /** + * Access the ArrayKit .env file parser facade. + */ + function dotenv(): ModuleProxy + { + return arraykit_dotenv(); + } +} diff --git a/src/namespaced-functions.php b/src/namespaced-functions.php index 7c140d4..16d9d90 100644 --- a/src/namespaced-functions.php +++ b/src/namespaced-functions.php @@ -7,6 +7,9 @@ use Infocyph\ArrayKit\Array\DotNotation; use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Collection\Pipeline; +use Infocyph\ArrayKit\Config\EnvParser; +use Infocyph\ArrayKit\Config\Support\Environment; +use Infocyph\ArrayKit\Facade\ModuleProxy; if (!function_exists(__NAMESPACE__ . '\\compare')) { function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool @@ -59,3 +62,17 @@ function chain(mixed $data): Pipeline return Collection::make($data)->process(); } } + +if (!function_exists(__NAMESPACE__ . '\\env')) { + function env(?string $key = null, mixed $default = null): mixed + { + return Environment::get($key, $default); + } +} + +if (!function_exists(__NAMESPACE__ . '\\dotenv')) { + function dotenv(): ModuleProxy + { + return new ModuleProxy(EnvParser::class); + } +} diff --git a/src/traits/HookTrait.php b/src/traits/HookTrait.php deleted file mode 100644 index 1a2e09c..0000000 --- a/src/traits/HookTrait.php +++ /dev/null @@ -1,100 +0,0 @@ -onGet('username', fn($value) => strtolower((string) $value)); - * $this->onSet('password', fn($plain) => password_hash((string) $plain, PASSWORD_BCRYPT)); - */ -trait HookTrait -{ - /** - * Holds the registered hooks, structured as: - * [ - * 'someKey-get' => [...callables], - * 'someKey-set' => [...callables], - * ] - * - * @var array - */ - protected array $hooks = []; - - /** - * Attach a callable hook that runs when a value is retrieved (on get) for the given offset. - * - * @param string $offset The key or offset - * @param callable $callback A transformation: fn($value) => $newValue - */ - public function onGet(string $offset, callable $callback): static - { - return $this->addHook($offset, 'get', $callback); - } - - /** - * Attach a callable hook that runs when a value is assigned (on set) for the given offset. - * - * @param string $offset The key or offset - * @param callable $callback A transformation: fn($value) => $newValue - */ - public function onSet(string $offset, callable $callback): static - { - return $this->addHook($offset, 'set', $callback); - } - - /** - * Register a hook callback for a given offset and direction ("get" or "set"). - * - * @param string|int $offset The key or offset - * @param string $direction Either "get" or "set" - * @param callable $callback The transformation function - */ - protected function addHook(string|int $offset, string $direction, callable $callback): static - { - $name = $this->getHookName((string) $offset, $direction); - - if (!in_array($callback, $this->hooks[$name] ?? [], true)) { - $this->hooks[$name][] = $callback; - } - - return $this; - } - - /** - * Construct the internal key for hooking, e.g. "offset-get" or "offset-set". - * - * @param string $hook The offset or key - * @param string $direction Either "get" or "set" - */ - protected function getHookName(string $hook, string $direction): string - { - return $hook . '-' . $direction; - } - - /** - * Apply any relevant hooks to a value before returning or storing it. - * - * @param string|int $offset The key or offset - * @param mixed $value The value to be transformed - * @param string $direction Either "get" or "set" - * @return mixed The possibly transformed value - */ - protected function processValue(string|int $offset, mixed $value, string $direction): mixed - { - $name = $this->getHookName((string) $offset, $direction); - $hooks = $this->hooks[$name] ?? []; - - foreach ($hooks as $hook) { - $value = $hook($value); - } - - return $value; - } -} diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php index 2e88285..1f1f530 100644 --- a/tests/Feature/ConfigTest.php +++ b/tests/Feature/ConfigTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\ArrayKit\Config\Config; +use Infocyph\ArrayKit\Config\Support\Environment; enum ConfigMode: string { @@ -128,6 +129,38 @@ enum ConfigMode: string } }); +it('materializes environment references and closures when exporting cache', function () { + $_ENV['ARRAYKIT_CACHE_HOST'] = 'cache.internal'; + + $cfg = new Config; + $cfg->loadArray([ + 'db' => [ + 'host' => Environment::ref('ARRAYKIT_CACHE_HOST', 'localhost'), + 'port' => fn (): int => 5432, + 'missing' => Environment::ref('ARRAYKIT_CACHE_MISSING', 'fallback'), + ], + ]); + + $cachePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'arraykit-config-cache-'.uniqid('', true).'.php'; + + try { + expect($cfg->exportCache($cachePath))->toBeTrue(); + + unset($_ENV['ARRAYKIT_CACHE_HOST']); + + $loaded = new Config; + expect($loaded->loadCache($cachePath))->toBeTrue() + ->and($loaded->get('db.host'))->toBe('cache.internal') + ->and($loaded->get('db.port'))->toBe(5432) + ->and($loaded->get('db.missing'))->toBe('fallback'); + } finally { + unset($_ENV['ARRAYKIT_CACHE_HOST']); + if (is_file($cachePath)) { + unlink($cachePath); + } + } +}); + it('memoizes reads without returning stale values after mutation', function () { $cfg = new Config; $cfg->loadArray(['app' => ['name' => 'ArrayKit']]); diff --git a/tests/Feature/DTOTraitTest.php b/tests/Feature/DTOTraitTest.php index a6c6c34..404f12f 100644 --- a/tests/Feature/DTOTraitTest.php +++ b/tests/Feature/DTOTraitTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Infocyph\ArrayKit\traits\DTOTrait; +use Infocyph\ArrayKit\DTO\Concerns\DTOTrait; final class DTOTraitAddress { diff --git a/tests/Feature/EnvParserTest.php b/tests/Feature/EnvParserTest.php new file mode 100644 index 0000000..217599a --- /dev/null +++ b/tests/Feature/EnvParserTest.php @@ -0,0 +1,167 @@ +toBe([ + 'APP_NAME' => 'ArrayKit', + 'APP_ENV' => 'local', + 'APP_KEY' => 'base64:abc#not-comment', + 'EMPTY' => '', + 'OPTIONAL' => null, + 'CACHE_DRIVER' => 'file', + ]); +}); + +it('parses quoted dotenv values', function () { + $items = EnvParser::parse(<<<'ENV' +SINGLE='value # kept' +DOUBLE="line\nnext\tTabbed" +TRAILING="value" # allowed +MULTILINE="first +second" +ENV); + + expect($items)->toBe([ + 'SINGLE' => 'value # kept', + 'DOUBLE' => "line\nnext\tTabbed", + 'TRAILING' => 'value', + 'MULTILINE' => "first\nsecond", + ]); +}); + +it('expands dotenv variable references', function () { + $_ENV['ARRAYKIT_EXTERNAL'] = 'external'; + + try { + $items = EnvParser::parse(<<<'ENV' +APP_NAME=ArrayKit +APP_URL="https://${APP_NAME}.test" +FROM_ENV=$ARRAYKIT_EXTERNAL +DEFAULT=${MISSING:-fallback} +ASSIGNED=${LATER:=created} +USES_ASSIGNED=$LATER +LITERAL_SINGLE='$APP_NAME' +LITERAL_DOUBLE="\$APP_NAME" +ENV); + + expect($items)->toBe([ + 'APP_NAME' => 'ArrayKit', + 'APP_URL' => 'https://ArrayKit.test', + 'FROM_ENV' => 'external', + 'DEFAULT' => 'fallback', + 'ASSIGNED' => 'created', + 'USES_ASSIGNED' => 'created', + 'LITERAL_SINGLE' => '$APP_NAME', + 'LITERAL_DOUBLE' => '$APP_NAME', + ]); + } finally { + unset($_ENV['ARRAYKIT_EXTERNAL']); + } +}); + +it('can parse raw dotenv values without variable expansion', function () { + $items = EnvParser::parseRaw(<<<'ENV' +APP_NAME=ArrayKit +APP_URL="https://${APP_NAME}.test" +LITERAL="\$APP_NAME" +ENV); + + expect($items)->toBe([ + 'APP_NAME' => 'ArrayKit', + 'APP_URL' => 'https://${APP_NAME}.test', + 'LITERAL' => '$APP_NAME', + ]); +}); + +it('supports hash-prefixed values', function () { + expect(EnvParser::parse(<<<'ENV' +HEX_COLOR=#FF5733 +ENV))->toBe([ + 'HEX_COLOR' => '#FF5733', + ]); +}); + +it('rejects malformed dotenv entries', function () { + expect(fn () => EnvParser::parse('1INVALID=value'))->toThrow(UnexpectedValueException::class) + ->and(fn () => EnvParser::parse('BROKEN="value'))->toThrow(UnexpectedValueException::class) + ->and(fn () => EnvParser::parse('BROKEN="value" extra'))->toThrow(UnexpectedValueException::class) + ->and(fn () => EnvParser::parse('SPACED=needs quotes'))->toThrow(UnexpectedValueException::class) + ->and(fn () => EnvParser::parse("BAD=\0value"))->toThrow(UnexpectedValueException::class) + ->and(fn () => EnvParser::parse("\xEF\xBB\xBFBAD=value"))->toThrow(UnexpectedValueException::class) + ->and(fn () => EnvParser::parse('BROKEN=${APP_NAME'))->toThrow(UnexpectedValueException::class); +}); + +it('rejects circular dotenv variable references', function () { + $_ENV['A'] = 'external'; + + try { + expect(fn () => EnvParser::parse("A=\$B\nB=\$A\n"))->toThrow(UnexpectedValueException::class) + ->and(fn () => EnvParser::parse('A=$A'))->toThrow(UnexpectedValueException::class); + } finally { + unset($_ENV['A']); + } +}); + +it('loads and merges dotenv files into config', function () { + $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'arraykit-env-'.uniqid('', true).'.env'; + file_put_contents($path, "APP_NAME=ArrayKit\nAPP_ENV=local\n"); + + try { + $config = new Config; + + expect($config->loadEnvFile($path))->toBeTrue() + ->and($config->get('APP_NAME'))->toBe('ArrayKit') + ->and($config->loadEnvFile($path))->toBeFalse(); + + $config->mergeEnvFile($path); + + expect($config->get('APP_ENV'))->toBe('local'); + } finally { + if (is_file($path)) { + unlink($path); + } + } +}); + +it('exposes the dotenv parser from the facade', function () { + expect(ArrayKit::dotenv()->parse("APP_NAME=ArrayKit\n"))->toBe([ + 'APP_NAME' => 'ArrayKit', + ]); +}); + +it('exposes the dotenv parser from the namespaced helper', function () { + expect(dotenv()->parse("APP_NAME=ArrayKit\n"))->toBe([ + 'APP_NAME' => 'ArrayKit', + ]); +}); + +it('reads current environment values from the namespaced helper and facade', function () { + $_ENV['ARRAYKIT_ENV_HELPER'] = 'from-env'; + + try { + expect(env('ARRAYKIT_ENV_HELPER'))->toBe('from-env') + ->and(env('ARRAYKIT_MISSING', 'fallback'))->toBe('fallback') + ->and(ArrayKit::env()->get('ARRAYKIT_ENV_HELPER'))->toBe('from-env') + ->and(ArrayKit::env()->has('ARRAYKIT_ENV_HELPER'))->toBeTrue(); + } finally { + unset($_ENV['ARRAYKIT_ENV_HELPER']); + } +}); diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php index 19565f0..d7b62b3 100644 --- a/tests/Feature/LazyFileConfigTest.php +++ b/tests/Feature/LazyFileConfigTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\ArrayKit\Config\LazyFileConfig; +use Infocyph\ArrayKit\Config\Support\Environment; function lazyConfigWriteArrayFile(string $directory, string $name, array $contents): void { @@ -323,6 +324,30 @@ function lazyConfigFlatIndex(string $directory): array ->and($fresh->get('db.options'))->toBe(['timeout' => 5]); }); +it('materializes environment references and closures when warming namespace cache', function () { + $_ENV['ARRAYKIT_LAZY_CACHE_HOST'] = 'lazy-cache.internal'; + + $config = new LazyFileConfig($this->configPath, items: [ + 'db' => [ + 'host' => Environment::ref('ARRAYKIT_LAZY_CACHE_HOST', 'localhost'), + 'port' => fn (): int => 3306, + ], + ], namespaceCacheDirectory: $this->cachePath); + + try { + $config->warmNamespaceCache('db'); + + unset($_ENV['ARRAYKIT_LAZY_CACHE_HOST']); + + $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + + expect($fresh->get('db.host'))->toBe('lazy-cache.internal') + ->and($fresh->get('db.port'))->toBe(3306); + } finally { + unset($_ENV['ARRAYKIT_LAZY_CACHE_HOST']); + } +}); + it('writes a flat leaf index containing only final scalar values', function () { lazyConfigWriteArrayFile($this->configPath, 'db', [ 'host' => 'localhost',