diff --git a/composer.json b/composer.json index 163a5a278..5eb7d5ab6 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,8 @@ "symfony/yaml": "^6.0", "twig/twig": "~3.0", "wikimedia/less.php": "~3.0", - "wikimedia/minify": "~2.2" + "wikimedia/minify": "~2.2", + "winter/laravel-config-writer": "^1.0.0" }, "require-dev": { "phpunit/phpunit": "^9.5.8", diff --git a/src/Config/ConfigWriter.php b/src/Config/ConfigWriter.php index 75f40831a..a609a498a 100644 --- a/src/Config/ConfigWriter.php +++ b/src/Config/ConfigWriter.php @@ -1,7 +1,5 @@ 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 (!is_file($filePath)) { - return [[], []]; - } - - $contents = file($filePath); - if (empty($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 index c0b2e7a18..5a9deb43b 100644 --- a/src/Parse/PHP/ArrayFile.php +++ b/src/Parse/PHP/ArrayFile.php @@ -1,435 +1,9 @@ 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 mixed $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 deleted file mode 100644 index 81f967daf..000000000 --- a/src/Parse/PHP/ArrayPrinter.php +++ /dev/null @@ -1,316 +0,0 @@ -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 array $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 index 887a9eac5..5304f7dfe 100644 --- a/src/Parse/PHP/PHPConstant.php +++ b/src/Parse/PHP/PHPConstant.php @@ -1,25 +1,7 @@ name = $name; - } +use Winter\LaravelConfig\Parser\PHPConstant as BasePHPConstant; - /** - * Get the const name - */ - public function getName(): string - { - return $this->name; - } +class PHPConstant extends BasePHPConstant +{ } diff --git a/src/Parse/PHP/PHPFunction.php b/src/Parse/PHP/PHPFunction.php index 1874b1e26..cd606a92d 100644 --- a/src/Parse/PHP/PHPFunction.php +++ b/src/Parse/PHP/PHPFunction.php @@ -1,47 +1,7 @@ name = $name; - $this->args = $args; - } +use Winter\LaravelConfig\Parser\PHPFunction as BasePHPFunction; - /** - * 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; - } +class PHPFunction extends BasePHPFunction +{ } diff --git a/tests/Parse/ArrayFileTest.php b/tests/Parse/ArrayFileTest.php index bb7a85090..12411badb 100644 --- a/tests/Parse/ArrayFileTest.php +++ b/tests/Parse/ArrayFileTest.php @@ -438,7 +438,7 @@ public function testWriteIllegalOffset() $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; $arrayFile = ArrayFile::open($file); - $this->expectException(\Winter\Storm\Exception\SystemException::class); + $this->expectException(\Winter\LaravelConfig\Exceptions\ConfigWriterException::class); $arrayFile->set([ 'w.i.n.t.e.r' => 'Winter CMS',