1: <?php declare(strict_types=1);
2:
3: namespace Salient\Utility;
4:
5: use Salient\Utility\Exception\InvalidEnvFileSyntaxException;
6: use Salient\Utility\Exception\InvalidEnvironmentException;
7: use Closure;
8: use RuntimeException;
9:
10: /**
11: * Work with environment variables and .env files
12: *
13: * Methods that get a value from the environment check `$_ENV`, `$_SERVER` and
14: * {@see getenv()}, and return the first value found. If it is not of the
15: * expected type, an {@see InvalidEnvironmentException} is thrown. If it is not
16: * present in the environment, `$default` is returned if given, otherwise an
17: * {@see InvalidEnvironmentException} is thrown.
18: *
19: * @api
20: */
21: final class Env extends AbstractUtility
22: {
23: /**
24: * Set locale information from the environment
25: *
26: * Locale names are set from environment variables `LC_ALL`, `LC_COLLATE`,
27: * `LC_CTYPE`, `LC_MONETARY`, `LC_NUMERIC`, `LC_TIME` and `LC_MESSAGES`, or
28: * from `LANG`. On Windows, they are set from the system's language and
29: * region settings.
30: */
31: public const APPLY_LOCALE = 1;
32:
33: /**
34: * Set the default timezone used by date and time functions from the
35: * environment
36: *
37: * If environment variable `TZ` contains a valid timezone, it is passed to
38: * {@see date_default_timezone_set()}.
39: */
40: public const APPLY_TIMEZONE = 2;
41:
42: /**
43: * Apply all recognised values from the environment to the running script
44: */
45: public const APPLY_ALL = Env::APPLY_LOCALE | Env::APPLY_TIMEZONE;
46:
47: /**
48: * Load values from one or more .env files into the environment
49: *
50: * Values are applied to `$_ENV`, `$_SERVER` and {@see putenv()} unless
51: * already present in one of them.
52: *
53: * Changes are applied after parsing all files. If a file contains invalid
54: * syntax, an exception is thrown and the environment is not modified.
55: *
56: * Later values override earlier ones.
57: *
58: * @throws InvalidEnvFileSyntaxException if invalid syntax is found.
59: */
60: public static function loadFiles(string ...$filenames): void
61: {
62: $queue = [];
63: $errors = [];
64: foreach ($filenames as $filename) {
65: $lines = explode("\n", Str::setEol(File::getContents($filename)));
66: self::parseLines($lines, $queue, $errors, $filename);
67: }
68:
69: if ($errors) {
70: throw new InvalidEnvFileSyntaxException(Inflect::format(
71: $filenames,
72: 'Unable to load .env {{#:file}}:%s',
73: count($errors) === 1
74: ? ' ' . $errors[0]
75: : Format::list($errors, "\n- %s"),
76: ));
77: }
78:
79: foreach ($queue as $name => $value) {
80: self::set($name, $value);
81: }
82: }
83:
84: /**
85: * Apply values from the environment to the running script
86: *
87: * @param int-mask-of<Env::APPLY_*> $flags
88: */
89: public static function apply(int $flags = Env::APPLY_ALL): void
90: {
91: if ($flags & self::APPLY_LOCALE) {
92: @setlocale(\LC_ALL, '');
93: }
94:
95: if ($flags & self::APPLY_TIMEZONE) {
96: $tz = Regex::replace(
97: ['/^:?(?:.*\/zoneinfo\/)?/', '/^(UTC)0$/'],
98: ['', '$1'],
99: self::get('TZ', '')
100: );
101: if ($tz !== '') {
102: $timezone = @timezone_open($tz);
103: if ($timezone !== false) {
104: $tz = $timezone->getName();
105: date_default_timezone_set($tz);
106: }
107: }
108: }
109: }
110:
111: /**
112: * Set an environment variable
113: *
114: * The value is applied to `$_ENV`, `$_SERVER` and {@see putenv()}.
115: */
116: public static function set(string $name, string $value): void
117: {
118: if (putenv($name . '=' . $value) === false) {
119: // @codeCoverageIgnoreStart
120: throw new RuntimeException(sprintf(
121: 'Unable to set environment variable: %s',
122: $name,
123: ));
124: // @codeCoverageIgnoreEnd
125: }
126: $_ENV[$name] = $value;
127: $_SERVER[$name] = $value;
128: }
129:
130: /**
131: * Unset an environment variable
132: *
133: * The variable is removed from `$_ENV`, `$_SERVER` and {@see putenv()}.
134: */
135: public static function unset(string $name): void
136: {
137: if (putenv($name) === false) {
138: // @codeCoverageIgnoreStart
139: throw new RuntimeException(sprintf(
140: 'Unable to unset environment variable: %s',
141: $name,
142: ));
143: // @codeCoverageIgnoreEnd
144: }
145: unset($_ENV[$name]);
146: unset($_SERVER[$name]);
147: }
148:
149: /**
150: * Check if a variable is present in the environment
151: */
152: public static function has(string $name): bool
153: {
154: return array_key_exists($name, $_ENV)
155: || array_key_exists($name, $_SERVER)
156: || getenv($name) !== false;
157: }
158:
159: /**
160: * Get a value from the environment
161: *
162: * @template T of string|null|false
163: *
164: * @param T|Closure(): T $default
165: * @return (T is string ? string : (T is null ? string|null : string|never))
166: */
167: public static function get(string $name, $default = false): ?string
168: {
169: $value = self::_get($name);
170: if ($value === false) {
171: return self::_default($name, $default, false);
172: }
173: return $value;
174: }
175:
176: /**
177: * Get an integer value from the environment
178: *
179: * @template T of int|null|false
180: *
181: * @param T|Closure(): T $default
182: * @return (T is int ? int : (T is null ? int|null : int|never))
183: */
184: public static function getInt(string $name, $default = false): ?int
185: {
186: $value = self::_get($name);
187: if ($value === false) {
188: return self::_default($name, $default, false);
189: }
190: $value = trim($value);
191: if (!Regex::match('/^' . Regex::INTEGER_STRING . '$/', $value)) {
192: throw new InvalidEnvironmentException(
193: sprintf('Value is not an integer: %s', $name)
194: );
195: }
196: return (int) $value;
197: }
198:
199: /**
200: * Get a boolean value from the environment
201: *
202: * @see Test::isBoolean()
203: *
204: * @template T of bool|null|-1
205: *
206: * @param T|Closure(): T $default
207: * @return (T is bool ? bool : (T is null ? bool|null : bool|never))
208: */
209: public static function getBool(string $name, $default = -1): ?bool
210: {
211: $value = self::_get($name);
212: if ($value === false) {
213: return self::_default($name, $default, -1);
214: }
215: $value = trim($value);
216: if ($value === '') {
217: return false;
218: }
219: if (!Regex::match(
220: '/^' . Regex::BOOLEAN_STRING . '$/',
221: $value,
222: $matches,
223: \PREG_UNMATCHED_AS_NULL
224: )) {
225: throw new InvalidEnvironmentException(
226: sprintf('Value is not boolean: %s', $name)
227: );
228: }
229: return $matches['true'] === null ? false : true;
230: }
231:
232: /**
233: * Get a list of values from the environment
234: *
235: * @template T of string[]|null|false
236: *
237: * @param T|Closure(): T $default
238: * @param non-empty-string $delimiter
239: * @return (T is string[] ? string[] : (T is null ? string[]|null : string[]|never))
240: */
241: public static function getList(string $name, $default = false, string $delimiter = ','): ?array
242: {
243: $value = self::_get($name);
244: if ($value === false) {
245: return self::_default($name, $default, false);
246: }
247: return $value === '' ? [] : explode($delimiter, $value);
248: }
249:
250: /**
251: * Get a list of integers from the environment
252: *
253: * @template T of int[]|null|false
254: *
255: * @param T|Closure(): T $default
256: * @param non-empty-string $delimiter
257: * @return (T is int[] ? int[] : (T is null ? int[]|null : int[]|never))
258: */
259: public static function getIntList(string $name, $default = false, string $delimiter = ','): ?array
260: {
261: $value = self::_get($name);
262: if ($value === false) {
263: return self::_default($name, $default, false);
264: }
265: if (trim($value) === '') {
266: return [];
267: }
268: foreach (explode($delimiter, $value) as $value) {
269: $value = trim($value);
270: if (!Regex::match('/^' . Regex::INTEGER_STRING . '$/', $value)) {
271: throw new InvalidEnvironmentException(
272: sprintf('Value is not an integer list: %s', $name)
273: );
274: }
275: $list[] = (int) $value;
276: }
277: return $list;
278: }
279:
280: /**
281: * Get a value from the environment, returning null if it's empty
282: *
283: * @template T of string|null|false
284: *
285: * @param T|Closure(): T $default
286: */
287: public static function getNullable(string $name, $default = false): ?string
288: {
289: $value = self::_get($name);
290: if ($value === false) {
291: return self::_default($name, $default, false);
292: }
293: return $value === '' ? null : $value;
294: }
295:
296: /**
297: * Get an integer value from the environment, returning null if it's empty
298: *
299: * @template T of int|null|false
300: *
301: * @param T|Closure(): T $default
302: */
303: public static function getNullableInt(string $name, $default = false): ?int
304: {
305: $value = self::_get($name);
306: if ($value === false) {
307: return self::_default($name, $default, false);
308: }
309: $value = trim($value);
310: if ($value === '') {
311: return null;
312: }
313: if (!Regex::match('/^' . Regex::INTEGER_STRING . '$/', $value)) {
314: throw new InvalidEnvironmentException(
315: sprintf('Value is not an integer: %s', $name)
316: );
317: }
318: return (int) $value;
319: }
320:
321: /**
322: * Get a boolean value from the environment, returning null if it's empty
323: *
324: * @template T of bool|null|-1
325: *
326: * @param T|Closure(): T $default
327: */
328: public static function getNullableBool(string $name, $default = -1): ?bool
329: {
330: $value = self::_get($name);
331: if ($value === false) {
332: return self::_default($name, $default, -1);
333: }
334: $value = trim($value);
335: if ($value === '') {
336: return null;
337: }
338: if (!Regex::match(
339: '/^' . Regex::BOOLEAN_STRING . '$/',
340: $value,
341: $matches,
342: \PREG_UNMATCHED_AS_NULL
343: )) {
344: throw new InvalidEnvironmentException(
345: sprintf('Value is not boolean: %s', $name)
346: );
347: }
348: return $matches['true'] === null ? false : true;
349: }
350:
351: /**
352: * @return string|false
353: */
354: private static function _get(string $name)
355: {
356: if (array_key_exists($name, $_ENV)) {
357: $value = $_ENV[$name];
358: } elseif (array_key_exists($name, $_SERVER)) {
359: $value = $_SERVER[$name];
360: } else {
361: $value = getenv($name, true);
362: return $value === false ? getenv($name) : $value;
363: }
364: if (!is_string($value)) {
365: throw new InvalidEnvironmentException(sprintf(
366: 'Value is not a string: %s',
367: $name,
368: ));
369: }
370: return $value;
371: }
372:
373: /**
374: * @template T of string[]|string|int[]|int|bool|null
375: * @template TDefault of false|-1
376: *
377: * @param T|Closure(): T $default
378: * @param TDefault $defaultDefault
379: * @return (TDefault is false ? (T is false ? never : T) : (T is -1 ? never : T))
380: */
381: private static function _default(string $name, $default, $defaultDefault)
382: {
383: $default = Get::value($default);
384: if ($default === $defaultDefault) {
385: throw new InvalidEnvironmentException(
386: sprintf('Value not found in environment: %s', $name)
387: );
388: }
389: // @phpstan-ignore return.type
390: return $default;
391: }
392:
393: /**
394: * Get the name of the current environment, e.g. "production" or
395: * "development"
396: *
397: * Tries each of the following in turn and returns `null` if none are
398: * present in the environment:
399: *
400: * - `app_env`
401: * - `APP_ENV`
402: * - `PHP_ENV`
403: */
404: public static function getEnvironment(): ?string
405: {
406: return self::getNullable(
407: 'app_env',
408: fn() => self::getNullable(
409: 'APP_ENV',
410: fn() => self::getNullable('PHP_ENV', null)
411: )
412: );
413: }
414:
415: /**
416: * Check if dry-run mode is enabled in the environment
417: */
418: public static function getDryRun(): bool
419: {
420: return self::getFlag('DRY_RUN');
421: }
422:
423: /**
424: * Enable or disable dry-run mode in the environment
425: */
426: public static function setDryRun(bool $value): void
427: {
428: self::setFlag('DRY_RUN', $value);
429: }
430:
431: /**
432: * Check if debug mode is enabled in the environment
433: */
434: public static function getDebug(): bool
435: {
436: return self::getFlag('DEBUG');
437: }
438:
439: /**
440: * Enable or disable debug mode in the environment
441: */
442: public static function setDebug(bool $value): void
443: {
444: self::setFlag('DEBUG', $value);
445: }
446:
447: /**
448: * Check if a flag is enabled in the environment
449: */
450: public static function getFlag(string $name): bool
451: {
452: return self::getBool($name, false);
453: }
454:
455: /**
456: * Enable or disable a flag in the environment
457: */
458: public static function setFlag(string $name, bool $value): void
459: {
460: if (self::getBool($name, false) === $value) {
461: return;
462: }
463: if ($value) {
464: self::set($name, '1');
465: } else {
466: self::unset($name);
467: }
468: }
469:
470: /**
471: * Get the current user's home directory, or null if a value for the user's
472: * home directory is not found in the environment
473: */
474: public static function getHomeDir(): ?string
475: {
476: $home = self::get('HOME', null);
477: if ($home !== null) {
478: return $home;
479: }
480:
481: $homeDrive = self::get('HOMEDRIVE', null);
482: $homePath = self::get('HOMEPATH', null);
483: if ($homeDrive !== null && $homePath !== null) {
484: return $homeDrive . $homePath;
485: }
486:
487: return null;
488: }
489:
490: /**
491: * @param string[] $lines
492: * @param array<string,string> $queue
493: * @param string[] $errors
494: */
495: private static function parseLines(
496: array $lines,
497: array &$queue,
498: array &$errors,
499: ?string $filename = null
500: ): void {
501: foreach ($lines as $i => $line) {
502: if (trim($line) === '' || $line[0] === '#') {
503: continue;
504: }
505:
506: if (!Regex::match(<<<'REGEX'
507: / ^
508: (?<name> [a-z_] [a-z0-9_]*+ ) = (?:
509: " (?<double> (?: [^"$\\`]++ | \\ ["$\\`] | \\ )*+ ) " |
510: ' (?<single> (?: [^']++ | ' \\ ' ' )*+ ) ' |
511: (?<none> (?: [^]"$'*?\\`\s[]++ | \\ . )*+ )
512: ) $ /xi
513: REGEX, $line, $matches, \PREG_UNMATCHED_AS_NULL)) {
514: $errors[] = $filename === null
515: ? sprintf('invalid syntax at index %d', $i)
516: : sprintf('invalid syntax at %s:%d', $filename, $i + 1);
517: continue;
518: }
519:
520: /** @var string */
521: $name = $matches['name'];
522: if (self::has($name)) {
523: continue;
524: }
525:
526: if (($double = $matches['double']) !== null) {
527: $queue[$name] = Regex::replace('/\\\\(["$\\\\`])/', '$1', $double);
528: } elseif (($single = $matches['single']) !== null) {
529: $queue[$name] = str_replace("'\''", "'", $single);
530: } else {
531: /** @var string */
532: $none = $matches['none'];
533: $queue[$name] = Regex::replace('/\\\\(.)/', '$1', $none);
534: }
535: }
536: }
537: }
538: