├── src ├── Exceptions │ ├── EnvParserException.php │ └── ConfigWriterException.php ├── Contracts │ ├── DataFilePrinterInterface.php │ ├── DataFileLexerInterface.php │ └── DataFileInterface.php ├── DataFile.php ├── Parser │ ├── PHPConstant.php │ ├── PHPFunction.php │ └── EnvLexer.php ├── Printer │ ├── EnvPrinter.php │ └── ArrayPrinter.php ├── EnvFile.php └── ArrayFile.php ├── LICENSE ├── composer.json ├── phpcs.xml └── README.md /src/Exceptions/EnvParserException.php: -------------------------------------------------------------------------------- 1 | $ast 10 | * @return string 11 | */ 12 | public function render(array $ast): string; 13 | } 14 | -------------------------------------------------------------------------------- /src/DataFile.php: -------------------------------------------------------------------------------- 1 | ast; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Parser/PHPConstant.php: -------------------------------------------------------------------------------- 1 | name = $name; 18 | } 19 | 20 | /** 21 | * Get the const name 22 | */ 23 | public function getName(): string 24 | { 25 | return $this->name; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Contracts/DataFileLexerInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function parse(string $string): array; 20 | } 21 | -------------------------------------------------------------------------------- /src/Contracts/DataFileInterface.php: -------------------------------------------------------------------------------- 1 | $key 18 | * @param mixed $value 19 | * @return static 20 | */ 21 | public function set($key, $value = null); 22 | 23 | /** 24 | * Write the current data to a file 25 | */ 26 | public function write(?string $filePath = null): void; 27 | 28 | /** 29 | * Get the printed data 30 | */ 31 | public function render(): string; 32 | } 33 | -------------------------------------------------------------------------------- /src/Parser/PHPFunction.php: -------------------------------------------------------------------------------- 1 | Function arguments 17 | */ 18 | protected $args; 19 | 20 | /** 21 | * @param string $name 22 | * @param array $args 23 | */ 24 | public function __construct(string $name, array $args = []) 25 | { 26 | $this->name = $name; 27 | $this->args = $args; 28 | } 29 | 30 | /** 31 | * Get the function name 32 | * 33 | * @return string 34 | */ 35 | public function getName(): string 36 | { 37 | return $this->name; 38 | } 39 | 40 | /** 41 | * Get the function arguments 42 | * 43 | * @return array 44 | */ 45 | public function getArgs(): array 46 | { 47 | return $this->args; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Winter CMS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Printer/EnvPrinter.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | The coding standard for Winter CMS. 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | */tests/* 27 | 28 | 29 | 30 | 34 | */tests/* 35 | 36 | 37 | 38 | 39 | 40 | src/ 41 | tests/ 42 | 43 | 44 | */vendor/* 45 | 46 | tests/fixtures/* 47 | 48 | -------------------------------------------------------------------------------- /src/Parser/EnvLexer.php: -------------------------------------------------------------------------------- 1 | self::T_WHITESPACE, 12 | '/^(#.*)/' => self::T_COMMENT, 13 | '/^(\w+)/s' => self::T_ENV, 14 | '/^="([^"\\\]*(?:\\\.[^"\\\]*)*)"/s' => self::T_QUOTED_VALUE, 15 | '/^\=(.*)/' => self::T_VALUE, 16 | ]; 17 | 18 | /** 19 | * Parses an array of lines into an AST 20 | * 21 | * @param string $string 22 | * @return array|array[] 23 | * @throws EnvParserException 24 | */ 25 | public function parse(string $string): array 26 | { 27 | $tokens = []; 28 | $offset = 0; 29 | do { 30 | $result = $this->match($string, $offset); 31 | 32 | if (is_null($result)) { 33 | throw new EnvParserException("Unable to parse file, failed at: " . $offset . "."); 34 | } 35 | 36 | $tokens[] = $result; 37 | 38 | $offset += strlen($result['match']); 39 | } while ($offset < strlen($string)); 40 | 41 | return $tokens; 42 | } 43 | 44 | /** 45 | * Parse a string against our token map and return a node 46 | * 47 | * @param string $str 48 | * @param int $offset 49 | * @return array|null 50 | */ 51 | public function match(string $str, int $offset): ?array 52 | { 53 | $source = $str; 54 | $str = substr($str, $offset); 55 | 56 | foreach ($this->tokenMap as $pattern => $name) { 57 | if (!preg_match($pattern, $str, $matches)) { 58 | continue; 59 | } 60 | 61 | switch ($name) { 62 | case static::T_ENV: 63 | case static::T_VALUE: 64 | case static::T_QUOTED_VALUE: 65 | return [ 66 | 'match' => $matches[0], 67 | 'value' => $matches[1] ?? '', 68 | 'token' => $name, 69 | ]; 70 | case static::T_COMMENT: 71 | return [ 72 | 'match' => $matches[0], 73 | 'token' => $name, 74 | ]; 75 | case static::T_WHITESPACE: 76 | return [ 77 | 'match' => $matches[1], 78 | 'token' => $name, 79 | ]; 80 | } 81 | } 82 | 83 | return null; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Config Writer 2 | 3 | [![Version](https://img.shields.io/github/v/release/wintercms/laravel-config-writer?sort=semver&style=flat-square)](https://github.com/wintercms/laravel-config-writer/releases) 4 | [![Tests](https://img.shields.io/github/actions/workflow/status/wintercms/laravel-config-writer/tests.yaml?&label=tests&style=flat-square)](https://github.com/wintercms/laravel-config-writer/actions) 5 | [![License](https://img.shields.io/github/license/wintercms/laravel-config-writer?label=open%20source&style=flat-square)](https://packagist.org/packages/winter/laravel-config-writer) 6 | [![Discord](https://img.shields.io/discord/816852513684193281?label=discord&style=flat-square)](https://discord.gg/D5MFSPH6Ux) 7 | 8 | A utility to easily create and modify Laravel-style PHP configuration files and environment files whilst maintaining the formatting and comments contained within. This utility works by parsing the configuration files using the [PHP Parser library](https://github.com/nikic/php-parser) to convert the configuration into an abstract syntax tree, then carefully modifying the configuration values as required. 9 | 10 | This library was originally written as part of the [Storm library](https://github.com/wintercms/storm) in [Winter CMS](https://wintercms.com), but has since been extracted and repurposed as a standalone library. 11 | 12 | ## Installation 13 | 14 | ``` 15 | composer require winter/laravel-config-writer 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### PHP array files 21 | 22 | You can modify Laravel-style PHP configuration files - PHP files that return a single array - by using the `Winter\LaravelConfigWriter\ArrayFile` class. Use the `open` method to open an existing file for modification, or to create a new config file. 23 | 24 | ```php 25 | use Winter\LaravelConfigWriter\ArrayFile; 26 | 27 | $config = ArrayFile::open(base_path('config/app.php')); 28 | ``` 29 | 30 | You can set values using the `set` method. This method can be used fluently, or can be called with a single key and value or an array of keys and values. 31 | 32 | ```php 33 | $config->set('name', 'Winter CMS'); 34 | 35 | $config 36 | ->set('locale', 'en_US') 37 | ->set('fallbackLocale', 'en'); 38 | 39 | $config->set([ 40 | 'trustedHosts' => true, 41 | 'trustedProxies' => '*', 42 | ]); 43 | ``` 44 | 45 | You can also set deep values in an array value by specifying the key in dot notation, or as a nested array. 46 | 47 | ```php 48 | $config->set('connections.mysql.host', 'localhost'); 49 | 50 | $config->set([ 51 | 'connections' => [ 52 | 'sqlite' => [ 53 | 'database' => 'database.sqlite', 54 | 'driver' => 'sqlite', 55 | 'foreign_key_constraints' => true, 56 | 'prefix' => '', 57 | 'url' => null, 58 | ], 59 | ], 60 | ]); 61 | ``` 62 | 63 | To finalise all your changes, use the `write` method to write the changes to the open file. 64 | 65 | ```php 66 | $config->write(); 67 | ``` 68 | 69 | If desired, you may also write the changes to another file altogether. 70 | 71 | ```php 72 | $config->write('path/to/newfile.php'); 73 | ``` 74 | 75 | Or you can simply render the changes as a string. 76 | 77 | ```php 78 | $config->render(); 79 | ``` 80 | 81 | #### Function calls as values 82 | 83 | Function calls can be added to your configuration file by using the `function` method. The first parameter of the `function` method defines the function to call, and the second parameter accepts an array of parameters to provide to the function. 84 | 85 | ```php 86 | $config->set('name', $config->function('env', ['APP_NAME', 'Winter CMS'])); 87 | ``` 88 | 89 | #### Constants as values 90 | 91 | Constants can be added to your configuration file by using the `constant` method. The only parameter required is the name of the constant. 92 | 93 | ```php 94 | $config->set('foo.bar', $config->constant('My\Class::CONSTANT')); 95 | ``` 96 | 97 | #### Sorting the configuration file 98 | 99 | You can sort the configuration keys alphabetically by using the `sort` method. This will sort all current configuration values. 100 | 101 | ```php 102 | $config->sort(); 103 | ``` 104 | 105 | By default, this will sort the keys alphabetically in ascending order. To sort in the opposite direction, include the `ArrayFile::SORT_DESC` parameter. 106 | 107 | ```php 108 | $config->sort(ArrayFile::SORT_DESC); 109 | ``` 110 | 111 | ### Environment files 112 | 113 | This utility library also allows manipulation of environment files, typically found as `.env` files in a project. The `Winter\LaravelConfigWriter\EnvFile::open()` method allows you to open or create an environment file for modification. 114 | 115 | ```php 116 | use Winter\LaravelConfigWriter\EnvFile; 117 | 118 | $env = EnvFile::open(base_path('.env')); 119 | ``` 120 | 121 | You can set values using the `set` method. This method can be used fluently, or can be called with a single key and value or an array of keys and values. 122 | 123 | ```php 124 | $env->set('APP_NAME', 'Winter CMS'); 125 | 126 | $env 127 | ->set('APP_URL', 'https://wintercms.com') 128 | ->set('APP_ENV', 'production'); 129 | 130 | $env->set([ 131 | 'DB_CONNECTION' => 'sqlite', 132 | 'DB_DATABASE' => 'database.sqlite', 133 | ]); 134 | ``` 135 | 136 | > **Note:** Arrays are not supported in environment files. 137 | 138 | You can add an empty line into the environment file by using the `addEmptyLine` method. This allows you to separate groups of environment variables. 139 | 140 | ```php 141 | $env->set('FOO', 'bar'); 142 | $env->addEmptyLine(); 143 | $env->set('BAR', 'foo'); 144 | ``` 145 | 146 | To finalise all your changes, use the `write` method to write the changes to the open file. 147 | 148 | ```php 149 | $env->write(); 150 | ``` 151 | 152 | If desired, you may also write the changes to another file altogether. 153 | 154 | ```php 155 | $env->write(base_path('.env.local')); 156 | ``` 157 | 158 | Or you can simply render the changes as a string. 159 | 160 | ```php 161 | $env->render(); 162 | ``` 163 | 164 | ## License 165 | 166 | This utility library is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). 167 | 168 | ## Security vulnerabilities 169 | 170 | Please review our [security policy](https://github.com/wintercms/winter/security/policy) on how to report security vulnerabilities. 171 | -------------------------------------------------------------------------------- /src/EnvFile.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 44 | $this->lexer = $lexer ?? new EnvLexer(); 45 | $this->printer = $printer ?? new EnvPrinter(); 46 | $this->ast = $this->parse($this->filePath); 47 | } 48 | 49 | /** 50 | * Return a new instance of `EnvFile` ready for modification of the file. 51 | * 52 | * @return static 53 | */ 54 | public static function open(string $filePath) 55 | { 56 | return new static($filePath); 57 | } 58 | 59 | /** 60 | * Set a property within the env. Passing an array as param 1 is also supported. 61 | * 62 | * ```php 63 | * $env->set('APP_PROPERTY', 'example'); 64 | * // or 65 | * $env->set([ 66 | * 'APP_PROPERTY' => 'example', 67 | * 'DIF_PROPERTY' => 'example' 68 | * ]); 69 | * ``` 70 | * @param string|array $key 71 | * @param mixed $value 72 | * @return static 73 | */ 74 | public function set($key, $value = null) 75 | { 76 | if (is_array($key)) { 77 | foreach ($key as $item => $value) { 78 | $this->set($item, $value); 79 | } 80 | return $this; 81 | } 82 | 83 | foreach ($this->ast as $index => $item) { 84 | // Skip all but keys 85 | if ($item['token'] !== $this->lexer::T_ENV) { 86 | continue; 87 | } 88 | 89 | if ($item['value'] === $key) { 90 | if ( 91 | !isset($this->ast[$index + 1]) 92 | || !in_array($this->ast[$index + 1]['token'], [$this->lexer::T_VALUE, $this->lexer::T_QUOTED_VALUE]) 93 | ) { 94 | // The next token was not a value, we need to create an empty value node to allow for value setting 95 | array_splice($this->ast, $index + 1, 0, [[ 96 | 'token' => $this->lexer::T_VALUE, 97 | 'value' => '' 98 | ]]); 99 | } 100 | 101 | $this->ast[$index + 1]['value'] = $this->castValue($value); 102 | 103 | // Reprocess the token type to ensure old casting rules are still applied 104 | switch ($this->ast[$index + 1]['token']) { 105 | case $this->lexer::T_VALUE: 106 | if ( 107 | strpos($this->ast[$index + 1]['value'], '"') !== false 108 | || strpos($this->ast[$index + 1]['value'], '\'') !== false 109 | ) { 110 | $this->ast[$index + 1]['token'] = $this->lexer::T_QUOTED_VALUE; 111 | } 112 | break; 113 | case $this->lexer::T_QUOTED_VALUE: 114 | if (is_null($value) || $value === true || $value === false) { 115 | $this->ast[$index + 1]['token'] = $this->lexer::T_VALUE; 116 | } 117 | break; 118 | } 119 | 120 | return $this; 121 | } 122 | } 123 | 124 | // We did not find the key in the AST, therefore we must create it 125 | $this->ast[] = [ 126 | 'token' => $this->lexer::T_ENV, 127 | 'value' => $key 128 | ]; 129 | $this->ast[] = [ 130 | 'token' => (is_numeric($value) || is_bool($value)) ? $this->lexer::T_VALUE : $this->lexer::T_QUOTED_VALUE, 131 | 'value' => $this->castValue($value) 132 | ]; 133 | 134 | // Add a new line 135 | $this->addEmptyLine(); 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * Push a newline onto the end of the env file 142 | */ 143 | public function addEmptyLine(): EnvFile 144 | { 145 | $this->ast[] = [ 146 | 'match' => PHP_EOL, 147 | 'token' => $this->lexer::T_WHITESPACE, 148 | ]; 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * Write the current env lines to a file 155 | */ 156 | public function write(string $filePath = null): void 157 | { 158 | if (!$filePath) { 159 | $filePath = $this->filePath; 160 | } 161 | 162 | file_put_contents($filePath, $this->render()); 163 | } 164 | 165 | /** 166 | * Get the env lines data as a string 167 | */ 168 | public function render(): string 169 | { 170 | return $this->printer->render($this->ast); 171 | } 172 | 173 | /** 174 | * Parse a .env file, returns an array of the env file data and a key => position map 175 | * 176 | * @return array> 177 | */ 178 | protected function parse(string $filePath): array 179 | { 180 | if (!is_file($filePath)) { 181 | return []; 182 | } 183 | 184 | $contents = file_get_contents($filePath); 185 | 186 | return $this->lexer->parse($contents); 187 | } 188 | 189 | /** 190 | * Get the variables from the current env lines data as an associative array 191 | * 192 | * @return array 193 | */ 194 | public function getVariables(): array 195 | { 196 | $env = []; 197 | 198 | foreach ($this->ast as $index => $item) { 199 | if ($item['token'] !== $this->lexer::T_ENV) { 200 | continue; 201 | } 202 | 203 | if (!( 204 | isset($this->ast[$index + 1]) 205 | && in_array($this->ast[$index + 1]['token'], [$this->lexer::T_VALUE, $this->lexer::T_QUOTED_VALUE]) 206 | )) { 207 | continue; 208 | } 209 | 210 | $env[$item['value']] = trim($this->ast[$index + 1]['value']); 211 | } 212 | 213 | return $env; 214 | } 215 | 216 | /** 217 | * Cast values to strings 218 | * 219 | * @param mixed $value 220 | */ 221 | protected function castValue($value): string 222 | { 223 | if (is_null($value)) { 224 | return 'null'; 225 | } 226 | 227 | if ($value === true) { 228 | return 'true'; 229 | } 230 | 231 | if ($value === false) { 232 | return 'false'; 233 | } 234 | 235 | return str_replace('"', '\"', $value); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/Printer/ArrayPrinter.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 71 | 72 | $p = "prettyPrint($stmts); 73 | 74 | if ($stmts[0] instanceof Stmt\InlineHTML) { 75 | $p = preg_replace('/^<\?php\s+\?>\n?/', '', $p); 76 | } 77 | if ($stmts[count($stmts) - 1] instanceof Stmt\InlineHTML) { 78 | $p = preg_replace('/<\?php$/', '', rtrim($p)); 79 | } 80 | 81 | $this->parser = null; 82 | 83 | return $p; 84 | } 85 | 86 | /** 87 | * @param array $nodes 88 | * @param bool $trailingComma 89 | * @return string 90 | */ 91 | protected function pMaybeMultiline(array $nodes, bool $trailingComma = false): string 92 | { 93 | if ($this->hasNodeWithComments($nodes) || (isset($nodes[0]) && $nodes[0] instanceof Expr\ArrayItem)) { 94 | return $this->pCommaSeparatedMultiline($nodes, $trailingComma) . $this->nl; 95 | } else { 96 | return $this->pCommaSeparated($nodes); 97 | } 98 | } 99 | 100 | /** 101 | * Pretty prints a comma-separated list of nodes in multiline style, including comments. 102 | * 103 | * The result includes a leading newline and one level of indentation (same as pStmts). 104 | * 105 | * @param array $nodes Array of Nodes to be printed 106 | * @param bool $trailingComma Whether to use a trailing comma 107 | * 108 | * @return string Comma separated pretty printed nodes in multiline style 109 | */ 110 | protected function pCommaSeparatedMultiline(array $nodes, bool $trailingComma): string 111 | { 112 | $this->indent(); 113 | 114 | $result = ''; 115 | $lastIdx = count($nodes) - 1; 116 | foreach ($nodes as $idx => $node) { 117 | if ($node !== null) { 118 | $comments = $node->getComments(); 119 | 120 | if ($comments) { 121 | $result .= $this->pComments($comments); 122 | } 123 | 124 | $result .= $this->nl . $this->p($node); 125 | } else { 126 | $result = trim($result) . "\n"; 127 | } 128 | if ($trailingComma || $idx !== $lastIdx) { 129 | $result .= ','; 130 | } 131 | } 132 | 133 | $this->outdent(); 134 | return $result; 135 | } 136 | 137 | /** 138 | * Render a return statement 139 | * 140 | * @param Stmt\Return_ $node Return statement node 141 | * 142 | * @return string Return followed by the return value 143 | */ 144 | protected function pStmt_Return(Stmt\Return_ $node): string 145 | { 146 | // Get tokens from parser 147 | $tokens = $this->parser->getTokens(); 148 | 149 | // Get the previous 2 tokens before the current node 150 | $previousTokens = array_splice($tokens, $node->getAttribute('startTokenPos') - 2, 2); 151 | 152 | // If the last token was whitespace and the token before that was not whitespace and the 153 | // whitespace token was a double return, then prefix a \n 154 | $prefix = ( 155 | count($previousTokens) > 1 156 | && $previousTokens[1]->id === T_WHITESPACE 157 | && $previousTokens[0]->id !== T_WHITESPACE 158 | && $previousTokens[1]->text === PHP_EOL . PHP_EOL 159 | ) ? "\n" : ''; 160 | 161 | return $prefix . 'return' . (null !== $node->expr ? ' ' . $this->p($node->expr) : '') . ';'; 162 | } 163 | 164 | /** 165 | * Render an array expression 166 | * 167 | * @param Expr\Array_ $node Array expression node 168 | * 169 | * @return string Comma separated pretty printed nodes in multiline style 170 | */ 171 | protected function pExpr_Array(Expr\Array_ $node): string 172 | { 173 | $ops = $node->getAttribute('kind', Expr\Array_::KIND_SHORT) === Expr\Array_::KIND_SHORT 174 | ? ['[', ']'] 175 | : ['array(', ')']; 176 | 177 | if (!count($node->items) && $comments = $this->getNodeComments($node)) { 178 | // We could previously return the indent string while modifying the indent level, however 179 | // Now the method is typehinted we cannot, so a little bodge... 180 | $this->indent(); 181 | $nl = $this->nl; 182 | $this->outdent(); 183 | // the array has no items, we can inject whatever we want 184 | return sprintf( 185 | '%s%s%s%s%s', 186 | // opening control char 187 | $ops[0], 188 | // indent and add nl string 189 | $nl, 190 | // join all comments with nl string 191 | implode($nl, $comments), 192 | // outdent and add nl string 193 | $this->nl, 194 | // closing control char 195 | $ops[1] 196 | ); 197 | } 198 | 199 | if ($comments = $this->getCommentsNotInArray($node)) { 200 | // array has items, we have detected comments not included within the array, therefore, we have found 201 | // trailing comments and must append them to the end of the array 202 | return sprintf( 203 | '%s%s%s%s%s%s', 204 | // opening control char 205 | $ops[0], 206 | // render the children 207 | $this->pMaybeMultiline($node->items, true), 208 | // add 1 level of indentation 209 | str_repeat(' ', 4), 210 | // join all comments with the current indentation 211 | implode($this->nl . str_repeat(' ', 4), $comments), 212 | // add a trailing nl 213 | $this->nl, 214 | // closing control char 215 | $ops[1] 216 | ); 217 | } 218 | 219 | // default return 220 | return $ops[0] . $this->pMaybeMultiline($node->items, true) . $ops[1]; 221 | } 222 | 223 | /** 224 | * Get all comments that have not been attributed to a node within a node array 225 | * 226 | * @param Expr\Array_ $nodes Array of nodes 227 | * 228 | * @return array Comments found 229 | */ 230 | protected function getCommentsNotInArray(Expr\Array_ $nodes): array 231 | { 232 | if (!$comments = $this->getNodeComments($nodes)) { 233 | return []; 234 | } 235 | 236 | return array_filter($comments, function ($comment) use ($nodes) { 237 | return !$this->commentInNodeList($nodes->items, $comment); 238 | }); 239 | } 240 | 241 | /** 242 | * Recursively check if a comment exists in an array of nodes 243 | * 244 | * @param Node[] $nodes Array of nodes 245 | * @param string $comment The comment to search for 246 | * 247 | * @return bool 248 | */ 249 | protected function commentInNodeList(array $nodes, string $comment): bool 250 | { 251 | foreach ($nodes as $node) { 252 | if (isset($node->value) && $node->value instanceof Expr\Array_ && $this->commentInNodeList($node->value->items, $comment)) { 253 | return true; 254 | } 255 | if ($nodeComments = $node->getAttribute('comments')) { 256 | foreach ($nodeComments as $nodeComment) { 257 | if ($nodeComment->getText() === $comment) { 258 | return true; 259 | } 260 | } 261 | } 262 | } 263 | 264 | return false; 265 | } 266 | 267 | /** 268 | * Check the parser tokens for comments within the node's start & end position, at root scope level 269 | * 270 | * @param Node $node Node to check 271 | * 272 | * @return array|null 273 | */ 274 | protected function getNodeComments(Node $node): ?array 275 | { 276 | $tokens = $this->parser->getTokens(); 277 | $pos = $node->getAttribute('startTokenPos'); 278 | $end = $node->getAttribute('endTokenPos'); 279 | $endLine = $node->getAttribute('endLine'); 280 | $comments = []; 281 | $level = 0; 282 | 283 | // We start at the starting position of the node which should be `[`, meaning that our root scope level 284 | // should always be 1, if it is less then we have exited the node, and bad things will happen 285 | for (;$pos <= $end; $pos++) { 286 | if (!isset($tokens[$pos]) || (!$tokens[$pos] instanceof Token) || $tokens[$pos]->line > $endLine) { 287 | break; 288 | } 289 | 290 | // When we encounter a token of either [ or ( we increase the scope level, this allows us to keep a track 291 | // of where we are in the ast, otherwise we will put comments in the wrong place as we will find comments 292 | // nested in deeper nodes, that we will pick up later anyway 293 | if (in_array($tokens[$pos]->id, static::LIST_T_OPENS)) { 294 | $level++; 295 | continue; 296 | } 297 | 298 | // When encountering a closing type, we reduce the scope level, allowing us to start looking for comments 299 | // again if we're only at scope level 1 300 | if (in_array($tokens[$pos]->id, static::LIST_T_CLOSES) && $level) { 301 | $level--; 302 | } 303 | 304 | // If either we encounter whitespace (we do not preserve whitespace) or our scope level is higher than our 305 | // root level, then we continue 306 | if ($tokens[$pos]->id === T_WHITESPACE || $level > 1) { 307 | continue; 308 | } 309 | 310 | // We found a comment in the scope of the node passed, add it to the array for returning 311 | if ($tokens[$pos]->id === T_COMMENT || $tokens[$pos]->id === T_DOC_COMMENT) { 312 | $comments[] = $tokens[$pos]->text; 313 | } 314 | } 315 | 316 | return empty($comments) ? null : $comments; 317 | } 318 | 319 | /** 320 | * Prints reformatted text of the passed comments. 321 | * 322 | * @param \PhpParser\Comment[] $comments List of comments 323 | * 324 | * @return string Reformatted text of comments 325 | */ 326 | protected function pComments(array $comments): string 327 | { 328 | $formattedComments = []; 329 | 330 | foreach ($comments as $comment) { 331 | $formattedComments[] = str_replace("\n", $this->nl, $comment->getReformattedText()); 332 | } 333 | 334 | $padding = $comments[0]->getStartLine() !== $comments[count($comments) - 1]->getEndLine() ? $this->nl : ''; 335 | 336 | // Get the parsed tokens 337 | $tokens = $this->parser->getTokens(); 338 | 339 | // Get the previous and next tokens either side of the comment block 340 | $previous = $tokens[$comments[array_key_first($comments)]->getStartTokenPos() - 1] ?? null; 341 | $next = $tokens[$comments[array_key_last($comments)]->getStartTokenPos() + 1] ?? null; 342 | 343 | // If the previous or next node contains duplicate \n then add one additional to the $this->nl, else just nl 344 | return (($previous->text ?? false) && substr_count($previous->text, PHP_EOL) > 1 ? "\n" : '') 345 | . $this->nl . trim($padding . implode($this->nl, $formattedComments)) 346 | . (($next->text ?? false) && substr_count($next->text, PHP_EOL) > 1 ? "\n" : ''); 347 | } 348 | 349 | /** 350 | * @param Expr\Include_ $node 351 | * @return string 352 | */ 353 | protected function pExpr_Include(Expr\Include_ $node, int $precedence, int $lhsPrecedence): string 354 | { 355 | static $map = [ 356 | Expr\Include_::TYPE_INCLUDE => 'include', 357 | Expr\Include_::TYPE_INCLUDE_ONCE => 'include_once', 358 | Expr\Include_::TYPE_REQUIRE => 'require', 359 | Expr\Include_::TYPE_REQUIRE_ONCE => 'require_once', 360 | ]; 361 | 362 | return $map[$node->type] . '(' . $this->p($node->expr) . ')'; 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/ArrayFile.php: -------------------------------------------------------------------------------- 1 | astReturnIndex = $this->getAstReturnIndex($ast); 60 | 61 | if (is_null($this->astReturnIndex)) { 62 | throw new \InvalidArgumentException('ArrayFiles must start with a return statement'); 63 | } 64 | 65 | $this->ast = $ast; 66 | $this->parser = $parser; 67 | $this->filePath = $filePath; 68 | $this->printer = $printer ?? new ArrayPrinter(); 69 | } 70 | 71 | /** 72 | * Return a new instance of `ArrayFile` ready for modification of the file. 73 | * 74 | * @throws \InvalidArgumentException if the provided path doesn't exist and $throwIfMissing is true 75 | * @throws ConfigWriterException if the provided path is unable to be parsed 76 | * @return static 77 | */ 78 | public static function open(string $filePath, bool $throwIfMissing = false) 79 | { 80 | $exists = file_exists($filePath); 81 | 82 | if (!$exists && $throwIfMissing) { 83 | throw new \InvalidArgumentException('file not found'); 84 | } 85 | 86 | $version = PhpVersion::getHostVersion(); 87 | 88 | $lexer = new Lexer\Emulative($version); 89 | $parser = ($version->id >= 80000) 90 | ? new Php8($lexer, $version) 91 | : new Php7($lexer, $version); 92 | 93 | try { 94 | $ast = $parser->parse( 95 | $exists 96 | ? file_get_contents($filePath) 97 | : sprintf('set('property.key.value', 'example'); 111 | * // or 112 | * $config->set([ 113 | * 'property.key1.value' => 'example', 114 | * 'property.key2.value' => 'example' 115 | * ]); 116 | * ``` 117 | * 118 | * @param string|array $key 119 | * @param mixed $value 120 | * @return static 121 | */ 122 | public function set($key, $value = null) 123 | { 124 | if (is_array($key)) { 125 | foreach ($key as $name => $value) { 126 | $this->set($name, $value); 127 | } 128 | 129 | return $this; 130 | } 131 | 132 | // try to find a reference to ast object 133 | list($target, $remaining) = $this->seek(explode('.', $key), $this->ast[$this->astReturnIndex]->expr); 134 | 135 | $valueType = $this->getType($value); 136 | 137 | // part of a path found 138 | if ($target && $remaining) { 139 | $target->value->items[] = $this->makeArrayItem(implode('.', $remaining), $valueType, $value); 140 | return $this; 141 | } 142 | 143 | // path to not found 144 | if (is_null($target)) { 145 | $this->ast[$this->astReturnIndex]->expr->items[] = $this->makeArrayItem($key, $valueType, $value); 146 | return $this; 147 | } 148 | 149 | if (!isset($target->value)) { 150 | return $this; 151 | } 152 | 153 | // special handling of function objects 154 | if (get_class($target->value) === FuncCall::class && $valueType !== 'function') { 155 | if ($target->value->name->name !== 'env' || !isset($target->value->args[0])) { 156 | return $this; 157 | } 158 | /* @phpstan-ignore-next-line */ 159 | if (isset($target->value->args[0]) && !isset($target->value->args[1])) { 160 | $target->value->args[1] = new Arg($this->makeAstNode($valueType, $value)); 161 | } 162 | $target->value->args[1]->value = $this->makeAstNode($valueType, $value); 163 | return $this; 164 | } 165 | 166 | // default update in place 167 | $target->value = $this->makeAstNode($valueType, $value); 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * Creates either a simple array item or a recursive array of items 174 | * 175 | * @param mixed $value 176 | */ 177 | protected function makeArrayItem(string $key, string $valueType, $value): ArrayItem 178 | { 179 | return (strpos($key, '.') !== false) 180 | ? $this->makeAstArrayRecursive($key, $valueType, $value) 181 | : new ArrayItem( 182 | $this->makeAstNode($valueType, $value), 183 | $this->makeAstNode($this->getType($key), $key) 184 | ); 185 | } 186 | 187 | /** 188 | * Generate an AST node, using `PhpParser` classes, for a value 189 | * 190 | * @param mixed $value 191 | * @throws \RuntimeException If $type is not one of 'string', 'boolean', 'integer', 'double', 'function', 'const', 'null', or 'array' 192 | * @return ConstFetch|LNumber|DNumber|String_|Array_|FuncCall 193 | */ 194 | protected function makeAstNode(string $type, $value) 195 | { 196 | switch (strtolower($type)) { 197 | case 'string': 198 | return new String_($value); 199 | case 'boolean': 200 | return new ConstFetch(new Name($value ? 'true' : 'false')); 201 | case 'integer': 202 | return new LNumber($value); 203 | case 'double': 204 | return new DNumber($value); 205 | case 'function': 206 | return new FuncCall( 207 | new Name($value->getName()), 208 | array_map(function ($arg) { 209 | return new Arg($this->makeAstNode($this->getType($arg), $arg)); 210 | }, $value->getArgs()) 211 | ); 212 | case 'const': 213 | return new ConstFetch(new Name($value->getName())); 214 | case 'null': 215 | return new ConstFetch(new Name('null')); 216 | case 'array': 217 | return $this->castArray($value); 218 | default: 219 | throw new \RuntimeException("An unimlemented replacement type ($type) was encountered"); 220 | } 221 | } 222 | 223 | /** 224 | * Cast an array to AST 225 | * 226 | * @param array $array 227 | */ 228 | protected function castArray(array $array): Array_ 229 | { 230 | return ($caster = function ($array, $ast) use (&$caster) { 231 | $useKeys = []; 232 | foreach (array_keys($array) as $i => $key) { 233 | $useKeys[$key] = (!is_numeric($key) || $key !== $i); 234 | } 235 | foreach ($array as $key => $item) { 236 | if (is_array($item)) { 237 | $ast->items[] = new ArrayItem( 238 | $caster($item, new Array_()), 239 | ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) 240 | ); 241 | continue; 242 | } 243 | $ast->items[] = new ArrayItem( 244 | $this->makeAstNode($this->getType($item), $item), 245 | ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) 246 | ); 247 | } 248 | 249 | return $ast; 250 | })($array, new Array_()); 251 | } 252 | 253 | /** 254 | * Returns type of var passed 255 | * 256 | * @param mixed $var 257 | */ 258 | protected function getType($var): string 259 | { 260 | if ($var instanceof PHPFunction) { 261 | return 'function'; 262 | } 263 | 264 | if ($var instanceof PHPConstant) { 265 | return 'const'; 266 | } 267 | 268 | return gettype($var); 269 | } 270 | 271 | /** 272 | * Returns an ArrayItem generated from a dot notation path 273 | * 274 | * @param string $key 275 | * @param string $valueType 276 | * @param mixed $value 277 | */ 278 | protected function makeAstArrayRecursive(string $key, string $valueType, $value): ArrayItem 279 | { 280 | $path = array_reverse(explode('.', $key)); 281 | 282 | $arrayItem = $this->makeAstNode($valueType, $value); 283 | 284 | foreach ($path as $index => $pathKey) { 285 | if (is_numeric($pathKey)) { 286 | $pathKey = (int) $pathKey; 287 | } 288 | $arrayItem = new ArrayItem($arrayItem, $this->makeAstNode($this->getType($pathKey), $pathKey)); 289 | 290 | if ($index !== array_key_last($path)) { 291 | $arrayItem = new Array_([$arrayItem]); 292 | } 293 | } 294 | 295 | return $arrayItem; 296 | } 297 | 298 | /** 299 | * Find the return position within the ast, returns null on encountering an unsupported ast stmt. 300 | * 301 | * @param Stmt[] $ast 302 | * @return int|null 303 | */ 304 | protected function getAstReturnIndex(array $ast): ?int 305 | { 306 | foreach ($ast as $index => $item) { 307 | switch (get_class($item)) { 308 | case Stmt\Use_::class: 309 | case Stmt\Expression::class: 310 | case Stmt\Declare_::class: 311 | break; 312 | case Stmt\Return_::class: 313 | return $index; 314 | default: 315 | return null; 316 | } 317 | } 318 | 319 | return null; 320 | } 321 | 322 | /** 323 | * Attempt to find the parent object of the targeted path. 324 | * If the path cannot be found completely, return the nearest parent and the remainder of the path 325 | * 326 | * @param array $path 327 | * @param mixed $pointer 328 | * @param int $depth 329 | * @return array 330 | * @throws ConfigWriterException if trying to set a position that is already occupied by a value 331 | */ 332 | protected function seek(array $path, &$pointer, int $depth = 0): array 333 | { 334 | if (!$pointer) { 335 | return [null, $path]; 336 | } 337 | 338 | $key = array_shift($path); 339 | 340 | if (isset($pointer->value) && !($pointer->value instanceof ArrayItem || $pointer->value instanceof Array_)) { 341 | throw new ConfigWriterException(sprintf( 342 | 'Illegal offset, you are trying to set a position occupied by a value (%s)', 343 | get_class($pointer->value) 344 | )); 345 | } 346 | 347 | foreach (($pointer->items ?? $pointer->value->items) as $index => &$item) { 348 | // loose checking to allow for int keys 349 | if ($item->key->value == $key) { 350 | if (!empty($path)) { 351 | return $this->seek($path, $item, ++$depth); 352 | } 353 | 354 | return [$item, []]; 355 | } 356 | } 357 | 358 | array_unshift($path, $key); 359 | 360 | return [($depth > 0) ? $pointer : null, $path]; 361 | } 362 | 363 | /** 364 | * Sort the config, supports: ArrayFile::SORT_ASC, ArrayFile::SORT_DESC, callable 365 | * 366 | * @param string|callable $mode 367 | * @throws \InvalidArgumentException if the provided sort type is not a callable or one of static::SORT_ASC or static::SORT_DESC 368 | */ 369 | public function sort($mode = self::SORT_ASC): ArrayFile 370 | { 371 | if (is_callable($mode)) { 372 | usort($this->ast[0]->expr->items, $mode); 373 | return $this; 374 | } 375 | 376 | switch ($mode) { 377 | case static::SORT_ASC: 378 | case static::SORT_DESC: 379 | $this->sortRecursive($this->ast[0]->expr->items, $mode); 380 | break; 381 | default: 382 | throw new \InvalidArgumentException('Requested sort type is invalid'); 383 | } 384 | 385 | return $this; 386 | } 387 | 388 | /** 389 | * Recursive sort an Array_ item array 390 | * 391 | * @param array $array 392 | */ 393 | protected function sortRecursive(array &$array, string $mode): void 394 | { 395 | foreach ($array as &$item) { 396 | if (isset($item->value) && $item->value instanceof Array_) { 397 | $this->sortRecursive($item->value->items, $mode); 398 | } 399 | } 400 | 401 | usort($array, function ($a, $b) use ($mode) { 402 | return $mode === static::SORT_ASC 403 | ? $a->key->value <=> $b->key->value 404 | : $b->key->value <=> $a->key->value; 405 | }); 406 | } 407 | 408 | /** 409 | * Write the current config to a file 410 | */ 411 | public function write(string $filePath = null): void 412 | { 413 | if (!$filePath && $this->filePath) { 414 | $filePath = $this->filePath; 415 | } 416 | 417 | file_put_contents($filePath, $this->render()); 418 | } 419 | 420 | /** 421 | * Returns a new instance of PHPFunction 422 | * 423 | * @param array $args 424 | */ 425 | public function function(string $name, array $args): PHPFunction 426 | { 427 | return new PHPFunction($name, $args); 428 | } 429 | 430 | /** 431 | * Returns a new instance of PHPConstant 432 | */ 433 | public function constant(string $name): PHPConstant 434 | { 435 | return new PHPConstant($name); 436 | } 437 | 438 | /** 439 | * Get the printed AST as PHP code 440 | */ 441 | public function render(): string 442 | { 443 | return $this->printer->render($this->ast, $this->parser) . "\n"; 444 | } 445 | } 446 | --------------------------------------------------------------------------------