$arguments
+ * @return mixed
+ */
+ public static function __callStatic(string $name, array $arguments): mixed
+ {
+ return self::instance()->{$name}(...$arguments);
+ }
}
diff --git a/src/Exceptions/DotENVException.php b/src/Exceptions/DotENVException.php
index e1d2eda..d0ca0b8 100644
--- a/src/Exceptions/DotENVException.php
+++ b/src/Exceptions/DotENVException.php
@@ -1,18 +1,28 @@
-
- * @copyright Copyright © 2022 InitPHP
- * @license https://initphp.github.io/license.txt MIT
- * @version 2.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-namespace InitPHP\DotENV\Exceptions;
-
-class DotENVException extends \InvalidArgumentException
-{
-}
+
+ * @copyright Copyright © 2022 InitPHP
+ * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT
+ * @link https://www.muhammetsafak.com.tr
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\DotENV\Exceptions;
+
+use InvalidArgumentException;
+
+/**
+ * Raised when a `.env` / `.env.php` file cannot be located, read, or parsed.
+ *
+ * Extends {@see InvalidArgumentException} for backwards compatibility, so
+ * existing `catch (\InvalidArgumentException $e)` blocks keep working.
+ */
+class DotENVException extends InvalidArgumentException
+{
+}
diff --git a/src/Lib.php b/src/Lib.php
index 3655d0e..6f6b615 100644
--- a/src/Lib.php
+++ b/src/Lib.php
@@ -1,256 +1,30 @@
-
- * @copyright Copyright © 2022 InitPHP
- * @license https://initphp.github.io/license.txt MIT
- * @version 2.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-namespace InitPHP\DotENV;
-
-use InitPHP\DotENV\Exceptions\DotENVException;
-
-use const DIRECTORY_SEPARATOR;
-use const FILE_IGNORE_NEW_LINES;
-use const FILE_SKIP_EMPTY_LINES;
-
-use function is_string;
-use function is_array;
-use function is_numeric;
-use function intval;
-use function floatval;
-use function strtolower;
-use function preg_match;
-use function preg_replace_callback;
-use function substr;
-use function putenv;
-use function getenv;
-use function sprintf;
-use function trim;
-use function rtrim;
-use function explode;
-use function file;
-use function is_dir;
-use function basename;
-use function is_file;
-use function strlen;
-
-final class Lib
-{
-
- /** @var array */
- protected $ENV = [];
-
- /**
- * Bir .env dosyasını işler.
- *
- * @param string $path
- * @param bool $debug Eğer dosya bulunamaz ya da okunamaz ise istisna fırlatma davranışını değiştirir.
- * @return void
- * @throws DotENVException
- */
- public function create($path, $debug = true)
- {
- $this->createImmutable($path, $debug);
- }
-
- /**
- * Bir ENV değerini döndürür.
- *
- * @param string $name
- * @param mixed $default
- * @return string|bool|null|mixed
- */
- public function get($name, $default = null)
- {
- if(isset($this->ENV[$name])){
- return $this->ENV[$name];
- }
- if(isset($_ENV[$name])){
- return $this->ENV[$name] = $this->convert($_ENV[$name]);
- }
- if(isset($_SERVER[$name])){
- return $this->ENV[$name] = $this->convert($_SERVER[$name]);
- }
- if(($env = getenv($name)) !== FALSE){
- return $this->ENV[$name] = $this->convert($env);
- }
- return $default;
- }
-
- /**
- * Bir ENV değerini döndürür.
- *
- * @see get()
- * @param string $name
- * @param mixed $default
- * @return string|bool|null|mixed
- */
- public function env($name, $default = null)
- {
- return $this->get($name, $default);
- }
-
- /**
- * @param string $path
- * @param bool $debug
- * @return void
- */
- private function createImmutable($path, $debug = true)
- {
- if(is_dir($path)){
- if(($path = $this->getDirFilePath($path)) === null){
- if($debug !== FALSE){
- throw new DotENVException('The file ".env" or ".env.php" could not be found in the directory you specified.');
- }
- return;
- }
- }
- if(!is_file($path)){
- if($debug !== FALSE){
- throw new DotENVException('The ' . $path . ' file could not be found.');
- }
- return;
- }
- $basename = basename($path);
- if($basename != '.env' && $basename != '.env.php'){
- if($debug !== FALSE){
- throw new DotENVException('The file to be uploaded must be ".env" or ".env.php" file.');
- }
- return;
- }
- if($basename == '.env'){
- $immutable = [];
- if(($read = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) === FALSE){
- if($debug !== FALSE){
- throw new DotENVException('The "' . $path . '" file could not be read.');
- }
- return;
- }
- foreach ($read as $line) {
- $line = trim($line);
- if($line == '' || $this->isCommentLine($line)){
- continue;
- }
- list($key, $value) = explode('=', $line, 2);
-
- $key = trim((string)$key);
-
- if (preg_match('/^"(.*)"/i', $value, $matches) || preg_match("/^'(.*)'/i", $value, $matches)) {
- $value = $matches[1];
- } else {
- preg_match('/[ \t]*+(?:#.*)?$/i', $value, $matches);
- if (!empty($matches[0])) {
- $value = substr($value, 0, (0 - strlen($matches[0])));
- }
- $value = trim($value);
- }
- $immutable[$key] = $value;
- }
- }else{
- $immutable = $this->phpRequired($path);
- if(!is_array($immutable)){
- if($debug !== FALSE){
- throw new DotENVException('The file ".env.php" should return an associative array.');
- }
- return;
- }
- }
- $this->createArrayImmutable($immutable);
- }
-
- /**
- * @param array $assoc
- * @return void
- */
- private function createArrayImmutable($assoc)
- {
- foreach ($assoc as $key => $value) {
- if(!isset($_SERVER[$key]) && !isset($_ENV[$key])){
- if(is_string($value)){
- putenv(sprintf('%s=%s', $key, $value));
- }
- $_ENV[$key] = $value;
- $_SERVER[$key] = $value;
- }
- }
- }
-
-
- /**
- * @param $line
- * @return bool
- */
- private function isCommentLine($line)
- {
- $firstChar = substr($line, 0, 1);
- return !((bool)preg_match('/^[\w-]+$/u', $firstChar));
- }
-
- private function convert($data)
- {
- if(!is_string($data)){
- return $data;
- }
- switch (strtolower($data)) {
- case 'true':
- return true;
- case 'false':
- return false;
- case 'empty':
- case '':
- return '';
- case 'null':
- return null;
- }
- $data = $this->nestedVariables($data);
- if(is_numeric($data)){
- if((bool)preg_match('/^-?([0-9]+)$/u', $data)){
- return intval($data);
- }
- return floatval($data);
- }
- return $data;
- }
-
- private function nestedVariables($data)
- {
- return preg_replace_callback('/\${(.+)}/', function ($env) {
- $env = trim($env[1], " \t\n\r\0\x0B\"'");
- return (string)$this->get($env);
- }, $data);
- }
-
- /**
- * @param string $dir
- * @return string|null
- */
- private function getDirFilePath($dir)
- {
- $path = rtrim($dir . '\\/') . DIRECTORY_SEPARATOR;
- if(is_file(($path . '.env'))){
- $path .= '.env';
- return $path;
- }
- if(is_file($path . '.env.php')){
- $path .= '.env.php';
- return $path;
- }
- return null;
- }
-
- /**
- * @param string $file
- * @return array
- */
- private function phpRequired($file)
- {
- return require $file;
- }
-
-}
+
+ * @copyright Copyright © 2022 InitPHP
+ * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT
+ * @link https://www.muhammetsafak.com.tr
+ *
+ * @deprecated 3.0 Use {@see \InitPHP\DotENV\Repository} instead.
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\DotENV;
+
+use function class_alias;
+use function class_exists;
+
+if (!class_exists(Lib::class, false)) {
+ class_alias(Repository::class, Lib::class);
+}
diff --git a/src/Repository.php b/src/Repository.php
new file mode 100644
index 0000000..081a37d
--- /dev/null
+++ b/src/Repository.php
@@ -0,0 +1,527 @@
+
+ * @copyright Copyright © 2022 InitPHP
+ * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT
+ * @link https://www.muhammetsafak.com.tr
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\DotENV;
+
+use InitPHP\DotENV\Exceptions\DotENVException;
+
+use function array_pop;
+use function basename;
+use function explode;
+use function file;
+use function is_dir;
+use function is_file;
+use function is_numeric;
+use function is_readable;
+use function ltrim;
+use function preg_match;
+use function preg_replace;
+use function preg_replace_callback;
+use function putenv;
+use function rtrim;
+use function str_contains;
+use function strncmp;
+use function strtolower;
+use function substr;
+use function trim;
+
+use const DIRECTORY_SEPARATOR;
+use const FILE_IGNORE_NEW_LINES;
+use const FILE_SKIP_EMPTY_LINES;
+
+/**
+ * Loads `.env` / `.env.php` files into the process environment and reads
+ * values back out with type coercion and `${VAR}` interpolation.
+ *
+ * This is the concrete worker behind the {@see DotENV} facade. It can also
+ * be used standalone:
+ *
+ * ```php
+ * $env = new Repository();
+ * $env->create('/path/to/project');
+ * $debug = $env->get('APP_DEBUG', false);
+ * ```
+ */
+final class Repository
+{
+ /**
+ * Accepted file names, in lookup priority order, when a directory path
+ * is given to {@see create()}.
+ */
+ private const FILENAMES = ['.env', '.env.php'];
+
+ /**
+ * Resolved values, keyed by name. Acts as a read cache so a value is
+ * coerced/interpolated at most once per name.
+ *
+ * @var array
+ */
+ private array $cache = [];
+
+ /**
+ * Names this instance has written into `$_ENV` / `$_SERVER` / `putenv()`.
+ * Tracked so {@see flush()} can remove exactly what it added without
+ * touching pre-existing environment variables.
+ *
+ * @var array
+ */
+ private array $loaded = [];
+
+ /**
+ * Names currently being interpolated. Used to break circular `${VAR}`
+ * references (e.g. `A=${B}`, `B=${A}`) instead of recursing forever.
+ *
+ * @var list
+ */
+ private array $resolving = [];
+
+ /**
+ * Reads and defines a `.env` or `.env.php` file.
+ *
+ * If `$path` is a directory, the repository looks for a `.env` file and
+ * then a `.env.php` file inside it. Values that already exist in `$_ENV`
+ * or `$_SERVER` are never overwritten.
+ *
+ * @param string $path Path to a `.env`/`.env.php` file, or to a
+ * directory that contains one.
+ * @param bool $debug When true (default), problems throw a
+ * {@see DotENVException}; when false they are
+ * silently ignored and the method returns.
+ * @return void
+ * @throws DotENVException
+ */
+ public function create(string $path, bool $debug = true): void
+ {
+ $file = $this->resolvePath($path, $debug);
+ if ($file === null) {
+ return;
+ }
+
+ $values = $this->parseFile($file, $debug);
+ if ($values === null) {
+ return;
+ }
+
+ $this->store($values);
+ }
+
+ /**
+ * Returns an environment value.
+ *
+ * Resolution order is `$_ENV` → `$_SERVER` → `getenv()`. String values
+ * are coerced (`"true"`/`"false"`/`"null"`/`"empty"`, integers and
+ * floats) and any `${VAR}` references are interpolated.
+ *
+ * The resolved value is cached on first read, so later external changes
+ * to `$_ENV` / `$_SERVER` / `getenv()` for the same name are not picked
+ * up until {@see flush()} clears the cache. A name that is undefined is
+ * not cached, so it can still be defined and read later.
+ *
+ * @param string $name The environment variable name.
+ * @param mixed $default Returned when the variable is not defined.
+ * @return mixed
+ */
+ public function get(string $name, mixed $default = null): mixed
+ {
+ if (\array_key_exists($name, $this->cache)) {
+ return $this->cache[$name];
+ }
+ if (\in_array($name, $this->resolving, true)) {
+ // Re-entered while this same name is already being resolved: a
+ // circular `${VAR}` reference. Break the cycle with an empty
+ // string and do not cache it (the in-progress outer frame owns
+ // the cache entry).
+ return '';
+ }
+ if (\array_key_exists($name, $_ENV)) {
+ return $this->cache[$name] = $this->resolve($name, $_ENV[$name]);
+ }
+ if (\array_key_exists($name, $_SERVER)) {
+ return $this->cache[$name] = $this->resolve($name, $_SERVER[$name]);
+ }
+
+ $env = getenv($name);
+ if ($env !== false) {
+ return $this->cache[$name] = $this->resolve($name, $env);
+ }
+
+ return $default;
+ }
+
+ /**
+ * Coerces and interpolates a raw value while marking `$name` as being
+ * resolved, so a `${VAR}` reference back to it short-circuits in
+ * {@see get()} instead of recursing.
+ *
+ * @param string $name
+ * @param mixed $raw
+ * @return mixed
+ */
+ private function resolve(string $name, mixed $raw): mixed
+ {
+ $this->resolving[] = $name;
+ try {
+ return $this->convert($raw);
+ } finally {
+ array_pop($this->resolving);
+ }
+ }
+
+ /**
+ * Alias of {@see get()}.
+ *
+ * @param string $name The environment variable name.
+ * @param mixed $default Returned when the variable is not defined.
+ * @return mixed
+ */
+ public function env(string $name, mixed $default = null): mixed
+ {
+ return $this->get($name, $default);
+ }
+
+ /**
+ * Removes every value this instance defined and clears the read cache.
+ *
+ * Pre-existing environment variables (those present before {@see create()}
+ * ran) are left untouched. Primarily a seam for tests and long-running
+ * workers that need to reload configuration.
+ *
+ * @return void
+ */
+ public function flush(): void
+ {
+ foreach ($this->loaded as $key) {
+ putenv($key);
+ unset($_ENV[$key], $_SERVER[$key]);
+ }
+ $this->loaded = [];
+ $this->cache = [];
+ $this->resolving = [];
+ }
+
+ /**
+ * Turns a path into a concrete, accepted file path, or null.
+ *
+ * @param string $path
+ * @param bool $debug
+ * @return string|null The resolved file path, or null when something is
+ * wrong and `$debug` is false.
+ * @throws DotENVException
+ */
+ private function resolvePath(string $path, bool $debug): ?string
+ {
+ if (is_dir($path)) {
+ $found = $this->locateInDirectory($path);
+ if ($found === null) {
+ if ($debug) {
+ throw new DotENVException('The file ".env" or ".env.php" could not be found in the directory you specified.');
+ }
+ return null;
+ }
+ $path = $found;
+ }
+
+ if (!is_file($path)) {
+ if ($debug) {
+ throw new DotENVException(\sprintf('The "%s" file could not be found.', $path));
+ }
+ return null;
+ }
+
+ if (!\in_array(basename($path), self::FILENAMES, true)) {
+ if ($debug) {
+ throw new DotENVException('The file to be loaded must be a ".env" or ".env.php" file.');
+ }
+ return null;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Finds the first accepted env file inside a directory.
+ *
+ * @param string $directory
+ * @return string|null
+ */
+ private function locateInDirectory(string $directory): ?string
+ {
+ $base = rtrim($directory, '\\/') . DIRECTORY_SEPARATOR;
+ foreach (self::FILENAMES as $filename) {
+ if (is_file($base . $filename)) {
+ return $base . $filename;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Reads a resolved file into an associative array of raw values.
+ *
+ * @param string $file
+ * @param bool $debug
+ * @return array|null
+ * @throws DotENVException
+ */
+ private function parseFile(string $file, bool $debug): ?array
+ {
+ if (basename($file) === '.env.php') {
+ $values = $this->requirePhpFile($file);
+ if (!\is_array($values)) {
+ if ($debug) {
+ throw new DotENVException('The ".env.php" file must return an associative array.');
+ }
+ return null;
+ }
+
+ $normalised = [];
+ foreach ($values as $key => $value) {
+ $normalised[(string) $key] = $value;
+ }
+
+ return $normalised;
+ }
+
+ $lines = is_readable($file)
+ ? file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)
+ : false;
+ if ($lines === false) {
+ if ($debug) {
+ throw new DotENVException(\sprintf('The "%s" file could not be read.', $file));
+ }
+ return null;
+ }
+
+ return $this->parseLines($lines);
+ }
+
+ /**
+ * Parses the lines of a `.env` file into a key/value map.
+ *
+ * @param array $lines
+ * @return array
+ */
+ private function parseLines(array $lines): array
+ {
+ $values = [];
+ foreach ($lines as $line) {
+ $line = trim($line);
+ if ($line === '' || $this->isComment($line)) {
+ continue;
+ }
+ // A non-comment line with no '=' is malformed; skip it rather
+ // than defining a key with an empty/null value.
+ if (!str_contains($line, '=')) {
+ continue;
+ }
+
+ [$key, $value] = explode('=', $line, 2);
+ $key = $this->normaliseKey($key);
+ // Defensive: the comment and `=` checks above make an empty key
+ // unreachable today, but guard anyway so a future grammar change
+ // can never define a blank-named variable.
+ if ($key === '') {
+ continue;
+ }
+
+ $values[$key] = $this->normaliseValue($value);
+ }
+
+ return $values;
+ }
+
+ /**
+ * Decides whether a (already trimmed, non-empty) line is a comment.
+ *
+ * A line is treated as a comment unless it begins with a word character
+ * (`A-Z`, `a-z`, `0-9`, `_`) or a hyphen. This makes `#`, `;`, `//` and
+ * similar prefixes comments.
+ *
+ * @param string $line
+ * @return bool
+ */
+ private function isComment(string $line): bool
+ {
+ return preg_match('/^[\w-]/u', $line) !== 1;
+ }
+
+ /**
+ * Trims a key and strips an optional leading `export ` shell prefix.
+ *
+ * @param string $key
+ * @return string
+ */
+ private function normaliseKey(string $key): string
+ {
+ $key = trim($key);
+ if (strncmp($key, 'export ', 7) === 0) {
+ $key = ltrim(substr($key, 7));
+ }
+
+ return $key;
+ }
+
+ /**
+ * Normalises a raw value: trims it, strips matching surrounding quotes,
+ * and removes a trailing inline comment.
+ *
+ * A quoted value keeps its contents verbatim (including any `#`). For an
+ * unquoted value, an inline comment starts at the first `#` that is
+ * preceded by whitespace, so a value such as `#ffffff` is preserved.
+ *
+ * @param string $value
+ * @return string
+ */
+ private function normaliseValue(string $value): string
+ {
+ $value = trim($value);
+ if ($value === '') {
+ return '';
+ }
+
+ if (($value[0] === '"' || $value[0] === "'")
+ && preg_match('/^(["\'])(.*)\1\s*(?:#.*)?$/s', $value, $matches) === 1) {
+ return $matches[2];
+ }
+
+ $stripped = preg_replace('/\s+#.*$/s', '', $value);
+
+ return rtrim($stripped ?? $value);
+ }
+
+ /**
+ * Writes parsed values into the environment without overwriting any name
+ * that is already defined.
+ *
+ * A name is considered already defined if it is present in `$_ENV`,
+ * `$_SERVER`, or `getenv()` — the same three sources {@see get()} reads
+ * from. Checking `getenv()` too matters because a real environment
+ * variable can be visible there while absent from the superglobals (when
+ * `variables_order` excludes `E`); without it, a `.env` file would
+ * silently clobber a genuine environment variable.
+ *
+ * @param array $values
+ * @return void
+ */
+ private function store(array $values): void
+ {
+ foreach ($values as $key => $value) {
+ if (\array_key_exists($key, $_ENV)
+ || \array_key_exists($key, $_SERVER)
+ || getenv($key) !== false) {
+ continue;
+ }
+
+ if (\is_string($value)) {
+ putenv(\sprintf('%s=%s', $key, $value));
+ }
+ $_ENV[$key] = $value;
+ $_SERVER[$key] = $value;
+ $this->loaded[$key] = $key;
+ }
+ }
+
+ /**
+ * Coerces a raw value to its scalar type and interpolates `${VAR}`
+ * references. Non-string values are returned unchanged.
+ *
+ * @param mixed $data
+ * @return mixed
+ */
+ private function convert(mixed $data): mixed
+ {
+ if (!\is_string($data)) {
+ return $data;
+ }
+
+ switch (strtolower($data)) {
+ case 'true':
+ return true;
+ case 'false':
+ return false;
+ case 'null':
+ return null;
+ case 'empty':
+ case '':
+ return '';
+ }
+
+ return $this->toScalar($this->interpolate($data));
+ }
+
+ /**
+ * Coerces a numeric string to int or float, but only when the conversion
+ * round-trips exactly. This preserves values such as `007`, `+90555…`,
+ * `1e3` and integers beyond `PHP_INT_MAX` as strings instead of silently
+ * mangling them.
+ *
+ * @param string $data
+ * @return int|float|string
+ */
+ private function toScalar(string $data): int|float|string
+ {
+ if (!is_numeric($data)) {
+ return $data;
+ }
+ if ((string) (int) $data === $data) {
+ return (int) $data;
+ }
+ if ((string) (float) $data === $data) {
+ return (float) $data;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Replaces every `${VAR}` reference with the value of `VAR`.
+ *
+ * Cycle handling lives in {@see get()} / {@see resolve()}: a reference
+ * back to a name that is already being resolved short-circuits to an
+ * empty string. A reference to an undefined or non-scalar value also
+ * resolves to an empty string; a scalar is inserted via PHP's string
+ * cast (so `true` becomes `"1"`, `false`/`null` become `""`).
+ *
+ * @param string $data
+ * @return string
+ */
+ private function interpolate(string $data): string
+ {
+ $result = preg_replace_callback('/\${([^}]+)}/', function (array $match): string {
+ $name = trim($match[1], " \t\n\r\0\x0B\"'");
+ if ($name === '') {
+ return '';
+ }
+
+ $value = $this->get($name);
+
+ return \is_scalar($value) ? (string) $value : '';
+ }, $data);
+
+ return $result ?? $data;
+ }
+
+ /**
+ * Includes a `.env.php` file and returns whatever it produces.
+ *
+ * @param string $file
+ * @return mixed
+ */
+ private function requirePhpFile(string $file): mixed
+ {
+ return require $file;
+ }
+}
diff --git a/src/helpers.php b/src/helpers.php
index 186c0b9..a35f8be 100644
--- a/src/helpers.php
+++ b/src/helpers.php
@@ -1,24 +1,30 @@
-
- * @copyright Copyright © 2022 InitPHP
- * @license https://initphp.github.io/license.txt MIT
- * @version 2.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-if (!function_exists('env')) {
- /**
- * @param string $name
- * @param mixed $default
- * @return mixed
- */
- function env($name, $default = null)
- {
- return \InitPHP\DotENV\DotENV::get($name, $default);
- }
-}
+
+ * @copyright Copyright © 2022 InitPHP
+ * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT
+ * @link https://www.muhammetsafak.com.tr
+ */
+
+declare(strict_types=1);
+
+use InitPHP\DotENV\DotENV;
+
+if (!function_exists('env')) {
+ /**
+ * Returns an environment value from the shared DotENV repository.
+ *
+ * @param string $name The environment variable name.
+ * @param mixed $default Returned when the variable is not defined.
+ * @return mixed
+ */
+ function env(string $name, mixed $default = null): mixed
+ {
+ return DotENV::get($name, $default);
+ }
+}
diff --git a/tests/CreateTest.php b/tests/CreateTest.php
new file mode 100644
index 0000000..8c5cecf
--- /dev/null
+++ b/tests/CreateTest.php
@@ -0,0 +1,129 @@
+writeFile("APP_NAME=DotENV\n");
+ $repo = new Repository();
+ $repo->create($file);
+
+ self::assertSame('DotENV', $repo->get('APP_NAME'));
+ }
+
+ public function testLoadsEnvFileByDirectoryPath(): void
+ {
+ // Regression: directory-path resolution used to be broken by a
+ // `rtrim($dir . '\\/')` typo and always threw.
+ $dir = $this->writeDir("APP_ENV=production\n");
+ $repo = new Repository();
+ $repo->create($dir);
+
+ self::assertSame('production', $repo->get('APP_ENV'));
+ }
+
+ public function testLoadsEnvFileByDirectoryPathWithTrailingSlash(): void
+ {
+ $dir = $this->writeDir("WITH_SLASH=yes\n");
+ $repo = new Repository();
+ $repo->create($dir . '/');
+
+ self::assertSame('yes', $repo->get('WITH_SLASH'));
+ }
+
+ public function testPrefersDotEnvOverDotEnvPhpInTheSameDirectory(): void
+ {
+ $dir = $this->makeDir();
+ $this->writeInto($dir, '.env', "SOURCE=env\n");
+ $this->writeInto($dir, '.env.php', " 'php'];");
+
+ $repo = new Repository();
+ $repo->create($dir);
+
+ self::assertSame('env', $repo->get('SOURCE'));
+ }
+
+ public function testMissingFileThrowsByDefault(): void
+ {
+ $this->expectException(DotENVException::class);
+ $this->expectExceptionMessage('could not be found');
+
+ (new Repository())->create('/no/such/path/.env');
+ }
+
+ public function testMissingFileIsSilentWhenDebugDisabled(): void
+ {
+ (new Repository())->create('/no/such/path/.env', false);
+
+ self::assertNull((new Repository())->get('ANYTHING_AT_ALL'));
+ }
+
+ public function testDirectoryWithoutEnvFileThrows(): void
+ {
+ $this->expectException(DotENVException::class);
+ $this->expectExceptionMessage('could not be found in the directory');
+
+ (new Repository())->create($this->makeDir());
+ }
+
+ public function testRejectsFileWithUnacceptedName(): void
+ {
+ $path = $this->writeFile("X=1\n", 'config.txt');
+
+ $this->expectException(DotENVException::class);
+ $this->expectExceptionMessage('must be a ".env" or ".env.php"');
+
+ (new Repository())->create($path);
+ }
+
+ public function testRejectsFileWithUnacceptedNameSilentlyWhenDebugDisabled(): void
+ {
+ $path = $this->writeFile("X=1\n", 'config.txt');
+ (new Repository())->create($path, false);
+
+ self::assertNull((new Repository())->get('X'));
+ }
+
+ public function testDirectoryWithoutEnvFileIsSilentWhenDebugDisabled(): void
+ {
+ (new Repository())->create($this->makeDir(), false);
+
+ self::assertNull((new Repository())->get('NOTHING_HERE'));
+ }
+
+ public function testUnreadableFileThrows(): void
+ {
+ $file = $this->makeUnreadable("UNREADABLE=1\n");
+
+ $this->expectException(DotENVException::class);
+ $this->expectExceptionMessage('could not be read');
+
+ (new Repository())->create($file);
+ }
+
+ public function testUnreadableFileIsSilentWhenDebugDisabled(): void
+ {
+ $file = $this->makeUnreadable("UNREADABLE=1\n");
+ (new Repository())->create($file, false);
+
+ self::assertNull((new Repository())->get('UNREADABLE'));
+ }
+
+ private function makeUnreadable(string $contents): string
+ {
+ $file = $this->writeFile($contents);
+ chmod($file, 0000);
+ if (is_readable($file)) {
+ self::markTestSkipped('Filesystem does not enforce read permissions here (running as root?).');
+ }
+
+ return $file;
+ }
+}
diff --git a/tests/DotEnvTestCase.php b/tests/DotEnvTestCase.php
new file mode 100644
index 0000000..ba67bde
--- /dev/null
+++ b/tests/DotEnvTestCase.php
@@ -0,0 +1,149 @@
+ */
+ private array $envBackup = [];
+
+ /** @var array */
+ private array $serverBackup = [];
+
+ /** @var list */
+ private array $tempDirs = [];
+
+ /** @var list */
+ private array $putenvKeys = [];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->envBackup = $_ENV;
+ $this->serverBackup = $_SERVER;
+ }
+
+ protected function tearDown(): void
+ {
+ DotENV::reset();
+
+ // Drop any putenv()/$_ENV keys the test introduced so they do not
+ // leak into other tests through getenv().
+ foreach (array_keys($_ENV) as $key) {
+ if (!\array_key_exists($key, $this->envBackup)) {
+ putenv((string) $key);
+ }
+ }
+
+ foreach ($this->putenvKeys as $key) {
+ putenv($key);
+ }
+ $this->putenvKeys = [];
+
+ $_ENV = $this->envBackup;
+ $_SERVER = $this->serverBackup;
+
+ foreach ($this->tempDirs as $dir) {
+ $this->deleteTree($dir);
+ }
+ $this->tempDirs = [];
+
+ parent::tearDown();
+ }
+
+ /**
+ * Sets an environment variable via putenv() and records it for removal
+ * during tear-down.
+ */
+ protected function putenvValue(string $name, string $value): void
+ {
+ putenv($name . '=' . $value);
+ $this->putenvKeys[] = $name;
+ }
+
+ /**
+ * Writes a throwaway env file in a fresh temp directory and returns the
+ * full path to the file.
+ */
+ protected function writeFile(string $contents, string $filename = '.env'): string
+ {
+ return $this->writeInto($this->makeDir(), $filename, $contents);
+ }
+
+ /**
+ * Writes a throwaway env file and returns the directory that contains it
+ * (for exercising directory-path loading).
+ */
+ protected function writeDir(string $contents, string $filename = '.env'): string
+ {
+ $dir = $this->makeDir();
+ $this->writeInto($dir, $filename, $contents);
+
+ return $dir;
+ }
+
+ /**
+ * Writes a file inside an existing directory and returns its full path.
+ */
+ protected function writeInto(string $dir, string $filename, string $contents): string
+ {
+ $path = $dir . '/' . $filename;
+ file_put_contents($path, $contents);
+
+ return $path;
+ }
+
+ /**
+ * Creates an empty temp directory and returns its path.
+ */
+ protected function makeDir(): string
+ {
+ $dir = sys_get_temp_dir() . '/dotenv-test-' . uniqid('', true);
+ if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
+ self::fail(\sprintf('Could not create temp directory "%s".', $dir));
+ }
+ $this->tempDirs[] = $dir;
+
+ return $dir;
+ }
+
+ private function deleteTree(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+ foreach (scandir($dir) ?: [] as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ is_dir($path) ? $this->deleteTree($path) : unlink($path);
+ }
+ rmdir($dir);
+ }
+}
diff --git a/tests/EnvHelperTest.php b/tests/EnvHelperTest.php
new file mode 100644
index 0000000..8fd22cf
--- /dev/null
+++ b/tests/EnvHelperTest.php
@@ -0,0 +1,34 @@
+writeFile("HELPER_KEY=helper_value\n");
+ DotENV::create($file);
+
+ self::assertSame('helper_value', env('HELPER_KEY'));
+ }
+
+ public function testHelperReturnsDefaultForMissingKey(): void
+ {
+ self::assertSame('default', env('MISSING_HELPER_KEY', 'default'));
+ }
+
+ public function testHelperSharesStateWithFacade(): void
+ {
+ $file = $this->writeFile("SHARED=42\n");
+ DotENV::create($file);
+
+ self::assertSame(42, env('SHARED'));
+ self::assertSame(42, DotENV::get('SHARED'));
+ }
+}
diff --git a/tests/ExceptionTest.php b/tests/ExceptionTest.php
new file mode 100644
index 0000000..9c6689f
--- /dev/null
+++ b/tests/ExceptionTest.php
@@ -0,0 +1,34 @@
+getParentClass();
+
+ self::assertNotFalse($parent);
+ self::assertSame(InvalidArgumentException::class, $parent->getName());
+ }
+
+ public function testIsCatchableAsInvalidArgumentException(): void
+ {
+ $caught = null;
+ try {
+ throw new DotENVException('boom');
+ } catch (InvalidArgumentException $e) {
+ $caught = $e;
+ }
+
+ self::assertInstanceOf(DotENVException::class, $caught);
+ self::assertSame('boom', $caught->getMessage());
+ }
+}
diff --git a/tests/FacadeTest.php b/tests/FacadeTest.php
new file mode 100644
index 0000000..4f01568
--- /dev/null
+++ b/tests/FacadeTest.php
@@ -0,0 +1,65 @@
+writeFile("FACADE_KEY=facade_value\n");
+ DotENV::create($file);
+
+ self::assertSame('facade_value', DotENV::get('FACADE_KEY'));
+ self::assertSame('facade_value', DotENV::env('FACADE_KEY'));
+ }
+
+ public function testGetReturnsDefaultThroughFacade(): void
+ {
+ self::assertSame('def', DotENV::get('MISSING_FACADE_KEY', 'def'));
+ }
+
+ public function testInstanceReturnsSharedRepository(): void
+ {
+ $first = DotENV::instance();
+ $second = DotENV::instance();
+
+ self::assertInstanceOf(Repository::class, $first);
+ self::assertSame($first, $second);
+ }
+
+ public function testResetDropsTheSharedInstance(): void
+ {
+ $before = DotENV::instance();
+ DotENV::reset();
+ $after = DotENV::instance();
+
+ self::assertNotSame($before, $after);
+ }
+
+ public function testFlushRemovesValuesThatWereLoaded(): void
+ {
+ $file = $this->writeFile("FLUSH_ME=value\n");
+ DotENV::create($file);
+ self::assertSame('value', DotENV::get('FLUSH_ME'));
+
+ DotENV::flush();
+
+ self::assertArrayNotHasKey('FLUSH_ME', $_ENV);
+ self::assertArrayNotHasKey('FLUSH_ME', $_SERVER);
+ self::assertFalse(getenv('FLUSH_ME'));
+ }
+
+ public function testInstanceCallForwardingThroughMagicCall(): void
+ {
+ $file = $this->writeFile("MAGIC_CALL=ok\n");
+ $facade = new DotENV();
+ $facade->create($file);
+
+ self::assertSame('ok', $facade->get('MAGIC_CALL'));
+ }
+}
diff --git a/tests/InterpolationTest.php b/tests/InterpolationTest.php
new file mode 100644
index 0000000..cb52bbd
--- /dev/null
+++ b/tests/InterpolationTest.php
@@ -0,0 +1,124 @@
+load("SITE_URL=http://lvh.me\nPAGE_URL=\${SITE_URL}/page\n");
+
+ self::assertSame('http://lvh.me/page', $repo->get('PAGE_URL'));
+ }
+
+ public function testMultipleVariablesOnOneLine(): void
+ {
+ // Regression: the greedy `\${(.+)}` pattern matched across both
+ // references and resolved to an empty string.
+ $repo = $this->load("HOST=localhost\nPORT=8080\nADDR=\${HOST}:\${PORT}\n");
+
+ self::assertSame('localhost:8080', $repo->get('ADDR'));
+ }
+
+ public function testNestedInterpolation(): void
+ {
+ $repo = $this->load("A=root\nB=\${A}/b\nC=\${B}/c\n");
+
+ self::assertSame('root/b/c', $repo->get('C'));
+ }
+
+ public function testMissingVariableResolvesToEmptyString(): void
+ {
+ $repo = $this->load("VALUE=\${DOES_NOT_EXIST}suffix\n");
+
+ self::assertSame('suffix', $repo->get('VALUE'));
+ }
+
+ public function testSelfReferenceDoesNotRecurseForever(): void
+ {
+ // Regression: `A=${A}` used to recurse until the stack overflowed.
+ $repo = $this->load("A=\${A}\n");
+
+ self::assertSame('', $repo->get('A'));
+ }
+
+ public function testMutualReferenceDoesNotRecurseForever(): void
+ {
+ $repo = $this->load("B=\${C}\nC=\${B}\n");
+
+ self::assertSame('', $repo->get('B'));
+ self::assertSame('', $repo->get('C'));
+ }
+
+ public function testInterpolationStopsAtClosingBrace(): void
+ {
+ $repo = $this->load("NAME=world\nGREETING=hello-\${NAME}-!\n");
+
+ self::assertSame('hello-world-!', $repo->get('GREETING'));
+ }
+
+ public function testSelfReferenceWithSurroundingTextResolvesOnce(): void
+ {
+ // Regression: the cyclic value used to be double-counted ("-tail-tail")
+ // because the recursive lookup re-resolved the whole value and cached
+ // the partial result.
+ $repo = $this->load("A=\${A}-tail\nB=pre-\${B}-post\n");
+
+ self::assertSame('-tail', $repo->get('A'));
+ self::assertSame('pre--post', $repo->get('B'));
+ }
+
+ public function testRepeatedReadOfCyclicValueIsStable(): void
+ {
+ $repo = $this->load("A=\${A}-tail\n");
+
+ self::assertSame($repo->get('A'), $repo->get('A'));
+ }
+
+ public function testWhitespaceInsideBracesIsIgnored(): void
+ {
+ $repo = $this->load("NAME=world\nGREETING=hello \${ NAME }\n");
+
+ self::assertSame('hello world', $repo->get('GREETING'));
+ }
+
+ public function testEmptyBracesAreLeftLiteral(): void
+ {
+ // `${}` has nothing between the braces, so the reference pattern does
+ // not match it and it is kept verbatim.
+ $repo = $this->load('VALUE=a${}b' . "\n");
+
+ self::assertSame('a${}b', $repo->get('VALUE'));
+ }
+
+ public function testWhitespaceOnlyBracesResolveToEmpty(): void
+ {
+ // `${ }` matches the pattern but the name trims to empty.
+ $repo = $this->load('VALUE=a${ }b' . "\n");
+
+ self::assertSame('ab', $repo->get('VALUE'));
+ }
+
+ public function testScalarReferencesUsePhpStringCast(): void
+ {
+ // Documented behaviour: a reference is inserted via PHP's string cast,
+ // then the whole value is re-coerced. So a referenced `true` becomes
+ // "1" (and is then coerced to int 1); a referenced `false` becomes "".
+ $repo = $this->load("FLAG=true\nWRAP=\${FLAG}\nOFF=false\nOFF_WRAP=x\${OFF}y\n");
+
+ self::assertSame(1, $repo->get('WRAP'));
+ self::assertSame('xy', $repo->get('OFF_WRAP'));
+ }
+
+ private function load(string $contents): Repository
+ {
+ $repo = new Repository();
+ $repo->create($this->writeFile($contents));
+
+ return $repo;
+ }
+}
diff --git a/tests/LibBackwardsCompatibilityTest.php b/tests/LibBackwardsCompatibilityTest.php
new file mode 100644
index 0000000..e81cf2a
--- /dev/null
+++ b/tests/LibBackwardsCompatibilityTest.php
@@ -0,0 +1,36 @@
+writeFile("LEGACY=works\n");
+
+ $lib = new Lib();
+ $lib->create($file);
+
+ self::assertSame('works', $lib->get('LEGACY'));
+ }
+}
diff --git a/tests/ParsingTest.php b/tests/ParsingTest.php
new file mode 100644
index 0000000..cec5fbc
--- /dev/null
+++ b/tests/ParsingTest.php
@@ -0,0 +1,140 @@
+load(<<get('REAL'));
+ self::assertNull($repo->get('# hash comment'));
+ }
+
+ public function testIgnoresBlankLines(): void
+ {
+ $repo = $this->load("A=1\n\n\nB=2\n");
+
+ self::assertSame(1, $repo->get('A'));
+ self::assertSame(2, $repo->get('B'));
+ }
+
+ public function testTrimsWhitespaceAroundKeyAndValue(): void
+ {
+ $repo = $this->load(" SPACED_KEY = spaced value \n");
+
+ self::assertSame('spaced value', $repo->get('SPACED_KEY'));
+ }
+
+ public function testStripsDoubleQuotes(): void
+ {
+ $repo = $this->load('QUOTED="hello world"' . "\n");
+
+ self::assertSame('hello world', $repo->get('QUOTED'));
+ }
+
+ public function testStripsSingleQuotes(): void
+ {
+ $repo = $this->load("QUOTED='hello world'\n");
+
+ self::assertSame('hello world', $repo->get('QUOTED'));
+ }
+
+ public function testStripsQuotesEvenWithSpacesAroundEquals(): void
+ {
+ // Regression: the quote check used to run against the untrimmed
+ // value, so `KEY = "v"` kept its quotes.
+ $repo = $this->load('SITE = "http://lvh.me"' . "\n");
+
+ self::assertSame('http://lvh.me', $repo->get('SITE'));
+ }
+
+ public function testKeepsHashInsideQuotes(): void
+ {
+ $repo = $this->load('COLOR="#ffffff"' . "\n");
+
+ self::assertSame('#ffffff', $repo->get('COLOR'));
+ }
+
+ public function testPreservesLeadingHashInUnquotedValue(): void
+ {
+ // Regression: `#ffffff` used to be swallowed entirely as a comment.
+ $repo = $this->load("COLOR=#ffffff\n");
+
+ self::assertSame('#ffffff', $repo->get('COLOR'));
+ }
+
+ public function testStripsInlineCommentAfterWhitespace(): void
+ {
+ $repo = $this->load("URL=http://lvh.me # inline comment\n");
+
+ self::assertSame('http://lvh.me', $repo->get('URL'));
+ }
+
+ public function testStripsInlineCommentAfterQuotedValue(): void
+ {
+ $repo = $this->load('TOKEN="abc" # secret' . "\n");
+
+ self::assertSame('abc', $repo->get('TOKEN'));
+ }
+
+ public function testDoesNotTreatHashWithoutLeadingSpaceAsComment(): void
+ {
+ $repo = $this->load("FRAGMENT=path#section\n");
+
+ self::assertSame('path#section', $repo->get('FRAGMENT'));
+ }
+
+ public function testIgnoresLinesWithoutEqualsSign(): void
+ {
+ // Regression: a non-comment line with no '=' raised PHP warnings
+ // and defined a junk key.
+ $repo = $this->load("VALID=1\nLINE_WITHOUT_EQUALS\nOTHER=2\n");
+
+ self::assertSame(1, $repo->get('VALID'));
+ self::assertSame(2, $repo->get('OTHER'));
+ self::assertNull($repo->get('LINE_WITHOUT_EQUALS'));
+ }
+
+ public function testStripsExportPrefix(): void
+ {
+ $repo = $this->load("export EXPORTED=shell\n");
+
+ self::assertSame('shell', $repo->get('EXPORTED'));
+ self::assertNull($repo->get('export EXPORTED'));
+ }
+
+ public function testExportPrefixRequiresAPlainSpace(): void
+ {
+ // The `export ` strip is matched literally, so a tab after `export`
+ // is not treated as the shell prefix.
+ $repo = $this->load("export\tTABKEY=value\n");
+
+ self::assertNull($repo->get('TABKEY'));
+ }
+
+ public function testKeyWithEqualsInValue(): void
+ {
+ $repo = $this->load("DSN=mysql:host=localhost;dbname=app\n");
+
+ self::assertSame('mysql:host=localhost;dbname=app', $repo->get('DSN'));
+ }
+
+ private function load(string $contents): Repository
+ {
+ $repo = new Repository();
+ $repo->create($this->writeFile($contents));
+
+ return $repo;
+ }
+}
diff --git a/tests/PhpEnvFileTest.php b/tests/PhpEnvFileTest.php
new file mode 100644
index 0000000..dba0d19
--- /dev/null
+++ b/tests/PhpEnvFileTest.php
@@ -0,0 +1,89 @@
+loadPhp(<<<'PHP'
+ 'string value',
+ 'BOOL' => true,
+ 'INT' => 13,
+ 'NULL' => null,
+ ];
+ PHP);
+
+ self::assertSame('string value', $repo->get('STR'));
+ self::assertTrue($repo->get('BOOL'));
+ self::assertSame(13, $repo->get('INT'));
+ self::assertNull($repo->get('NULL'));
+ }
+
+ public function testInterpolatesStringValuesFromPhpFile(): void
+ {
+ $repo = $this->loadPhp(<<<'PHP'
+ 'http://lvh.me',
+ 'PAGE_URL' => '${SITE_URL}/page',
+ ];
+ PHP);
+
+ self::assertSame('http://lvh.me/page', $repo->get('PAGE_URL'));
+ }
+
+ public function testNonStringValuesAreNotPushedToGetenv(): void
+ {
+ // putenv() only accepts strings, so non-string .env.php values live in
+ // $_ENV / $_SERVER (and get()), but not in getenv(). This locks that
+ // documented limitation in place.
+ $repo = $this->loadPhp(" 8080];");
+
+ self::assertSame(8080, $repo->get('PORT_INT'));
+ self::assertFalse(getenv('PORT_INT'));
+ self::assertSame(8080, $_ENV['PORT_INT']);
+ }
+
+ public function testNonArrayReturnThrows(): void
+ {
+ $file = $this->writeFile('expectException(DotENVException::class);
+ $this->expectExceptionMessage('must return an associative array');
+
+ (new Repository())->create($file);
+ }
+
+ public function testNonArrayReturnIsSilentWhenDebugDisabled(): void
+ {
+ $file = $this->writeFile('create($file, false);
+
+ self::assertNull((new Repository())->get('ANYTHING'));
+ }
+
+ public function testLoadsPhpFileFromDirectory(): void
+ {
+ $dir = $this->writeDir(" 'yes'];", '.env.php');
+ $repo = new Repository();
+ $repo->create($dir);
+
+ self::assertSame('yes', $repo->get('FROM_DIR'));
+ }
+
+ private function loadPhp(string $contents): Repository
+ {
+ $repo = new Repository();
+ $repo->create($this->writeFile($contents, '.env.php'));
+
+ return $repo;
+ }
+}
diff --git a/tests/PriorityTest.php b/tests/PriorityTest.php
new file mode 100644
index 0000000..eeca231
--- /dev/null
+++ b/tests/PriorityTest.php
@@ -0,0 +1,81 @@
+get('PRIORITY'));
+ }
+
+ public function testServerTakesPriorityOverGetenv(): void
+ {
+ unset($_ENV['PRIORITY_SG']);
+ $_SERVER['PRIORITY_SG'] = 'from_server';
+ $this->putenvValue('PRIORITY_SG', 'from_getenv');
+
+ self::assertSame('from_server', (new Repository())->get('PRIORITY_SG'));
+ }
+
+ public function testFallsBackToGetenv(): void
+ {
+ unset($_ENV['ONLY_GETENV'], $_SERVER['ONLY_GETENV']);
+ $this->putenvValue('ONLY_GETENV', 'here');
+
+ self::assertSame('here', (new Repository())->get('ONLY_GETENV'));
+ }
+
+ public function testReturnsDefaultWhenMissing(): void
+ {
+ self::assertSame('fallback', (new Repository())->get('TOTALLY_UNSET_KEY', 'fallback'));
+ }
+
+ public function testDefaultIsNullByDefault(): void
+ {
+ self::assertNull((new Repository())->get('TOTALLY_UNSET_KEY'));
+ }
+
+ public function testCreateDoesNotOverwriteExistingEnvValue(): void
+ {
+ $_ENV['EXISTING_KEY'] = 'original';
+ $file = $this->writeFile("EXISTING_KEY=replacement\n");
+
+ (new Repository())->create($file);
+
+ self::assertSame('original', $_ENV['EXISTING_KEY']);
+ self::assertSame('original', (new Repository())->get('EXISTING_KEY'));
+ }
+
+ public function testCreateDoesNotOverwriteGetenvOnlyValue(): void
+ {
+ // A real environment variable can be visible via getenv() while
+ // absent from $_ENV/$_SERVER (variables_order without "E"). It must
+ // still win over a .env file value.
+ unset($_ENV['GETENV_ONLY'], $_SERVER['GETENV_ONLY']);
+ $this->putenvValue('GETENV_ONLY', 'real-from-environment');
+
+ $file = $this->writeFile("GETENV_ONLY=overwritten-by-file\n");
+ (new Repository())->create($file);
+
+ self::assertSame('real-from-environment', getenv('GETENV_ONLY'));
+ self::assertSame('real-from-environment', (new Repository())->get('GETENV_ONLY'));
+ }
+
+ public function testCreatePopulatesAllThreeStores(): void
+ {
+ $file = $this->writeFile("WRITTEN=value\n");
+ (new Repository())->create($file);
+
+ self::assertSame('value', $_ENV['WRITTEN']);
+ self::assertSame('value', $_SERVER['WRITTEN']);
+ self::assertSame('value', getenv('WRITTEN'));
+ }
+}
diff --git a/tests/ValueTypeTest.php b/tests/ValueTypeTest.php
new file mode 100644
index 0000000..90613cd
--- /dev/null
+++ b/tests/ValueTypeTest.php
@@ -0,0 +1,96 @@
+load("VALUE={$literal}");
+
+ self::assertSame($expected, $repo->get('VALUE'));
+ }
+
+ /**
+ * @return array
+ */
+ public static function keywordProvider(): array
+ {
+ return [
+ 'true' => ['true', true],
+ 'false' => ['false', false],
+ 'null' => ['null', null],
+ 'empty' => ['empty', ''],
+ 'TRUE upper' => ['TRUE', true],
+ 'False mixed' => ['False', false],
+ ];
+ }
+
+ public function testEmptyAssignmentBecomesEmptyString(): void
+ {
+ $repo = $this->load('BLANK=');
+
+ self::assertSame('', $repo->get('BLANK'));
+ }
+
+ public function testIntegerCoercion(): void
+ {
+ $repo = $this->load('PORT=8080');
+
+ self::assertSame(8080, $repo->get('PORT'));
+ }
+
+ public function testNegativeIntegerCoercion(): void
+ {
+ $repo = $this->load('OFFSET=-42');
+
+ self::assertSame(-42, $repo->get('OFFSET'));
+ }
+
+ public function testFloatCoercion(): void
+ {
+ $repo = $this->load('PI=3.14');
+
+ self::assertSame(3.14, $repo->get('PI'));
+ }
+
+ /**
+ * @dataProvider preservedStringProvider
+ */
+ public function testNonRoundTrippingNumbersStayStrings(string $literal): void
+ {
+ // Regression: aggressive intval()/floatval() used to mangle these.
+ $repo = $this->load("VALUE={$literal}");
+
+ self::assertSame($literal, $repo->get('VALUE'));
+ }
+
+ /**
+ * @return array
+ */
+ public static function preservedStringProvider(): array
+ {
+ return [
+ 'leading zero' => ['007'],
+ 'leading plus' => ['+905551112233'],
+ 'beyond int max' => ['99999999999999999999'],
+ 'scientific' => ['1e3'],
+ 'phone with dashes' => ['555-12-34'],
+ ];
+ }
+
+ private function load(string $contents): Repository
+ {
+ $repo = new Repository();
+ $repo->create($this->writeFile($contents));
+
+ return $repo;
+ }
+}