diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..89a39e0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./tests/ + + + + + src + + + \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php new file mode 100644 index 0000000..6a587d2 --- /dev/null +++ b/src/Compiler/Compiler.php @@ -0,0 +1,80 @@ + + * @license: MIT + */ + +namespace NeonXP\Dotenv\Compiler; + +use NeonXP\Dotenv\Exception\RuntimeException; +use NeonXP\Dotenv\Types\KeyValue; + +/** + * Class Compiler + * @package NeonXP\Dotenv\Compiler + */ +class Compiler implements CompilerInterface +{ + const REGEX_VARIABLE = '/\$\{(.+?)\}/'; + + /** + * @var KeyValue[] + */ + protected $collection = []; + + /** + * @var KeyValue[] + */ + protected $cache = []; + + /** + * @inheritdoc + * @param KeyValue[] $collection + */ + public function setRawCollection(array $collection): void + { + $this->collection = []; + $this->cache = []; + foreach ($collection as $keyValue) { + $this->collection[$keyValue->getKey()] = $keyValue; + } + } + + /** + * @inheritdoc + * @param KeyValue $keyValue + * @return KeyValue + */ + public function compileKeyValue(KeyValue $keyValue): KeyValue + { + $newValue = preg_replace_callback(self::REGEX_VARIABLE, function ($variable) use ($keyValue) { + $variable = $variable[1]; + if ($variable === $keyValue->getKey()) { + throw new RuntimeException('Self referencing'); + } + if (isset($this->cache[$variable])) { + return $this->cache[$variable]->getValue(); + } elseif (isset($this->collection[$variable]) && !$this->needToCompile($this->collection[$variable])) { + return $this->collection[$variable]->getValue(); + } elseif (isset($this->collection[$variable]) && $this->needToCompile($this->collection[$variable])) { + return $this->compileKeyValue($this->collection[$variable])->getValue(); + } + return "UNKNOWN VARIABLE {$variable}"; + }, $keyValue->getValue()); + $result = new KeyValue($keyValue->getKey(), $newValue); + $this->cache[$result->getKey()] = $result; + + return $result; + } + + /** + * @param KeyValue $keyValue + * @return bool + */ + protected function needToCompile(KeyValue $keyValue): bool + { + return !!preg_match(self::REGEX_VARIABLE, $keyValue->getValue()); + } +} \ No newline at end of file diff --git a/src/Compiler/CompilerInterface.php b/src/Compiler/CompilerInterface.php index 042a7d0..15b6823 100644 --- a/src/Compiler/CompilerInterface.php +++ b/src/Compiler/CompilerInterface.php @@ -1,4 +1,6 @@ * @license: MIT @@ -8,16 +10,20 @@ namespace NeonXP\Dotenv\Compiler; use NeonXP\Dotenv\Types\KeyValue; +/** + * Interface CompilerInterface + * @package NeonXP\Dotenv\Compiler + */ interface CompilerInterface { /** * @param KeyValue[] $collection */ - function setRawCollection(array $collection): void; + public function setRawCollection(array $collection): void; /** * @param KeyValue $keyValue * @return KeyValue */ - function compileKeyValue(KeyValue $keyValue): KeyValue; + public function compileKeyValue(KeyValue $keyValue): KeyValue; } \ No newline at end of file diff --git a/src/Dotenv.php b/src/Dotenv.php index 8e7ac10..3f3b0c6 100644 --- a/src/Dotenv.php +++ b/src/Dotenv.php @@ -1,4 +1,6 @@ * @license: MIT @@ -8,10 +10,16 @@ namespace NeonXP\Dotenv; use NeonXP\Dotenv\Compiler\CompilerInterface; use NeonXP\Dotenv\Exception\RuntimeException; +use NeonXP\Dotenv\Loader\FileLoader; use NeonXP\Dotenv\Loader\LoaderInterface; +use NeonXP\Dotenv\Parser\Parser; use NeonXP\Dotenv\Parser\ParserInterface; use NeonXP\Dotenv\Types\KeyValue; +/** + * Class Dotenv + * @package NeonXP\Dotenv + */ class Dotenv implements \ArrayAccess, \IteratorAggregate { /** @@ -40,6 +48,13 @@ class Dotenv implements \ArrayAccess, \IteratorAggregate */ public function __construct(LoaderInterface $loader = null, ParserInterface $parser = null, CompilerInterface $compiler = null) { + if (!$loader) { + $loader = new FileLoader(); // Default loader + } + if (!$parser) { + $parser = new Parser(); // Default parser + } + $this->loader = $loader; $this->parser = $parser; $this->compiler = $compiler; @@ -63,6 +78,10 @@ class Dotenv implements \ArrayAccess, \IteratorAggregate }, [] ); + foreach ($this->loadedValues as $key => $value) { + $_ENV[$key] = $value; + putenv($key . "=" . $value); + } return $this; } @@ -85,7 +104,7 @@ class Dotenv implements \ArrayAccess, \IteratorAggregate * @return mixed * @throws RuntimeException */ - public function get(string $key, mixed $default = null): mixed + public function get(string $key, $default = null) { if (!$this->has($key)) { return $default; @@ -122,7 +141,7 @@ class Dotenv implements \ArrayAccess, \IteratorAggregate * @return bool * @throws RuntimeException */ - public function offsetExists($offset) + public function offsetExists($offset): bool { return $this->has($offset); } diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php index 1a9be9c..5728623 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/RuntimeException.php @@ -1,4 +1,6 @@ * @license: MIT @@ -6,7 +8,10 @@ namespace NeonXP\Dotenv\Exception; - +/** + * Class RuntimeException + * @package NeonXP\Dotenv\Exception + */ class RuntimeException extends \Exception { diff --git a/src/Loader/FileLoader.php b/src/Loader/FileLoader.php new file mode 100644 index 0000000..0702a53 --- /dev/null +++ b/src/Loader/FileLoader.php @@ -0,0 +1,41 @@ + + * @license: MIT + */ + +namespace NeonXP\Dotenv\Loader; + +use NeonXP\Dotenv\Exception\RuntimeException; + +/** + * Class FileLoader + * @package NeonXP\Dotenv\Loader + */ +class FileLoader implements LoaderInterface +{ + const COMMENT_LINE_REGEX = '/^\s*#/'; + + /** + * @inheritdoc + * @param string $filePath + * @return array + * @throws RuntimeException + */ + public function load(string $filePath = '.env'): array + { + if (!file_exists($filePath)) { + throw new RuntimeException("There is no {$filePath} file!"); + } + $lines = file($filePath); + $lines = array_map('trim', $lines); + $lines = array_filter($lines, function (string $line) { + return trim($line) && !preg_match(self::COMMENT_LINE_REGEX, $line); + }); + $lines = array_values($lines); + + return $lines; + } +} \ No newline at end of file diff --git a/src/Loader/LoaderInterface.php b/src/Loader/LoaderInterface.php index 1c4f166..c848e6c 100644 --- a/src/Loader/LoaderInterface.php +++ b/src/Loader/LoaderInterface.php @@ -1,4 +1,6 @@ * @license: MIT @@ -6,9 +8,14 @@ namespace NeonXP\Dotenv\Loader; +/** + * Interface LoaderInterface + * @package NeonXP\Dotenv\Loader + */ interface LoaderInterface { /** + * Load not empty lines from file or other source * @param string $filePath * @return string[] */ diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php new file mode 100644 index 0000000..7a5c37b --- /dev/null +++ b/src/Parser/Parser.php @@ -0,0 +1,55 @@ + + * @license: MIT + */ + +namespace NeonXP\Dotenv\Parser; + +use NeonXP\Dotenv\Types\KeyValue; + +/** + * Class Parser + * @package NeonXP\Dotenv\Parser + */ +class Parser implements ParserInterface +{ + const REGEX_EXPORT_PREFIX = '/^\s*export\s/i'; + + // Quotes and comments + const SINGLE_QUOTED_LINE_WITH_COMMENT = '/^\'(.*?)\'\s+#.*?$/i'; + const DOUBLE_QUOTED_LINE_WITH_COMMENT = '/^\"(.+?)\"\s+#.*?$/i'; + const SINGLE_QUOTED_LINE = '/^\'(.+?)\'$/i'; + const DOUBLE_QUOTED_LINE = '/^\"(.*?)\"$/i'; + + // Types + const BOOLEAN = '/^(true|false)$/i'; + const NUMBER = '/^(\d+)$/'; + + public function parseLine(string $line): KeyValue + { + $line = preg_replace(self::REGEX_EXPORT_PREFIX, '', $line); + list($key, $value) = explode('=', $line, 2) + ['', '']; + $key = trim($key); + $value = trim($value); + $matches = []; + if ( + preg_match(self::SINGLE_QUOTED_LINE_WITH_COMMENT, $value, $matches) || + preg_match(self::DOUBLE_QUOTED_LINE_WITH_COMMENT, $value, $matches) || + preg_match(self::SINGLE_QUOTED_LINE, $value, $matches) || + preg_match(self::DOUBLE_QUOTED_LINE, $value, $matches) + ) { + $value = $matches[1]; + } + + if (preg_match(self::BOOLEAN, $value)) { + $value = (strtolower($value) === 'true'); + } elseif (preg_match(self::NUMBER, $value)) { + $value = intval($value); + } + + return new KeyValue($key, $value); + } +} \ No newline at end of file diff --git a/src/Parser/ParserInterface.php b/src/Parser/ParserInterface.php index b53e29f..f67ffb5 100644 --- a/src/Parser/ParserInterface.php +++ b/src/Parser/ParserInterface.php @@ -1,4 +1,6 @@ * @license: MIT @@ -8,6 +10,10 @@ namespace NeonXP\Dotenv\Parser; use NeonXP\Dotenv\Types\KeyValue; +/** + * Interface ParserInterface + * @package NeonXP\Dotenv\Parser + */ interface ParserInterface { /** diff --git a/src/Types/KeyValue.php b/src/Types/KeyValue.php index e17dc4d..54b0360 100644 --- a/src/Types/KeyValue.php +++ b/src/Types/KeyValue.php @@ -1,4 +1,6 @@ * @license: MIT @@ -6,7 +8,10 @@ namespace NeonXP\Dotenv\Types; - +/** + * Class KeyValue + * @package NeonXP\Dotenv\Types + */ class KeyValue { /** @@ -24,7 +29,7 @@ class KeyValue * @param string $key * @param mixed $value */ - public function __construct(string $key, mixed $value) + public function __construct(string $key, $value) { $this->key = $key; $this->value = $value; @@ -41,7 +46,7 @@ class KeyValue /** * @return mixed */ - public function getValue(): mixed + public function getValue() { return $this->value; } diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php new file mode 100644 index 0000000..9a820ec --- /dev/null +++ b/tests/CompilerTest.php @@ -0,0 +1,44 @@ + + * @license: MIT + */ + +use NeonXP\Dotenv\Types\KeyValue; +use PHPUnit\Framework\TestCase; + +/** + * Class CompilerTest + */ +class CompilerTest extends TestCase +{ + public function testParseLines() + { + $collection = [ + 'KEY1' => 'VALUE1', + 'KEY2' => '${KEY1} ${KEY3}', + 'KEY3' => 'VALUE3', + 'KEY4' => 'Test ${KEY2} => ${KEY3}' + ]; + $tests = [ + 'KEY1' => 'VALUE1', + 'KEY2' => 'VALUE1 VALUE3', + 'KEY3' => 'VALUE3', + 'KEY4' => 'Test VALUE1 VALUE3 => VALUE3', + ]; + $compiler = new \NeonXP\Dotenv\Compiler\Compiler(); + $collectionOfKeyValues = []; + foreach ($collection as $key => $value) { + $collectionOfKeyValues[] = new KeyValue($key, $value); + } + $compiler->setRawCollection($collectionOfKeyValues); + + foreach ($tests as $key => $expected) { + $result = $compiler->compileKeyValue(new KeyValue($key, $collection[$key])); + $this->assertEquals($key, $result->getKey()); + $this->assertEquals($expected, $result->getValue()); + } + } +} \ No newline at end of file diff --git a/tests/DotenvTest.php b/tests/DotenvTest.php new file mode 100644 index 0000000..59dd8b0 --- /dev/null +++ b/tests/DotenvTest.php @@ -0,0 +1,68 @@ + + * @license: MIT + */ + +use NeonXP\Dotenv\Compiler\CompilerInterface; +use NeonXP\Dotenv\Dotenv; +use NeonXP\Dotenv\Exception\RuntimeException; +use NeonXP\Dotenv\Loader\LoaderInterface; +use NeonXP\Dotenv\Parser\ParserInterface; + +use PHPUnit\Framework\TestCase; + +/** + * Class TestDotenv + */ +class DotenvTest extends TestCase +{ + /** + * @var LoaderInterface + */ + private $mockLoader; + + /** + * @var ParserInterface + */ + private $mockParser; + + /** + * @var CompilerInterface + */ + private $mockCompiler; + + public function setUp() + { + $this->mockLoader = new MockLoader(); + $this->mockParser = new MockParser(); + $this->mockCompiler = new MockCompiler(); + } + + public function testLoad() + { + $dotenv = new Dotenv($this->mockLoader, $this->mockParser, $this->mockCompiler); + + try { + $dotenv->get('TEST1'); + $this->assertTrue(false, 'Dotenv must throws exception if it not loaded'); + } catch (RuntimeException $exception) { + $this->assertTrue(true, 'Dotenv must throws exception if it not loaded'); + } + + $dotenv->load(); + + $this->assertNull( $dotenv->get('NOT_EXISTS')); + $this->assertEquals('default value', $dotenv->get('NOT_EXISTS', 'default value')); + $this->assertEquals('VALUE3', $dotenv->get('KEY3', 'default value')); + $this->assertEquals('VALUE3', $dotenv['KEY3']); + $idx = 1; + foreach ($dotenv as $key => $value) { + $this->assertEquals("KEY{$idx}", $key); + $this->assertEquals("VALUE{$idx}", $value); + $idx++; + } + } +} \ No newline at end of file diff --git a/tests/FileLoaderTest.php b/tests/FileLoaderTest.php new file mode 100644 index 0000000..16c7a43 --- /dev/null +++ b/tests/FileLoaderTest.php @@ -0,0 +1,23 @@ + + * @license: MIT + */ +use PHPUnit\Framework\TestCase; + +/** + * Class FileLoaderTest + */ +class FileLoaderTest extends TestCase +{ + public function testLoadfile() + { + $loader = new NeonXP\Dotenv\Loader\FileLoader(); + + $result = $loader->load(__DIR__ . '/misc/.env.test'); + + $this->assertEquals(['KEY1=VALUE1', 'KEY2=VALUE2'], $result); + } +} \ No newline at end of file diff --git a/tests/ParserTest.php b/tests/ParserTest.php new file mode 100644 index 0000000..9f13261 --- /dev/null +++ b/tests/ParserTest.php @@ -0,0 +1,37 @@ + + * @license: MIT + */ +use NeonXP\Dotenv\Parser\Parser; +use PHPUnit\Framework\TestCase; + +/** + * Class ParserTest + */ +class ParserTest extends TestCase +{ + public function testParseLines() + { + $tests = [ + "key1='value1' # comment" => ['key1', 'value1'], + "key2 = 'value2'" => ['key2', 'value2'], + "key3 = \"value3\" # comment" => ['key3', 'value3'], + "key4 =\"value4\"" => ['key4', 'value4'], + "key5 ='value5 # not comment'" => ['key5', 'value5 # not comment'], + "key6 = \"value6 # not comment\"" => ['key6', 'value6 # not comment'], + "boolean=true" => ['boolean', true], + "numeric = 123" => ['numeric', 123] + ]; + + $parser = new Parser(); + + foreach ($tests as $test => $expected) { + $result = $parser->parseLine($test); + $this->assertEquals($expected[0], $result->getKey()); + $this->assertEquals($expected[1], $result->getValue()); + } + } +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..88dc08e --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,20 @@ + + * @license: MIT + */ + +require_once (__DIR__ . "/mocks/MockLoader.php"); +require_once (__DIR__ . "/mocks/MockParser.php"); +require_once (__DIR__ . "/mocks/MockCompiler.php"); + +$vendorDir = __DIR__ . '/../../..'; +if (file_exists($file = $vendorDir . '/autoload.php')) { + require_once $file; +} else if (file_exists($file = './vendor/autoload.php')) { + require_once $file; +} else { + throw new \RuntimeException("Not found composer autoload"); +} \ No newline at end of file diff --git a/tests/misc/.env.test b/tests/misc/.env.test new file mode 100644 index 0000000..e781a8d --- /dev/null +++ b/tests/misc/.env.test @@ -0,0 +1,7 @@ +# This is comment + +# Before was empty line +KEY1=VALUE1 + +KEY2=VALUE2 +#KEY3=VALUE3 diff --git a/tests/mocks/MockCompiler.php b/tests/mocks/MockCompiler.php new file mode 100644 index 0000000..89db68f --- /dev/null +++ b/tests/mocks/MockCompiler.php @@ -0,0 +1,34 @@ + + * @license: MIT + */ + +use NeonXP\Dotenv\Compiler\CompilerInterface; +use NeonXP\Dotenv\Types\KeyValue; + +/** + * Class MockCompiler + */ +class MockCompiler implements CompilerInterface +{ + + /** + * @param KeyValue[] $collection + */ + function setRawCollection(array $collection): void + { + // Do nothing + } + + /** + * @param KeyValue $keyValue + * @return KeyValue + */ + function compileKeyValue(KeyValue $keyValue): KeyValue + { + return $keyValue; + } +} \ No newline at end of file diff --git a/tests/mocks/MockLoader.php b/tests/mocks/MockLoader.php new file mode 100644 index 0000000..bb0d557 --- /dev/null +++ b/tests/mocks/MockLoader.php @@ -0,0 +1,32 @@ + + * @license: MIT + */ + +use NeonXP\Dotenv\Loader\LoaderInterface; + +/** + * Class MockLoader + */ +class MockLoader implements LoaderInterface +{ + + /** + * Load not empty lines from file or other source + * @param string $filePath + * @return string[] + */ + public function load(string $filePath = '.env'): array + { + return [ + 'KEY1=VALUE1', + 'KEY2=VALUE2', + 'KEY3=VALUE3', + 'KEY4=VALUE4', + 'KEY5=VALUE5', + ]; + } +} \ No newline at end of file diff --git a/tests/mocks/MockParser.php b/tests/mocks/MockParser.php new file mode 100644 index 0000000..39c369c --- /dev/null +++ b/tests/mocks/MockParser.php @@ -0,0 +1,28 @@ + + * @license: MIT + */ + +use NeonXP\Dotenv\Parser\ParserInterface; +use NeonXP\Dotenv\Types\KeyValue; + +/** + * Class MockParser + */ +class MockParser implements ParserInterface +{ + + /** + * @param string $line + * @return KeyValue + */ + public function parseLine(string $line): KeyValue + { + list($key, $value) = explode("=", $line); + + return new KeyValue($key, $value); + } +} \ No newline at end of file