Skip to content

Commit

Permalink
Added new lexer and printer to EnvFile
Browse files Browse the repository at this point in the history
  • Loading branch information
jaxwilko committed Jan 4, 2023
1 parent c5de01a commit be719c0
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 122 deletions.
216 changes: 95 additions & 121 deletions src/EnvFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
namespace Winter\LaravelConfigWriter;

use Winter\LaravelConfigWriter\Contracts\DataFileInterface;
use Winter\LaravelConfigWriter\Contracts\DataFileLexerInterface;
use Winter\LaravelConfigWriter\Contracts\DataFilePrinterInterface;
use Winter\LaravelConfigWriter\Parser\EnvLexer;
use Winter\LaravelConfigWriter\Printer\EnvPrinter;

/**
* Class EnvFile
Expand All @@ -12,16 +16,23 @@ class EnvFile implements DataFileInterface
/**
* Lines of env data
*
* @var array<int, array<string|int, mixed>>
* @var array<int, array<int, mixed>>
*/
protected array $env = [];
protected array $ast = [];

/**
* Map of variable names to line indexes
* Env file lexer, used to generate ast from src
*
* @var array<string, int>
* @var DataFileLexerInterface
*/
protected array $map = [];
protected DataFileLexerInterface $lexer;

/**
* Env file printer to convert the ast back to a string
*
* @var DataFilePrinterInterface
*/
protected DataFilePrinterInterface $printer;

