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