diff --git a/composer.json b/composer.json index b9ecb636d..42b3e2513 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "laravel/tinker": "^2.7", "league/csv": "~9.1", "nesbot/carbon": "^2.0", + "nikic/php-parser": "^4.10", "scssphp/scssphp": "~1.0", "symfony/yaml": "^6.0", "twig/twig": "~3.0", @@ -51,7 +52,7 @@ "require-dev": { "phpunit/phpunit": "^9.5.8", "mockery/mockery": "^1.4.4", - "squizlabs/php_codesniffer": "3.*", + "squizlabs/php_codesniffer": "^3.2", "php-parallel-lint/php-parallel-lint": "^1.0", "meyfa/phpunit-assert-gd": "^2.0.0|^3.0.0", "dms/phpunit-arraysubset-asserts": "^0.1.0|^0.2.1" diff --git a/src/Config/ConfigWriter.php b/src/Config/ConfigWriter.php index 9761b1cba..75f40831a 100644 --- a/src/Config/ConfigWriter.php +++ b/src/Config/ConfigWriter.php @@ -2,217 +2,49 @@ use Exception; +use PhpParser\Error; +use PhpParser\Lexer\Emulative; +use PhpParser\ParserFactory; +use Winter\Storm\Exception\SystemException; +use Winter\Storm\Parse\PHP\ArrayFile; + /** * Configuration rewriter * - * https://github.com/daftspunk/laravel-config-writer + * @see https://wintercms.com/docs/services/parser#data-file-array * * This class lets you rewrite array values inside a basic configuration file * that returns a single array definition (a Laravel config file) whilst maintaining * the integrity of the file, leaving comments and advanced settings intact. - * - * The following value types are supported for writing: - * - strings - * - integers - * - booleans - * - nulls - * - single-dimension arrays - * - * To do: - * - When an entry does not exist, provide a way to create it - * - * Pro Regextip: Use [\s\S] instead of . for multiline support */ class ConfigWriter { - public function toFile($filePath, $newValues, $useValidation = true) - { - $contents = file_get_contents($filePath); - $contents = $this->toContent($contents, $newValues, $useValidation); - file_put_contents($filePath, $contents); - return $contents; - } - - public function toContent($contents, $newValues, $useValidation = true) - { - $contents = $this->parseContent($contents, $newValues); - - if (!$useValidation) { - return $contents; - } - - $result = eval('?>'.$contents); - - foreach ($newValues as $key => $expectedValue) { - $parts = explode('.', $key); - - $array = $result; - foreach ($parts as $part) { - if (!is_array($array) || !array_key_exists($part, $array)) { - throw new Exception(sprintf('Unable to rewrite key "%s" in config, does it exist?', $key)); - } - - $array = $array[$part]; - } - $actualValue = $array; - - if ($actualValue != $expectedValue) { - throw new Exception(sprintf('Unable to rewrite key "%s" in config, rewrite failed', $key)); - } - } - - return $contents; - } - - protected function parseContent($contents, $newValues) - { - $result = $contents; - - foreach ($newValues as $path => $value) { - $result = $this->parseContentValue($result, $path, $value); - } - - return $result; - } - - protected function parseContentValue($contents, $path, $value) - { - $result = $contents; - $items = explode('.', $path); - $key = array_pop($items); - $replaceValue = $this->writeValueToPhp($value); - - $count = 0; - $patterns = []; - $patterns[] = $this->buildStringExpression($key, $items); - $patterns[] = $this->buildStringExpression($key, $items, '"'); - $patterns[] = $this->buildConstantExpression($key, $items); - $patterns[] = $this->buildArrayExpression($key, $items); - - foreach ($patterns as $pattern) { - $result = preg_replace($pattern, '${1}${2}'.$replaceValue, $result, 1, $count); - - if ($count > 0) { - break; - } - } - - return $result; - } - - protected function writeValueToPhp($value) - { - if (is_string($value) && strpos($value, "'") === false) { - $replaceValue = "'".$value."'"; - } - elseif (is_string($value) && strpos($value, '"') === false) { - $replaceValue = '"'.$value.'"'; - } - elseif (is_bool($value)) { - $replaceValue = ($value ? 'true' : 'false'); - } - elseif (is_null($value)) { - $replaceValue = 'null'; - } - elseif (is_array($value) && count($value) === count($value, COUNT_RECURSIVE)) { - $replaceValue = $this->writeArrayToPhp($value); - } - else { - $replaceValue = $value; - } - - $replaceValue = str_replace('$', '\$', $replaceValue); - - return $replaceValue; - } - - protected function writeArrayToPhp($array) + public function toFile(string $filePath, array $newValues): string { - $result = []; - - foreach ($array as $value) { - if (!is_array($value)) { - $result[] = $this->writeValueToPhp($value); - } - } - - return '['.implode(', ', $result).']'; + $arrayFile = ArrayFile::open($filePath)->set($newValues); + $arrayFile->write(); + return $arrayFile->render(); } - protected function buildStringExpression($targetKey, $arrayItems = [], $quoteChar = "'") + public function toContent(string $contents, $newValues): string { - $expression = []; - - // Opening expression for array items ($1) - $expression[] = $this->buildArrayOpeningExpression($arrayItems); - - // The target key opening - $expression[] = '([\'|"]'.$targetKey.'[\'|"]\s*=>\s*)['.$quoteChar.']'; - - // The target value to be replaced ($2) - $expression[] = '([^'.$quoteChar.']*)'; - - // The target key closure - $expression[] = '['.$quoteChar.']'; - - return '/' . implode('', $expression) . '/'; - } + $lexer = new Emulative([ + 'usedAttributes' => [ + 'comments', + 'startTokenPos', + 'startLine', + 'endTokenPos', + 'endLine' + ] + ]); + $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7, $lexer); - /** - * Common constants only (true, false, null, integers) - */ - protected function buildConstantExpression($targetKey, $arrayItems = []) - { - $expression = []; - - // Opening expression for array items ($1) - $expression[] = $this->buildArrayOpeningExpression($arrayItems); - - // The target key opening ($2) - $expression[] = '([\'|"]'.$targetKey.'[\'|"]\s*=>\s*)'; - - // The target value to be replaced ($3) - $expression[] = '([tT][rR][uU][eE]|[fF][aA][lL][sS][eE]|[nN][uU][lL]{2}|[\d]+)'; - - return '/' . implode('', $expression) . '/'; - } - - /** - * Single level arrays only - */ - protected function buildArrayExpression($targetKey, $arrayItems = []) - { - $expression = []; - - // Opening expression for array items ($1) - $expression[] = $this->buildArrayOpeningExpression($arrayItems); - - // The target key opening ($2) - $expression[] = '([\'|"]'.$targetKey.'[\'|"]\s*=>\s*)'; - - // The target value to be replaced ($3) - $expression[] = '(?:[aA][rR]{2}[aA][yY]\(|[\[])([^\]|)]*)[\]|)]'; - - return '/' . implode('', $expression) . '/'; - } - - protected function buildArrayOpeningExpression($arrayItems) - { - if (count($arrayItems)) { - $itemOpen = []; - foreach ($arrayItems as $item) { - // The left hand array assignment - $itemOpen[] = '[\'|"]'.$item.'[\'|"]\s*=>\s*(?:[aA][rR]{2}[aA][yY]\(|[\[])'; - } - - // Capture all opening array (non greedy) - $result = '(' . implode('[\s\S]*', $itemOpen) . '[\s\S]*?)'; - } - else { - // Gotta capture something for $1 - $result = '()'; + try { + $ast = $parser->parse($contents); + } catch (Error $e) { + throw new SystemException($e); } - return $result; + return (new ArrayFile($ast, $lexer, null))->set($newValues)->render(); } } diff --git a/src/Foundation/Console/KeyGenerateCommand.php b/src/Foundation/Console/KeyGenerateCommand.php index 84ffd0845..931ed226c 100644 --- a/src/Foundation/Console/KeyGenerateCommand.php +++ b/src/Foundation/Console/KeyGenerateCommand.php @@ -1,82 +1,48 @@ files = $files; + $env = EnvFile::open($this->laravel->environmentFilePath()); + $env->set('APP_KEY', $key); + $env->write(); } /** - * Execute the console command. + * Confirm before proceeding with the action. * - * @return void - */ - public function handle() - { - $key = $this->generateRandomKey(); - - if ($this->option('show')) { - return $this->line(''.$key.''); - } - - // Next, we will replace the application key in the config file so it is - // automatically setup for this developer. This key gets generated using a - // secure random byte generator and is later base64 encoded for storage. - if (!$this->setKeyInConfigFile($key)) { - return; - } - - $this->laravel['config']['app.key'] = $key; - - $this->info("Application key [$key] set successfully."); - } - - /** - * Set the application key in the config file. + * This method only asks for confirmation in production. * - * @param string $key + * @param string $warning + * @param \Closure|bool|null $callback * @return bool */ - protected function setKeyInConfigFile($key) + public function confirmToProceed($warning = 'Application In Production!', $callback = null) { - if (!$this->confirmToProceed()) { - return false; + if ($this->hasOption('force') && $this->option('force')) { + return true; } - $currentKey = $this->laravel['config']['app.key']; + $this->alert('An application key is already set!'); - list($path, $contents) = $this->getKeyFile(); + $confirmed = $this->confirm('Do you really wish to run this command?'); - $contents = str_replace($currentKey, $key, $contents); + if (!$confirmed) { + $this->comment('Command Canceled!'); - $this->files->put($path, $contents); + return false; + } return true; } - - /** - * Get the key file and contents. - * - * @return array - */ - protected function getKeyFile() - { - $env = $this->option('env') ? $this->option('env').'/' : ''; - - $contents = $this->files->get($path = $this->laravel['path.config']."/{$env}app.php"); - - return [$path, $contents]; - } } diff --git a/src/Parse/Contracts/DataFileInterface.php b/src/Parse/Contracts/DataFileInterface.php new file mode 100644 index 000000000..949fc0066 --- /dev/null +++ b/src/Parse/Contracts/DataFileInterface.php @@ -0,0 +1,24 @@ +filePath = $filePath; + + list($this->env, $this->map) = $this->parse($filePath); + } + + /** + * Return a new instance of `EnvFile` ready for modification of the file. + */ + public static function open(?string $filePath = null): static + { + if (!$filePath) { + $filePath = base_path('.env'); + } + + return new static($filePath); + } + + /** + * Set a property within the env. Passing an array as param 1 is also supported. + * + * ```php + * $env->set('APP_PROPERTY', 'example'); + * // or + * $env->set([ + * 'APP_PROPERTY' => 'example', + * 'DIF_PROPERTY' => 'example' + * ]); + * ``` + */ + public function set(array|string $key, $value = null): static + { + if (is_array($key)) { + foreach ($key as $item => $value) { + $this->set($item, $value); + } + return $this; + } + + if (!isset($this->map[$key])) { + $this->env[] = [ + 'type' => 'var', + 'key' => $key, + 'value' => $value + ]; + + $this->map[$key] = count($this->env) - 1; + + return $this; + } + + $this->env[$this->map[$key]]['value'] = $value; + + return $this; + } + + /** + * Push a newline onto the end of the env file + */ + public function addEmptyLine(): EnvFile + { + $this->env[] = [ + 'type' => 'nl' + ]; + + return $this; + } + + /** + * Write the current env lines to a fileh + */ + public function write(string $filePath = null): void + { + if (!$filePath) { + $filePath = $this->filePath; + } + + file_put_contents($filePath, $this->render()); + } + + /** + * Get the env lines data as a string + */ + 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) . '"'; + } + } + + /** + * Parse a .env file, returns an array of the env file data and a key => position map + */ + protected function parse(string $filePath): array + { + if (!file_exists($filePath) || !($contents = file($filePath)) || !count($contents)) { + return [[], []]; + } + + $env = []; + $map = []; + + foreach ($contents as $line) { + $type = !($line = trim($line)) + ? 'nl' + : ( + Str::startsWith($line, '#') + ? '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]; + } + + /** + * Get the variables from the current env lines data as an associative array + */ + public function getVariables(): array + { + $env = []; + + foreach ($this->env as $item) { + if ($item['type'] !== 'var') { + continue; + } + $env[$item['key']] = $item['value']; + } + + return $env; + } +} diff --git a/src/Parse/PHP/ArrayFile.php b/src/Parse/PHP/ArrayFile.php new file mode 100644 index 000000000..fe18024aa --- /dev/null +++ b/src/Parse/PHP/ArrayFile.php @@ -0,0 +1,435 @@ +astReturnIndex = $this->getAstReturnIndex($ast); + + if (is_null($this->astReturnIndex)) { + throw new \InvalidArgumentException('ArrayFiles must start with a return statement'); + } + + $this->ast = $ast; + $this->lexer = $lexer; + $this->filePath = $filePath; + $this->printer = $printer ?? new ArrayPrinter(); + } + + /** + * Return a new instance of `ArrayFile` ready for modification of the file. + * + * @throws \InvalidArgumentException if the provided path doesn't exist and $throwIfMissing is true + * @throws SystemException if the provided path is unable to be parsed + */ + public static function open(string $filePath, bool $throwIfMissing = false): static + { + $exists = file_exists($filePath); + + if (!$exists && $throwIfMissing) { + throw new \InvalidArgumentException('file not found'); + } + + $lexer = new Lexer\Emulative([ + 'usedAttributes' => [ + 'comments', + 'startTokenPos', + 'startLine', + 'endTokenPos', + 'endLine' + ] + ]); + $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7, $lexer); + + try { + $ast = $parser->parse( + $exists + ? file_get_contents($filePath) + : sprintf('set('property.key.value', 'example'); + * // or + * $config->set([ + * 'property.key1.value' => 'example', + * 'property.key2.value' => 'example' + * ]); + * ``` + */ + public function set(string|array $key, $value = null): static + { + if (is_array($key)) { + foreach ($key as $name => $value) { + $this->set($name, $value); + } + + return $this; + } + + // try to find a reference to ast object + list($target, $remaining) = $this->seek(explode('.', $key), $this->ast[$this->astReturnIndex]->expr); + + $valueType = $this->getType($value); + + // part of a path found + if ($target && $remaining) { + $target->value->items[] = $this->makeArrayItem(implode('.', $remaining), $valueType, $value); + return $this; + } + + // path to not found + if (is_null($target)) { + $this->ast[$this->astReturnIndex]->expr->items[] = $this->makeArrayItem($key, $valueType, $value); + return $this; + } + + if (!isset($target->value)) { + return $this; + } + + // special handling of function objects + if (get_class($target->value) === FuncCall::class && $valueType !== 'function') { + if ($target->value->name->parts[0] !== 'env' || !isset($target->value->args[0])) { + return $this; + } + if (isset($target->value->args[0]) && !isset($target->value->args[1])) { + $target->value->args[1] = new Arg($this->makeAstNode($valueType, $value)); + } + $target->value->args[1]->value = $this->makeAstNode($valueType, $value); + return $this; + } + + // default update in place + $target->value = $this->makeAstNode($valueType, $value); + + return $this; + } + + /** + * Creates either a simple array item or a recursive array of items + */ + protected function makeArrayItem(string $key, string $valueType, $value): ArrayItem + { + return (str_contains($key, '.')) + ? $this->makeAstArrayRecursive($key, $valueType, $value) + : new ArrayItem( + $this->makeAstNode($valueType, $value), + $this->makeAstNode($this->getType($key), $key) + ); + } + + /** + * Generate an AST node, using `PhpParser` classes, for a value + * + * @throws \RuntimeException If $type is not one of 'string', 'boolean', 'integer', 'function', 'const', 'null', or 'array' + * @return ConstFetch|LNumber|String_|Array_|FuncCall + */ + protected function makeAstNode(string $type, $value) + { + switch (strtolower($type)) { + case 'string': + return new String_($value); + case 'boolean': + return new ConstFetch(new Name($value ? 'true' : 'false')); + case 'integer': + return new LNumber($value); + case 'function': + return new FuncCall( + new Name($value->getName()), + array_map(function ($arg) { + return new Arg($this->makeAstNode($this->getType($arg), $arg)); + }, $value->getArgs()) + ); + case 'const': + return new ConstFetch(new Name($value->getName())); + case 'null': + return new ConstFetch(new Name('null')); + case 'array': + return $this->castArray($value); + default: + throw new \RuntimeException("An unimlemented replacement type ($type) was encountered"); + } + } + + /** + * Cast an array to AST + */ + protected function castArray(array $array): Array_ + { + return ($caster = function ($array, $ast) use (&$caster) { + $useKeys = []; + foreach (array_keys($array) as $i => $key) { + $useKeys[$key] = (!is_numeric($key) || $key !== $i); + } + foreach ($array as $key => $item) { + if (is_array($item)) { + $ast->items[] = new ArrayItem( + $caster($item, new Array_()), + ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) + ); + continue; + } + $ast->items[] = new ArrayItem( + $this->makeAstNode($this->getType($item), $item), + ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) + ); + } + + return $ast; + })($array, new Array_()); + } + + /** + * Returns type of var passed + * + * @param mixed $var + */ + protected function getType($var): string + { + if ($var instanceof PHPFunction) { + return 'function'; + } + + if ($var instanceof PHPConstant) { + return 'const'; + } + + return gettype($var); + } + + /** + * Returns an ArrayItem generated from a dot notation path + * + * @param string $key + * @param string $valueType + * @param mixed $value + */ + protected function makeAstArrayRecursive(string $key, string $valueType, $value): ArrayItem + { + $path = array_reverse(explode('.', $key)); + + $arrayItem = $this->makeAstNode($valueType, $value); + + foreach ($path as $index => $pathKey) { + if (is_numeric($pathKey)) { + $pathKey = (int) $pathKey; + } + $arrayItem = new ArrayItem($arrayItem, $this->makeAstNode($this->getType($pathKey), $pathKey)); + + if ($index !== array_key_last($path)) { + $arrayItem = new Array_([$arrayItem]); + } + } + + return $arrayItem; + } + + /** + * Find the return position within the ast, returns null on encountering an unsupported ast stmt. + * + * @param array $ast + * @return int|null + */ + protected function getAstReturnIndex(array $ast): ?int + { + foreach ($ast as $index => $item) { + switch (get_class($item)) { + case Stmt\Use_::class: + case Stmt\Expression::class: + break; + case Stmt\Return_::class: + return $index; + default: + return null; + } + } + + return null; + } + + /** + * Attempt to find the parent object of the targeted path. + * If the path cannot be found completely, return the nearest parent and the remainder of the path + * + * @param array $path + * @param $pointer + * @param int $depth + * @throws SystemException if trying to set a position that is already occupied by a value + */ + protected function seek(array $path, &$pointer, int $depth = 0): array + { + if (!$pointer) { + return [null, $path]; + } + + $key = array_shift($path); + + if (isset($pointer->value) && !($pointer->value instanceof ArrayItem || $pointer->value instanceof Array_)) { + throw new SystemException(sprintf( + 'Illegal offset, you are trying to set a position occupied by a value (%s)', + get_class($pointer->value) + )); + } + + foreach (($pointer->items ?? $pointer->value->items) as $index => &$item) { + // loose checking to allow for int keys + if ($item->key->value == $key) { + if (!empty($path)) { + return $this->seek($path, $item, ++$depth); + } + + return [$item, []]; + } + } + + array_unshift($path, $key); + + return [($depth > 0) ? $pointer : null, $path]; + } + + /** + * Sort the config, supports: ArrayFile::SORT_ASC, ArrayFile::SORT_DESC, callable + * + * @param string|callable $mode + * @throws \InvalidArgumentException if the provided sort type is not a callable or one of static::SORT_ASC or static::SORT_DESC + */ + public function sort($mode = self::SORT_ASC): ArrayFile + { + if (is_callable($mode)) { + usort($this->ast[0]->expr->items, $mode); + return $this; + } + + switch ($mode) { + case static::SORT_ASC: + case static::SORT_DESC: + $this->sortRecursive($this->ast[0]->expr->items, $mode); + break; + default: + throw new \InvalidArgumentException('Requested sort type is invalid'); + } + + return $this; + } + + /** + * Recursive sort an Array_ item array + */ + protected function sortRecursive(array &$array, string $mode): void + { + foreach ($array as &$item) { + if (isset($item->value) && $item->value instanceof Array_) { + $this->sortRecursive($item->value->items, $mode); + } + } + + usort($array, function ($a, $b) use ($mode) { + return $mode === static::SORT_ASC + ? $a->key->value <=> $b->key->value + : $b->key->value <=> $a->key->value; + }); + } + + /** + * Write the current config to a file + */ + public function write(string $filePath = null): void + { + if (!$filePath && $this->filePath) { + $filePath = $this->filePath; + } + + file_put_contents($filePath, $this->render()); + } + + /** + * Returns a new instance of PHPFunction + */ + public function function(string $name, array $args): PHPFunction + { + return new PHPFunction($name, $args); + } + + /** + * Returns a new instance of PHPConstant + */ + public function constant(string $name): PHPConstant + { + return new PHPConstant($name); + } + + /** + * Get the printed AST as PHP code + */ + public function render(): string + { + return $this->printer->render($this->ast, $this->lexer) . "\n"; + } + + /** + * Get currently loaded AST + * + * @return Stmt[]|null + */ + public function getAst() + { + return $this->ast; + } +} diff --git a/src/Parse/PHP/ArrayPrinter.php b/src/Parse/PHP/ArrayPrinter.php new file mode 100644 index 000000000..04bf38937 --- /dev/null +++ b/src/Parse/PHP/ArrayPrinter.php @@ -0,0 +1,316 @@ +lexer = $lexer; + + $p = "prettyPrint($stmts); + + if ($stmts[0] instanceof Stmt\InlineHTML) { + $p = preg_replace('/^<\?php\s+\?>\n?/', '', $p); + } + if ($stmts[count($stmts) - 1] instanceof Stmt\InlineHTML) { + $p = preg_replace('/<\?php$/', '', rtrim($p)); + } + + $this->lexer = null; + + return $p; + } + + /** + * @param array $nodes + * @param bool $trailingComma + * @return string + */ + protected function pMaybeMultiline(array $nodes, bool $trailingComma = false) + { + if ($this->hasNodeWithComments($nodes) || (isset($nodes[0]) && $nodes[0] instanceof Expr\ArrayItem)) { + return $this->pCommaSeparatedMultiline($nodes, $trailingComma) . $this->nl; + } else { + return $this->pCommaSeparated($nodes); + } + } + + /** + * Pretty prints a comma-separated list of nodes in multiline style, including comments. + * + * The result includes a leading newline and one level of indentation (same as pStmts). + * + * @param Node[] $nodes Array of Nodes to be printed + * @param bool $trailingComma Whether to use a trailing comma + * + * @return string Comma separated pretty printed nodes in multiline style + */ + protected function pCommaSeparatedMultiline(array $nodes, bool $trailingComma): string + { + $this->indent(); + + $result = ''; + $lastIdx = count($nodes) - 1; + foreach ($nodes as $idx => $node) { + if ($node !== null) { + $comments = $node->getComments(); + + if ($comments) { + $result .= $this->pComments($comments); + } + + $result .= $this->nl . $this->p($node); + } else { + $result = trim($result) . "\n"; + } + if ($trailingComma || $idx !== $lastIdx) { + $result .= ','; + } + } + + $this->outdent(); + return $result; + } + + /** + * Render an array expression + * + * @param Expr\Array_ $node Array expression node + * + * @return string Comma separated pretty printed nodes in multiline style + */ + protected function pExpr_Array(Expr\Array_ $node): string + { + $default = $this->options['shortArraySyntax'] + ? Expr\Array_::KIND_SHORT + : Expr\Array_::KIND_LONG; + + $ops = $node->getAttribute('kind', $default) === Expr\Array_::KIND_SHORT + ? ['[', ']'] + : ['array(', ')']; + + if (!count($node->items) && $comments = $this->getNodeComments($node)) { + // the array has no items, we can inject whatever we want + return sprintf( + '%s%s%s%s%s', + // opening control char + $ops[0], + // indent and add nl string + $this->indent(), + // join all comments with nl string + implode($this->nl, $comments), + // outdent and add nl string + $this->outdent(), + // closing control char + $ops[1] + ); + } + + if ($comments = $this->getCommentsNotInArray($node)) { + // array has items, we have detected comments not included within the array, therefore we have found + // trailing comments and must append them to the end of the array + return sprintf( + '%s%s%s%s%s%s', + // opening control char + $ops[0], + // render the children + $this->pMaybeMultiline($node->items, true), + // add 1 level of indentation + str_repeat(' ', 4), + // join all comments with the current indentation + implode($this->nl . str_repeat(' ', 4), $comments), + // add a trailing nl + $this->nl, + // closing control char + $ops[1] + ); + } + + // default return + return $ops[0] . $this->pMaybeMultiline($node->items, true) . $ops[1]; + } + + /** + * Increase indentation level. + * Proxied to allow for nl return + * + * @return string + */ + protected function indent(): string + { + $this->indentLevel += 4; + $this->nl .= ' '; + return $this->nl; + } + + /** + * Decrease indentation level. + * Proxied to allow for nl return + * + * @return string + */ + protected function outdent(): string + { + assert($this->indentLevel >= 4); + $this->indentLevel -= 4; + $this->nl = "\n" . str_repeat(' ', $this->indentLevel); + return $this->nl; + } + + /** + * Get all comments that have not been attributed to a node within a node array + * + * @param Expr\Array_ $nodes Array of nodes + * + * @return array Comments found + */ + protected function getCommentsNotInArray(Expr\Array_ $nodes): array + { + if (!$comments = $this->getNodeComments($nodes)) { + return []; + } + + return array_filter($comments, function ($comment) use ($nodes) { + return !$this->commentInNodeList($nodes->items, $comment); + }); + } + + /** + * Recursively check if a comment exists in an array of nodes + * + * @param Node[] $nodes Array of nodes + * @param string $comment The comment to search for + * + * @return bool + */ + protected function commentInNodeList(array $nodes, string $comment): bool + { + foreach ($nodes as $node) { + if ($node->value instanceof Expr\Array_ && $this->commentInNodeList($node->value->items, $comment)) { + return true; + } + if ($nodeComments = $node->getAttribute('comments')) { + foreach ($nodeComments as $nodeComment) { + if ($nodeComment->getText() === $comment) { + return true; + } + } + } + } + + return false; + } + + /** + * Check the lexer tokens for comments within the node's start & end position + * + * @param Node $node Node to check + * + * @return ?array + */ + protected function getNodeComments(Node $node): ?array + { + $tokens = $this->lexer->getTokens(); + $pos = $node->getAttribute('startTokenPos'); + $end = $node->getAttribute('endTokenPos'); + $endLine = $node->getAttribute('endLine'); + $content = []; + + while (++$pos < $end) { + if (!isset($tokens[$pos]) || (!is_array($tokens[$pos]) && $tokens[$pos] !== ',')) { + break; + } + + if ($tokens[$pos][0] === T_WHITESPACE || $tokens[$pos] === ',') { + continue; + } + + list($type, $string, $line) = $tokens[$pos]; + + if ($line > $endLine) { + break; + } + + if ($type === T_COMMENT || $type === T_DOC_COMMENT) { + $content[] = $string; + } elseif ($content) { + break; + } + } + + return empty($content) ? null : $content; + } + + /** + * Prints reformatted text of the passed comments. + * + * @param array $comments List of comments + * + * @return string Reformatted text of comments + */ + protected function pComments(array $comments): string + { + $formattedComments = []; + + foreach ($comments as $comment) { + $formattedComments[] = str_replace("\n", $this->nl, $comment->getReformattedText()); + } + + $padding = $comments[0]->getStartLine() !== $comments[count($comments) - 1]->getEndLine() ? $this->nl : ''; + + return "\n" . $this->nl . trim($padding . implode($this->nl, $formattedComments)) . "\n"; + } + + protected function pExpr_Include(Expr\Include_ $node) + { + static $map = [ + Expr\Include_::TYPE_INCLUDE => 'include', + Expr\Include_::TYPE_INCLUDE_ONCE => 'include_once', + Expr\Include_::TYPE_REQUIRE => 'require', + Expr\Include_::TYPE_REQUIRE_ONCE => 'require_once', + ]; + + return $map[$node->type] . '(' . $this->p($node->expr) . ')'; + } +} diff --git a/src/Parse/PHP/PHPConstant.php b/src/Parse/PHP/PHPConstant.php new file mode 100644 index 000000000..887a9eac5 --- /dev/null +++ b/src/Parse/PHP/PHPConstant.php @@ -0,0 +1,25 @@ +name = $name; + } + + /** + * Get the const name + */ + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Parse/PHP/PHPFunction.php b/src/Parse/PHP/PHPFunction.php new file mode 100644 index 000000000..1874b1e26 --- /dev/null +++ b/src/Parse/PHP/PHPFunction.php @@ -0,0 +1,47 @@ +name = $name; + $this->args = $args; + } + + /** + * Get the function name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the function arguments + * + * @return array + */ + public function getArgs(): array + { + return $this->args; + } +} diff --git a/tests/Parse/ArrayFileTest.php b/tests/Parse/ArrayFileTest.php new file mode 100644 index 000000000..bb7a85090 --- /dev/null +++ b/tests/Parse/ArrayFileTest.php @@ -0,0 +1,845 @@ +assertInstanceOf(ArrayFile::class, $arrayFile); + + $ast = $arrayFile->getAst(); + + $this->assertTrue(isset($ast[0]->expr->items[0]->key->value)); + $this->assertEquals('debug', $ast[0]->expr->items[0]->key->value); + } + + public function testWriteFile() + { + $filePath = __DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'; + $tmpFile = __DIR__ . '/../fixtures/parse/arrayfile/temp-array-file.php'; + + $arrayFile = ArrayFile::open($filePath); + $arrayFile->write($tmpFile); + + $result = include $tmpFile; + $this->assertArrayHasKey('connections', $result); + $this->assertArrayHasKey('sqlite', $result['connections']); + $this->assertArrayHasKey('driver', $result['connections']['sqlite']); + $this->assertEquals('sqlite', $result['connections']['sqlite']['driver']); + + unlink($tmpFile); + } + + public function testWriteFileWithUpdates() + { + $filePath = __DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'; + $tmpFile = __DIR__ . '/../fixtures/parse/arrayfile/temp-array-file.php'; + + $arrayFile = ArrayFile::open($filePath); + $arrayFile->set('connections.sqlite.driver', 'winter'); + $arrayFile->write($tmpFile); + + $result = include $tmpFile; + $this->assertArrayHasKey('connections', $result); + $this->assertArrayHasKey('sqlite', $result['connections']); + $this->assertArrayHasKey('driver', $result['connections']['sqlite']); + $this->assertEquals('winter', $result['connections']['sqlite']['driver']); + + unlink($tmpFile); + } + + public function testWriteFileWithUpdatesArray() + { + $filePath = __DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'; + $tmpFile = __DIR__ . '/../fixtures/parse/arrayfile/temp-array-file.php'; + + $arrayFile = ArrayFile::open($filePath); + $arrayFile->set([ + 'connections.sqlite.driver' => 'winter', + 'connections.sqlite.prefix' => 'test', + ]); + $arrayFile->write($tmpFile); + + $result = include $tmpFile; + $this->assertArrayHasKey('connections', $result); + $this->assertArrayHasKey('sqlite', $result['connections']); + $this->assertArrayHasKey('driver', $result['connections']['sqlite']); + $this->assertEquals('winter', $result['connections']['sqlite']['driver']); + $this->assertEquals('test', $result['connections']['sqlite']['prefix']); + + unlink($tmpFile); + } + + public function testWriteEnvUpdates() + { + $filePath = __DIR__ . '/../fixtures/parse/arrayfile/env-config.php'; + $tmpFile = __DIR__ . '/../fixtures/parse/arrayfile/temp-array-file.php'; + + $arrayFile = ArrayFile::open($filePath); + $arrayFile->write($tmpFile); + + $result = include $tmpFile; + + $this->assertArrayHasKey('sample', $result); + $this->assertArrayHasKey('value', $result['sample']); + $this->assertArrayHasKey('no_default', $result['sample']); + $this->assertEquals('default', $result['sample']['value']); + $this->assertNull($result['sample']['no_default']); + + $arrayFile->set([ + 'sample.value' => 'winter', + 'sample.no_default' => 'test', + ]); + $arrayFile->write($tmpFile); + + $result = include $tmpFile; + + $this->assertArrayHasKey('sample', $result); + $this->assertArrayHasKey('value', $result['sample']); + $this->assertArrayHasKey('no_default', $result['sample']); + $this->assertEquals('winter', $result['sample']['value']); + $this->assertEquals('test', $result['sample']['no_default']); + + unlink($tmpFile); + } + + public function testCasting() + { + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $result = eval('?>' . $arrayFile->render()); + + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('url', $result); + $this->assertEquals('http://localhost', $result['url']); + + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('url', false); + $result = eval('?>' . $arrayFile->render()); + + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('url', $result); + $this->assertFalse($result['url']); + + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('url', 1234); + $result = eval('?>' . $arrayFile->render()); + + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('url', $result); + $this->assertIsInt($result['url']); + } + + public function testRender() + { + /* + * Rewrite a single level string + */ + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('url', 'https://wintercms.com'); + $result = eval('?>' . $arrayFile->render()); + + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('url', $result); + $this->assertEquals('https://wintercms.com', $result['url']); + + /* + * Rewrite a second level string + */ + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('memcached.host', '69.69.69.69'); + $result = eval('?>' . $arrayFile->render()); + + $this->assertArrayHasKey('memcached', $result); + $this->assertArrayHasKey('host', $result['memcached']); + $this->assertEquals('69.69.69.69', $result['memcached']['host']); + + /* + * Rewrite a third level string + */ + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('connections.mysql.host', '127.0.0.1'); + $result = eval('?>' . $arrayFile->render()); + + $this->assertArrayHasKey('connections', $result); + $this->assertArrayHasKey('mysql', $result['connections']); + $this->assertArrayHasKey('host', $result['connections']['mysql']); + $this->assertEquals('127.0.0.1', $result['connections']['mysql']['host']); + + /*un- + * Test alternative quoting + */ + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('timezone', 'The Fifth Dimension') + ->set('timezoneAgain', 'The "Sixth" Dimension'); + $result = eval('?>' . $arrayFile->render()); + + $this->assertArrayHasKey('timezone', $result); + $this->assertArrayHasKey('timezoneAgain', $result); + $this->assertEquals('The Fifth Dimension', $result['timezone']); + $this->assertEquals('The "Sixth" Dimension', $result['timezoneAgain']); + + /* + * Rewrite a boolean + */ + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('debug', false) + ->set('debugAgain', true) + ->set('bullyIan', true) + ->set('booLeeIan', false) + ->set('memcached.weight', false) + ->set('connections.pgsql.password', true); + + $result = eval('?>' . $arrayFile->render()); + + $this->assertArrayHasKey('debug', $result); + $this->assertArrayHasKey('debugAgain', $result); + $this->assertArrayHasKey('bullyIan', $result); + $this->assertArrayHasKey('booLeeIan', $result); + $this->assertFalse($result['debug']); + $this->assertTrue($result['debugAgain']); + $this->assertTrue($result['bullyIan']); + $this->assertFalse($result['booLeeIan']); + + $this->assertArrayHasKey('memcached', $result); + $this->assertArrayHasKey('weight', $result['memcached']); + $this->assertFalse($result['memcached']['weight']); + + $this->assertArrayHasKey('connections', $result); + $this->assertArrayHasKey('pgsql', $result['connections']); + $this->assertArrayHasKey('password', $result['connections']['pgsql']); + $this->assertTrue($result['connections']['pgsql']['password']); + $this->assertEquals('', $result['connections']['sqlsrv']['password']); + + /* + * Rewrite an integer + */ + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'); + $arrayFile->set('aNumber', 69); + $result = eval('?>' . $arrayFile->render()); + + $this->assertArrayHasKey('aNumber', $result); + $this->assertEquals(69, $result['aNumber']); + } + + public function testConfigInvalid() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('ArrayFiles must start with a return statement'); + + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/invalid.php'); + } + + public function testConfigImports() + { + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/import.php'); + + $expected = << Response::HTTP_OK, + 'bar' => Response::HTTP_I_AM_A_TEAPOT, +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testConfigImportsUpdating() + { + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/import.php'); + $arrayFile->set('foo', $arrayFile->constant('Response::HTTP_CONFLICT')); + + $expected = << Response::HTTP_CONFLICT, + 'bar' => Response::HTTP_I_AM_A_TEAPOT, +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testConfigExpression() + { + $arrayFile = ArrayFile::open(__DIR__ . '/../fixtures/parse/arrayfile/expression.php'); + + $expected = << \$bar, +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testReadCreateFile() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + + $this->assertFalse(file_exists($file)); + + $arrayFile = ArrayFile::open($file); + + $this->assertInstanceOf(ArrayFile::class, $arrayFile); + + $arrayFile->write(); + + $this->assertTrue(file_exists($file)); + $this->assertEquals(sprintf('set('w.i.n.t.e.r', 'cms'); + + $result = eval('?>' . $arrayFile->render()); + + $this->assertArrayHasKey('w', $result); + $this->assertArrayHasKey('i', $result['w']); + $this->assertArrayHasKey('n', $result['w']['i']); + $this->assertArrayHasKey('t', $result['w']['i']['n']); + $this->assertArrayHasKey('e', $result['w']['i']['n']['t']); + $this->assertArrayHasKey('r', $result['w']['i']['n']['t']['e']); + $this->assertEquals('cms', $result['w']['i']['n']['t']['e']['r']); + } + + public function testWriteDotNotationMixedCase() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + $arrayFile->set('w.0.n.1.e.2', 'cms'); + + $result = eval('?>' . $arrayFile->render()); + + $this->assertArrayHasKey('w', $result); + $this->assertArrayHasKey(0, $result['w']); + $this->assertArrayHasKey('n', $result['w'][0]); + $this->assertArrayHasKey(1, $result['w'][0]['n']); + $this->assertArrayHasKey('e', $result['w'][0]['n'][1]); + $this->assertArrayHasKey(2, $result['w'][0]['n'][1]['e']); + $this->assertEquals('cms', $result['w'][0]['n'][1]['e'][2]); + } + + public function testWriteDotNotationMultiple() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + $arrayFile->set('w.i.n.t.e.r', 'Winter CMS'); + $arrayFile->set('w.i.n.b', 'is'); + $arrayFile->set('w.i.n.t.a', 'very'); + $arrayFile->set('w.i.n.c.l', 'good'); + $arrayFile->set('w.i.n.c.e', 'and'); + $arrayFile->set('w.i.n.c.f', 'awesome'); + $arrayFile->set('w.i.n.g', 'for'); + $arrayFile->set('w.i.2.g', 'development'); + + $arrayFile->write(); + + $contents = file_get_contents($file); + + $expected = << [ + 'i' => [ + 'n' => [ + 't' => [ + 'e' => [ + 'r' => 'Winter CMS', + ], + 'a' => 'very', + ], + 'b' => 'is', + 'c' => [ + 'l' => 'good', + 'e' => 'and', + 'f' => 'awesome', + ], + 'g' => 'for', + ], + 2 => [ + 'g' => 'development', + ], + ], + ], +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $contents); + + unlink($file); + } + + public function testWriteDotDuplicateIntKeys() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + $arrayFile->set([ + 'w.i.n.t.e.r' => 'Winter CMS', + 'w.i.2.g' => 'development', + ]); + $arrayFile->set('w.i.2.g', 'development'); + + $arrayFile->write(); + + $contents = file_get_contents($file); + + $expected = << [ + 'i' => [ + 'n' => [ + 't' => [ + 'e' => [ + 'r' => 'Winter CMS', + ], + ], + ], + 2 => [ + 'g' => 'development', + ], + ], + ], +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $contents); + + unlink($file); + } + + public function testWriteIllegalOffset() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $this->expectException(\Winter\Storm\Exception\SystemException::class); + + $arrayFile->set([ + 'w.i.n.t.e.r' => 'Winter CMS', + 'w.i.n.t.e.r.2' => 'test', + ]); + } + + public function testThrowExceptionIfMissing() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/missing.php'; + + $this->expectException(\InvalidArgumentException::class); + + $arrayFile = ArrayFile::open($file, true); + } + + public function testSetArray() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'w' => [ + 'i' => 'n', + 't' => [ + 'e', + 'r' + ] + ] + ]); + + $expected = << [ + 'i' => 'n', + 't' => [ + 'e', + 'r', + ], + ], +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testSetNumericArray() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'winter' => [ + 1 => 'a', + 2 => 'b', + ], + 'cms' => [ + 0 => 'a', + 1 => 'b' + ] + ]); + + $expected = << [ + 1 => 'a', + 2 => 'b', + ], + 'cms' => [ + 'a', + 'b', + ], +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testWriteConstCall() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'curl_port' => $arrayFile->constant('CURLOPT_PORT') + ]); + + $arrayFile->set([ + 'curl_return' => new \Winter\Storm\Parse\PHP\PHPConstant('CURLOPT_RETURNTRANSFER') + ]); + + $expected = << CURLOPT_PORT, + 'curl_return' => CURLOPT_RETURNTRANSFER, +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testWriteArrayFunctionsAndConstCall() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'path.to.config' => [ + 'test' => $arrayFile->function('env', ['TEST_KEY', 'default']), + 'details' => [ + 'test1', + 'test2', + 'additional' => [ + $arrayFile->constant('\Winter\Storm\Parse\PHP\ArrayFile::SORT_ASC'), + $arrayFile->constant('\Winter\Storm\Parse\PHP\ArrayFile::SORT_DESC') + ] + ] + ] + ]); + + $expected = << [ + 'to' => [ + 'config' => [ + 'test' => env('TEST_KEY', 'default'), + 'details' => [ + 'test1', + 'test2', + 'additional' => [ + \Winter\Storm\Parse\PHP\ArrayFile::SORT_ASC, + \Winter\Storm\Parse\PHP\ArrayFile::SORT_DESC, + ], + ], + ], + ], + ], +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testWriteFunctionCall() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'key' => $arrayFile->function('env', ['KEY_A', true]) + ]); + + $arrayFile->set([ + 'key2' => new \Winter\Storm\Parse\PHP\PHPFunction('nl2br', ['KEY_B', false]) + ]); + + $expected = << env('KEY_A', true), + 'key2' => nl2br('KEY_B', false), +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testWriteFunctionCallOverwrite() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'key' => $arrayFile->function('env', ['KEY_A', true]) + ]); + + $arrayFile->set([ + 'key' => new \Winter\Storm\Parse\PHP\PHPFunction('nl2br', ['KEY_B', false]) + ]); + + $expected = << nl2br('KEY_B', false), +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testInsertNull() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'key' => $arrayFile->function('env', ['KEY_A', null]), + 'key2' => null + ]); + + $expected = << env('KEY_A', null), + 'key2' => null, +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testSortAsc() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'b.b' => 'b', + 'b.a' => 'a', + 'a.a.b' => 'b', + 'a.a.a' => 'a', + 'a.c' => 'c', + 'a.b' => 'b', + ]); + + $arrayFile->sort(); + + $expected = << [ + 'a' => [ + 'a' => 'a', + 'b' => 'b', + ], + 'b' => 'b', + 'c' => 'c', + ], + 'b' => [ + 'a' => 'a', + 'b' => 'b', + ], +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + + public function testSortDesc() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'b.a' => 'a', + 'a.a.a' => 'a', + 'a.a.b' => 'b', + 'a.b' => 'b', + 'a.c' => 'c', + 'b.b' => 'b', + ]); + + $arrayFile->sort(ArrayFile::SORT_DESC); + + $expected = << [ + 'b' => 'b', + 'a' => 'a', + ], + 'a' => [ + 'c' => 'c', + 'b' => 'b', + 'a' => [ + 'b' => 'b', + 'a' => 'a', + ], + ], +]; + +PHP; + + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testSortUsort() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; + $arrayFile = ArrayFile::open($file); + + $arrayFile->set([ + 'a' => 'a', + 'b' => 'b' + ]); + + $arrayFile->sort(function ($a, $b) { + static $i; + if (!isset($i)) { + $i = 1; + } + return $i--; + }); + + $expected = << 'b', + 'a' => 'a', +]; + +PHP; + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testIncludeFormatting() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/include.php'; + $arrayFile = ArrayFile::open($file); + + $expected = << array_merge(include(__DIR__ . '/sample-array-file.php'), [ + 'bar' => 'foo', + ]), + 'bar' => 'foo', +]; + +PHP; + $this->assertEquals(str_replace("\r", '', $expected), $arrayFile->render()); + } + + public function testEmptyNewLines() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/sample-array-file.php'; + $arrayFile = ArrayFile::open($file); + + preg_match('/^\s+$/m', $arrayFile->render(), $matches); + + $this->assertEmpty($matches); + } + + public function testNestedComments() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/nested-comments.php'; + $arrayFile = ArrayFile::open($file); + + $code = $arrayFile->render(); + + $this->assertStringContainsString(str_repeat(' ', 8) . '|', $code); + $this->assertStringNotContainsString(str_repeat(' ', 12) . '|', $code); + } + + public function testSingleLineComment() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/single-line-comments.php'; + $arrayFile = ArrayFile::open($file); + + $this->assertEquals( + str_replace("\r", '', file_get_contents($file)), + str_replace("\r", '', $arrayFile->render()) + ); + } + + public function testSingleLineCommentSubItem() + { + $file = __DIR__ . '/../fixtures/parse/arrayfile/single-line-comments-subitem.php'; + $arrayFile = ArrayFile::open($file); + + $this->assertEquals( + str_replace("\r", '', file_get_contents($file)), + str_replace("\r", '', $arrayFile->render()) + ); + } +} diff --git a/tests/Parse/EnvFileTest.php b/tests/Parse/EnvFileTest.php new file mode 100644 index 000000000..48412ce36 --- /dev/null +++ b/tests/Parse/EnvFileTest.php @@ -0,0 +1,189 @@ +assertInstanceOf(EnvFile::class, $env); + + $arr = $env->getVariables(); + + $this->assertArrayHasKey('APP_URL', $arr); + $this->assertArrayHasKey('APP_KEY', $arr); + $this->assertArrayHasKey('MAIL_HOST', $arr); + $this->assertArrayHasKey('MAIL_DRIVER', $arr); + $this->assertArrayHasKey('ROUTES_CACHE', $arr); + $this->assertArrayNotHasKey('KEY_WITH_NO_VALUE', $arr); + + $this->assertEquals('http://localhost', $arr['APP_URL']); + $this->assertEquals('changeme', $arr['APP_KEY']); + $this->assertEquals('smtp.mailgun.org', $arr['MAIL_HOST']); + $this->assertEquals('smtp', $arr['MAIL_DRIVER']); + $this->assertEquals('false', $arr['ROUTES_CACHE']); + } + + public function testWriteFile() + { + $filePath = __DIR__ . '/../fixtures/parse/test.env'; + $tmpFile = __DIR__ . '/../fixtures/parse/temp-test.env'; + + $env = EnvFile::open($filePath); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + + $this->assertStringContainsString('APP_DEBUG=true', $result); + $this->assertStringContainsString('DB_USE_CONFIG_FOR_TESTING=false', $result); + $this->assertStringContainsString('MAIL_HOST="smtp.mailgun.org"', $result); + $this->assertStringContainsString('ROUTES_CACHE=false', $result); + $this->assertStringContainsString('ENABLE_CSRF=true', $result); + $this->assertStringContainsString('KEY_WITH_NO_VALUE', $result); + + unlink($tmpFile); + } + + public function testWriteFileWithUpdates() + { + $filePath = __DIR__ . '/../fixtures/parse/test.env'; + $tmpFile = __DIR__ . '/../fixtures/parse/temp-test.env'; + + $env = EnvFile::open($filePath); + $env->set('APP_KEY', 'winter'); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + + $this->assertStringContainsString('APP_DEBUG=true', $result); + $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=false', $result); + $this->assertStringContainsString('ENABLE_CSRF=true', $result); + $this->assertStringContainsString('# HELLO WORLD', $result); + $this->assertStringContainsString('#ENV_TEST="wintercms"', $result); + $this->assertStringContainsString('KEY_WITH_NO_VALUE', $result); + + unlink($tmpFile); + } + + public function testWriteFileWithUpdatesArray() + { + $filePath = __DIR__ . '/../fixtures/parse/test.env'; + $tmpFile = __DIR__ . '/../fixtures/parse/temp-test.env'; + + $env = EnvFile::open($filePath); + $env->set([ + 'APP_KEY' => 'winter', + 'ROUTES_CACHE' => 'winter', + ]); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + + $this->assertStringContainsString('APP_DEBUG=true', $result); + $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('ENABLE_CSRF=true', $result); + $this->assertStringContainsString('# HELLO WORLD', $result); + $this->assertStringContainsString('#ENV_TEST="wintercms"', $result); + $this->assertStringContainsString('KEY_WITH_NO_VALUE', $result); + + unlink($tmpFile); + } + + public function testValueFormats() + { + $envFile = new EnvFile(''); + $cases = [ + 'APP_DEBUG=true' => [ + 'variable' => 'APP_DEBUG', + 'value' => true, + ], + 'APP_URL="https://localhost"' => [ + 'variable' => 'APP_URL', + 'value' => "https://localhost", + ], + 'DB_CONNECTION="mysql"' => [ + 'variable' => 'DB_CONNECTION', + 'value' => "mysql", + ], + 'DB_DATABASE="data#base"' => [ + 'variable' => 'DB_DATABASE', + 'value' => "data#base", + ], + 'DB_USERNAME="teal\\\'c"' => [ + 'variable' => 'DB_USERNAME', + 'value' => "teal\'c", + ], + 'DB_PASSWORD="test\\"quotes\\\'test"' => [ + 'variable' => 'DB_PASSWORD', + 'value' => "test\"quotes\'test", + ], + 'DB_PORT=3306' => [ + 'variable' => 'DB_PORT', + 'value' => 3306, + ], + ]; + + foreach ($cases as $output => $config) { + $envFile->set($config['variable'], $config['value']); + $this->assertStringContainsString($output, $envFile->render()); + } + } + + public function testCasting() + { + $filePath = __DIR__ . '/../fixtures/parse/test.env'; + $tmpFile = __DIR__ . '/../fixtures/parse/temp-test.env'; + + $env = EnvFile::open($filePath); + $env->set(['APP_KEY' => 'winter']); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + $this->assertStringContainsString('APP_KEY="winter"', $result); + + $env->set(['APP_KEY' => '123']); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + $this->assertStringContainsString('APP_KEY=123', $result); + + $env->set(['APP_KEY' => true]); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + $this->assertStringContainsString('APP_KEY=true', $result); + + $env->set(['APP_KEY' => false]); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + $this->assertStringContainsString('APP_KEY=false', $result); + + $env->set(['APP_KEY' => null]); + $env->write($tmpFile); + + $result = file_get_contents($tmpFile); + $this->assertStringContainsString('APP_KEY=null', $result); + + unlink($tmpFile); + } + + public function testRender() + { + $filePath = __DIR__ . '/../fixtures/parse/test.env'; + + $env = EnvFile::open($filePath); + + $this->assertEquals(file_get_contents($filePath), $env->render()); + } +} diff --git a/tests/fixtures/parse/arrayfile/env-config.php b/tests/fixtures/parse/arrayfile/env-config.php new file mode 100644 index 000000000..952e415aa --- /dev/null +++ b/tests/fixtures/parse/arrayfile/env-config.php @@ -0,0 +1,8 @@ + [ + 'value' => env('TEST_ENV', 'default'), + 'no_default' => env('TEST_NO_DEFAULT') + ] +]; diff --git a/tests/fixtures/parse/arrayfile/expression.php b/tests/fixtures/parse/arrayfile/expression.php new file mode 100644 index 000000000..4a9b08d6a --- /dev/null +++ b/tests/fixtures/parse/arrayfile/expression.php @@ -0,0 +1,7 @@ + $bar +]; diff --git a/tests/fixtures/parse/arrayfile/import.php b/tests/fixtures/parse/arrayfile/import.php new file mode 100644 index 000000000..9cc2108f2 --- /dev/null +++ b/tests/fixtures/parse/arrayfile/import.php @@ -0,0 +1,8 @@ + Response::HTTP_OK, + 'bar' => Response::HTTP_I_AM_A_TEAPOT +]; diff --git a/tests/fixtures/parse/arrayfile/include.php b/tests/fixtures/parse/arrayfile/include.php new file mode 100644 index 000000000..267bf9bb2 --- /dev/null +++ b/tests/fixtures/parse/arrayfile/include.php @@ -0,0 +1,13 @@ + array_merge(include(__DIR__ . '/sample-array-file.php'), [ + 'bar' => 'foo' + ]), + 'bar' => 'foo' +]; diff --git a/tests/fixtures/parse/arrayfile/invalid.php b/tests/fixtures/parse/arrayfile/invalid.php new file mode 100644 index 000000000..f734f1b9f --- /dev/null +++ b/tests/fixtures/parse/arrayfile/invalid.php @@ -0,0 +1,10 @@ + winterTest('foo') +]; diff --git a/tests/fixtures/parse/arrayfile/nested-comments.php b/tests/fixtures/parse/arrayfile/nested-comments.php new file mode 100644 index 000000000..846e31a59 --- /dev/null +++ b/tests/fixtures/parse/arrayfile/nested-comments.php @@ -0,0 +1,41 @@ + [ + + /* + |-------------------------------------------------------------------------- + | Enable throttling of Backend authentication attempts + |-------------------------------------------------------------------------- + | + | If set to true, users will be given a limited number of attempts to sign + | in to the Backend before being blocked for a specified number of minutes. + | + */ + + 'enabled' => true, + + /* + |-------------------------------------------------------------------------- + | Failed Authentication Attempt Limit + |-------------------------------------------------------------------------- + | + | Number of failed attempts allowed while trying to authenticate a user. + | + */ + + 'attemptLimit' => 5, + + /* + |-------------------------------------------------------------------------- + | Suspension Time + |-------------------------------------------------------------------------- + | + | The number of minutes to suspend further attempts on authentication once + | the attempt limit is reached. + | + */ + + 'suspensionTime' => 15, + ], +]; diff --git a/tests/fixtures/parse/arrayfile/sample-array-file.php b/tests/fixtures/parse/arrayfile/sample-array-file.php new file mode 100644 index 000000000..9cdb0c712 --- /dev/null +++ b/tests/fixtures/parse/arrayfile/sample-array-file.php @@ -0,0 +1,149 @@ + true, + + // phpcs:ignore + "debugAgain" => FALSE , + + "bullyIan" => 0, + + 'booLeeIan' => 1, + + 'aNumber' => 55, + + 'default' => 'mysql', + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | your application so that it is used when running Artisan tasks. + | + */ + + 'url' => 'http://localhost', + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. We have gone + | ahead and set this to a sensible default for you out of the box. + | + */ + + 'timezone' => "Winter's time", + + "timezoneAgain" => 'Something "else"' , + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Here are each of the database connections setup for your application. + | Of course, examples of configuring each database platform that is + | supported by Laravel is shown below to make development simple. + | + | + | All database work in Laravel is done through the PHP PDO facilities + | so make sure you have the driver for your particular database of + | choice installed on your machine before you begin development. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => __DIR__.'/../database/production.sqlite', + 'prefix' => '', + ], + + 'mysql' => [ + 'driver' => ['rabble' => 'mysql'], + 'host' => 'localhost', + 'database' => 'database', + 'username' => 'root', + 'password' => '', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'host' => 'localhost', + 'database' => 'database', + 'username' => 'root', + 'password' => '', + 'prefix' => '', + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'database' => 'database', + 'username' => 'root', + 'password' => false, + 'charset' => 'utf8', + 'prefix' => '', + 'schema' => 'public', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Memcached Servers + |-------------------------------------------------------------------------- + | + | Now you may specify an array of your Memcached servers that should be + | used when utilizing the Memcached cache driver. All of the servers + | should contain a value for "host", "port", and "weight" options. + | + */ + + 'memcached' => ['host' => '127.0.0.1', 'port' => 11211, 'weight' => true], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer set of commands than a typical key-value systems + | such as APC or Memcached. Laravel makes it easy to dig right in. + | + */ + + 'redis' => [ + + 'cluster' => false, + + 'default' => [ + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'database' => 0, + ], + + ], +]; diff --git a/tests/fixtures/parse/arrayfile/single-line-comments-subitem.php b/tests/fixtures/parse/arrayfile/single-line-comments-subitem.php new file mode 100644 index 000000000..948f010a1 --- /dev/null +++ b/tests/fixtures/parse/arrayfile/single-line-comments-subitem.php @@ -0,0 +1,60 @@ + env('BROADCAST_DRIVER', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over websockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + 'pusher' => [ + 'app_id' => env('PUSHER_APP_ID'), + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'useTLS' => true, + ], + 'secret' => env('PUSHER_APP_SECRET'), + ], + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + 'redis' => [ + 'connection' => 'default', + 'driver' => 'redis', + ], + 'log' => [ + 'driver' => 'log', + ], + 'null' => [ + 'driver' => 'null', + ], + ], +]; diff --git a/tests/fixtures/parse/arrayfile/single-line-comments.php b/tests/fixtures/parse/arrayfile/single-line-comments.php new file mode 100644 index 000000000..1076982d4 --- /dev/null +++ b/tests/fixtures/parse/arrayfile/single-line-comments.php @@ -0,0 +1,35 @@ + [ + + // above property + + 'bool' => true, + 'array' => [ + // empty array comment + ], + 'multi_line' => [ + // empty array comment + // with extra + ], + 'cms' => [ + 'value', + // end of array comment + ], + 'multi_endings' => [ + 'value', + // first line + // last line + ], + 'multi_comment' => [ + 'value', + /* + * Something long + */ + ], + 'callable' => array_merge(config('something'), [ + // configs + ]), + ], +]; diff --git a/tests/fixtures/parse/test.env b/tests/fixtures/parse/test.env new file mode 100644 index 000000000..2af86fdd7 --- /dev/null +++ b/tests/fixtures/parse/test.env @@ -0,0 +1,26 @@ +# WINTERCMS + +APP_DEBUG=true +APP_URL="http://localhost" +APP_KEY="changeme" +# HELLO WORLD + +DB_USE_CONFIG_FOR_TESTING=false +CACHE_DRIVER="file" +SESSION_DRIVER="file" +QUEUE_CONNECTION="sync" + +MAIL_DRIVER="smtp" +MAIL_HOST="smtp.mailgun.org" +MAIL_PORT=587 +MAIL_ENCRYPTION="tls" +MAIL_USERNAME=null +MAIL_PASSWORD=null + +ROUTES_CACHE=false +ASSET_CACHE=false +DATABASE_TEMPLATES=false +LINK_POLICY="detect" +ENABLE_CSRF=true +#ENV_TEST="wintercms" +KEY_WITH_NO_VALUE