/**
* Filepath currently being worked on
Expand All @@ -31,11 +42,15 @@ class EnvFile implements DataFileInterface
/**
* EnvFile constructor
*/
final public function __construct(string $filePath)
{
final public function __construct(
string $filePath,
DataFileLexerInterface $lexer = null,
DataFilePrinterInterface $printer = null
) {
$this->filePath = $filePath;

list($this->env, $this->map) = $this->parse($filePath);
$this->lexer = $lexer ?? new EnvLexer();
$this->printer = $printer ?? new EnvPrinter();
$this->ast = $this->parse($this->filePath);
}

/**
Expand Down Expand Up @@ -72,19 +87,42 @@ public function set($key, $value = null)
return $this;
}

if (!isset($this->map[$key])) {
$this->env[] = [
'type' => 'var',
'key' => $key,
'value' => $value
];

$this->map[$key] = count($this->env) - 1;
foreach ($this->ast as $index => $item) {
if (
!in_array($item['token'], [
$this->lexer::T_ENV,
$this->lexer::T_QUOTED_ENV,
$this->lexer::T_ENV_NO_VALUE
])
) {
continue;
}

return $this;
if ($item['env']['key'] === $key) {
$this->ast[$index]['env']['value'] = $this->castValue($value);
// Reprocess the token type to ensure old casting rules are still applied
$this->ast[$index]['token'] = (
is_numeric($value)
|| is_bool($value)
|| is_null($value)
|| (is_string($value) && !str_contains($value, ' ') && $item['token'] === $this->lexer::T_ENV)
) ? $this->lexer::T_ENV : $this->lexer::T_QUOTED_ENV;

return $this;
}
}

$this->env[$this->map[$key]]['value'] = $value;
// We did not find the key in the AST, therefore we must create it
$this->ast[] = [
'token' => (is_numeric($value) || is_bool($value)) ? $this->lexer::T_ENV : $this->lexer::T_QUOTED_ENV,
'env' => [
'key' => $key,
'value' => $this->castValue($value)
]
];

// Add a new line
$this->addEmptyLine();

return $this;
}
Expand All @@ -94,15 +132,16 @@ public function set($key, $value = null)
*/
public function addEmptyLine(): EnvFile
{
$this->env[] = [
'type' => 'nl'
$this->ast[] = [
'match' => PHP_EOL,
'token' => $this->lexer::T_WHITESPACE,
];

return $this;
}

/**
* Write the current env lines to a fileh
* Write the current env lines to a file
*/
public function write(string $filePath = null): void
{
Expand All @@ -118,55 +157,7 @@ public function write(string $filePath = null): void
*/
public function render(): string
{
$out = '';
foreach ($this->env as $env) {
switch ($env['type']) {
case 'comment':
$out .= $env['value'];
break;
case 'var':
$out .= $env['key'] . '=' . $this->escapeValue($env['value']);
break;
}

$out .= PHP_EOL;
}

return $out;
}

/**
* Wrap a value in quotes if needed
*
* @param mixed $value
*/
protected function escapeValue($value): string
{
if (is_numeric($value)) {
return $value;
}

if ($value === true) {
return 'true';
}

if ($value === false) {
return 'false';
}

if ($value === null) {
return 'null';
}

switch ($value) {
case 'true':
case 'false':
case 'null':
return $value;
default:
// addslashes() wont work as it'll escape single quotes and they will be read literally
return '"' . str_replace('"', '\"', $value) . '"';
}
return $this->printer->render($this->ast);
}

/**
Expand All @@ -177,57 +168,12 @@ protected function escapeValue($value): string
protected function parse(string $filePath): array
{
if (!is_file($filePath)) {
return [[], []];
return [];
}

$contents = file($filePath);
if (empty($contents)) {
return [[], []];
}

$env = [];
$map = [];

foreach ($contents as $line) {
$type = !($line = trim($line))
? 'nl'
: (
(substr($line, 0, 1) === '#')
? 'comment'
: 'var'
);

$entry = [
'type' => $type
];

if ($type === 'var') {
if (strpos($line, '=') === false) {
// if we cannot split the string, handle it the same as a comment
// i.e. inject it back into the file as is
$entry['type'] = $type = 'comment';
} else {
list($key, $value) = explode('=', $line);
$entry['key'] = trim($key);
$entry['value'] = trim($value, '"');
}
}

if ($type === 'comment') {
$entry['value'] = $line;
}

$env[] = $entry;
}

foreach ($env as $index => $item) {
if ($item['type'] !== 'var') {
continue;
}
$map[$item['key']] = $index;
}

return [$env, $map];
return $this->lexer->parse($contents);
}

/**
Expand All @@ -239,13 +185,41 @@ public function getVariables(): array
{
$env = [];

foreach ($this->env as $item) {
if ($item['type'] !== 'var') {
foreach ($this->ast as $item) {
if (
!in_array($item['token'], [
$this->lexer::T_ENV,
$this->lexer::T_QUOTED_ENV
])
) {
continue;
}
$env[$item['key']] = $item['value'];

$env[$item['env']['key']] = $item['env']['value'];
}

return $env;
}

/**
* Cast values to strings
*
* @param mixed $value
*/
protected function castValue($value): string
{
if (is_null($value)) {
return 'null';
}

if ($value === true) {
return 'true';
}

if ($value === false) {
return 'false';
}

return str_replace('"', '\"', $value);
}
}
26 changes: 25 additions & 1 deletion tests/EnvFileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public function testWriteFileWithUpdatesArray()
$this->assertStringContainsString('APP_KEY="winter"', $result);
$this->assertStringContainsString('DB_USE_CONFIG_FOR_TESTING=false', $result);
$this->assertStringContainsString('MAIL_HOST="smtp.mailgun.org"', $result);
$this->assertStringContainsString('ROUTES_CACHE="winter"', $result);
$this->assertStringContainsString('ROUTES_CACHE=winter', $result);
$this->assertStringContainsString('ENABLE_CSRF=true', $result);
$this->assertStringContainsString('# HELLO WORLD', $result);
$this->assertStringContainsString('#ENV_TEST="wintercms"', $result);
Expand Down Expand Up @@ -189,4 +189,28 @@ public function testRender()

$this->assertEquals(file_get_contents($filePath), $env->render());
}

public function testHandleEdgeCases()
{
$filePath = __DIR__ . '/fixtures/env/edge-cases.env';

$env = EnvFile::open($filePath);

$this->assertEquals(file_get_contents($filePath), $env->render());
}

public function testUpdateEnvWithTrailingComment()
{
$filePath = __DIR__ . '/fixtures/env/edge-cases.env';

$env = EnvFile::open($filePath);

$env->set('APP_KEY', '123');
$result = $env->render();
$this->assertStringContainsString('APP_KEY=123 # Change this', $result);

$env->set('APP_KEY', 'this is a test');
$result = $env->render();
$this->assertStringContainsString('APP_KEY="this is a test" # Change this', $result);
}
}
16 changes: 16 additions & 0 deletions tests/fixtures/env/edge-cases.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# WINTERCMS

APP_DEBUG=true
APP_URL="http:https://localhost"

# HELLO WORLD

APP_KEY="changeme" # Change this

APP_EXAMPLE="test"

#ENV_COMMENT="1"

ENV_TEST="1"

#ENV_COMMENT=1

0 comments on commit be719c0

Please sign in to comment.