├── LICENSE ├── README.md ├── composer.json ├── phpcs.xml └── src ├── ArrayFile.php ├── Contracts ├── DataFileInterface.php ├── DataFileLexerInterface.php └── DataFilePrinterInterface.php ├── DataFile.php ├── EnvFile.php ├── Exceptions ├── ConfigWriterException.php └── EnvParserException.php ├── Parser ├── EnvLexer.php ├── PHPConstant.php └── PHPFunction.php └── Printer ├── ArrayPrinter.php └── EnvPrinter.php /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "winter/laravel-config-writer", 3 | "description": "Utility to create and update Laravel config and .env files", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Jack Wilkinson", 9 | "email": "me@jaxwilko.com", 10 | "role": "Original author" 11 | }, 12 | { 13 | "name": "Winter CMS Maintainers", 14 | "homepage": "https://wintercms.com", 15 | "role": "Maintainers" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.4.0 || ^8.0", 20 | "nikic/php-parser": "^4.10" 21 | }, 22 | "require-dev": { 23 | "phpstan/phpstan": "^1.6", 24 | "phpunit/phpunit": "^9.5" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Winter\\LaravelConfigWriter\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Winter\\LaravelConfigWriter\\Tests\\": "tests/" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 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/ArrayFile.php: -------------------------------------------------------------------------------- 1 | astReturnIndex = $this->getAstReturnIndex($ast); 56 | 57 | if (is_null($this->astReturnIndex)) { 58 | throw new \InvalidArgumentException('ArrayFiles must start with a return statement'); 59 | } 60 | 61 | $this->ast = $ast; 62 | $this->lexer = $lexer; 63 | $this->filePath = $filePath; 64 | $this->printer = $printer ?? new ArrayPrinter(); 65 | } 66 | 67 | /** 68 | * Return a new instance of `ArrayFile` ready for modification of the file. 69 | * 70 | * @throws \InvalidArgumentException if the provided path doesn't exist and $throwIfMissing is true 71 | * @throws ConfigWriterException if the provided path is unable to be parsed 72 | * @return static 73 | */ 74 | public static function open(string $filePath, bool $throwIfMissing = false) 75 | { 76 | $exists = file_exists($filePath); 77 | 78 | if (!$exists && $throwIfMissing) { 79 | throw new \InvalidArgumentException('file not found'); 80 | } 81 | 82 | $lexer = new Lexer\Emulative([ 83 | 'usedAttributes' => [ 84 | 'comments', 85 | 'startTokenPos', 86 | 'startLine', 87 | 'endTokenPos', 88 | 'endLine' 89 | ] 90 | ]); 91 | $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7, $lexer); 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->parts[0] !== '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', 'function', 'const', 'null', or 'array' 192 | * @return ConstFetch|LNumber|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 'function': 204 | return new FuncCall( 205 | new Name($value->getName()), 206 | array_map(function ($arg) { 207 | return new Arg($this->makeAstNode($this->getType($arg), $arg)); 208 | }, $value->getArgs()) 209 | ); 210 | case 'const': 211 | return new ConstFetch(new Name($value->getName())); 212 | case 'null': 213 | return new ConstFetch(new Name('null')); 214 | case 'array': 215 | return $this->castArray($value); 216 | default: 217 | throw new \RuntimeException("An unimlemented replacement type ($type) was encountered"); 218 | } 219 | } 220 | 221 | /** 222 | * Cast an array to AST 223 | * 224 | * @param array $array 225 | */ 226 | protected function castArray(array $array): Array_ 227 | { 228 | return ($caster = function ($array, $ast) use (&$caster) { 229 | $useKeys = []; 230 | foreach (array_keys($array) as $i => $key) { 231 | $useKeys[$key] = (!is_numeric($key) || $key !== $i); 232 | } 233 | foreach ($array as $key => $item) { 234 | if (is_array($item)) { 235 | $ast->items[] = new ArrayItem( 236 | $caster($item, new Array_()), 237 | ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) 238 | ); 239 | continue; 240 | } 241 | $ast->items[] = new ArrayItem( 242 | $this->makeAstNode($this->getType($item), $item), 243 | ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) 244 | ); 245 | } 246 | 247 | return $ast; 248 | })($array, new Array_()); 249 | } 250 | 251 | /** 252 | * Returns type of var passed 253 | * 254 | * @param mixed $var 255 | */ 256 | protected function getType($var): string 257 | { 258 | if ($var instanceof PHPFunction) { 259 | return 'function'; 260 | } 261 | 262 | if ($var instanceof PHPConstant) { 263 | return 'const'; 264 | } 265 | 266 | return gettype($var); 267 | } 268 | 269 | /** 270 | * Returns an ArrayItem generated from a dot notation path 271 | * 272 | * @param string $key 273 | * @param string $valueType 274 | * @param mixed $value 275 | */ 276 | protected function makeAstArrayRecursive(string $key, string $valueType, $value): ArrayItem 277 | { 278 | $path = array_reverse(explode('.', $key)); 279 | 280 | $arrayItem = $this->makeAstNode($valueType, $value); 281 | 282 | foreach ($path as $index => $pathKey) { 283 | if (is_numeric($pathKey)) { 284 | $pathKey = (int) $pathKey; 285 | } 286 | $arrayItem = new ArrayItem($arrayItem, $this->makeAstNode($this->getType($pathKey), $pathKey)); 287 | 288 | if ($index !== array_key_last($path)) { 289 | $arrayItem = new Array_([$arrayItem]); 290 | } 291 | } 292 | 293 | return $arrayItem; 294 | } 295 | 296 | /** 297 | * Find the return position within the ast, returns null on encountering an unsupported ast stmt. 298 | * 299 | * @param Stmt[] $ast 300 | * @return int|null 301 | */ 302 | protected function getAstReturnIndex(array $ast): ?int 303 | { 304 | foreach ($ast as $index => $item) { 305 | switch (get_class($item)) { 306 | case Stmt\Use_::class: 307 | case Stmt\Expression::class: 308 | case Stmt\Declare_::class: 309 | break; 310 | case Stmt\Return_::class: 311 | return $index; 312 | default: 313 | return null; 314 | } 315 | } 316 | 317 | return null; 318 | } 319 | 320 | /** 321 | * Attempt to find the parent object of the targeted path. 322 | * If the path cannot be found completely, return the nearest parent and the remainder of the path 323 | * 324 | * @param array $path 325 | * @param mixed $pointer 326 | * @param int $depth 327 | * @return array 328 | * @throws ConfigWriterException if trying to set a position that is already occupied by a value 329 | */ 330 | protected function seek(array $path, &$pointer, int $depth = 0): array 331 | { 332 | if (!$pointer) { 333 | return [null, $path]; 334 | } 335 | 336 | $key = array_shift($path); 337 | 338 | if (isset($pointer->value) && !($pointer->value instanceof ArrayItem || $pointer->value instanceof Array_)) { 339 | throw new ConfigWriterException(sprintf( 340 | 'Illegal offset, you are trying to set a position occupied by a value (%s)', 341 | get_class($pointer->value) 342 | )); 343 | } 344 | 345 | foreach (($pointer->items ?? $pointer->value->items) as $index => &$item) { 346 | // loose checking to allow for int keys 347 | if ($item->key->value == $key) { 348 | if (!empty($path)) { 349 | return $this->seek($path, $item, ++$depth); 350 | } 351 | 352 | return [$item, []]; 353 | } 354 | } 355 | 356 | array_unshift($path, $key); 357 | 358 | return [($depth > 0) ? $pointer : null, $path]; 359 | } 360 | 361 | /** 362 | * Sort the config, supports: ArrayFile::SORT_ASC, ArrayFile::SORT_DESC, callable 363 | * 364 | * @param string|callable $mode 365 | * @throws \InvalidArgumentException if the provided sort type is not a callable or one of static::SORT_ASC or static::SORT_DESC 366 | */ 367 | public function sort($mode = self::SORT_ASC): ArrayFile 368 | { 369 | if (is_callable($mode)) { 370 | usort($this->ast[0]->expr->items, $mode); 371 | return $this; 372 | } 373 | 374 | switch ($mode) { 375 | case static::SORT_ASC: 376 | case static::SORT_DESC: 377 | $this->sortRecursive($this->ast[0]->expr->items, $mode); 378 | break; 379 | default: 380 | throw new \InvalidArgumentException('Requested sort type is invalid'); 381 | } 382 | 383 | return $this; 384 | } 385 | 386 | /** 387 | * Recursive sort an Array_ item array 388 | * 389 | * @param array $array 390 | */ 391 | protected function sortRecursive(array &$array, string $mode): void 392 | { 393 | foreach ($array as &$item) { 394 | if (isset($item->value) && $item->value instanceof Array_) { 395 | $this->sortRecursive($item->value->items, $mode); 396 | } 397 | } 398 | 399 | usort($array, function ($a, $b) use ($mode) { 400 | return $mode === static::SORT_ASC 401 | ? $a->key->value <=> $b->key->value 402 | : $b->key->value <=> $a->key->value; 403 | }); 404 | } 405 | 406 | /** 407 | * Write the current config to a file 408 | */ 409 | public function write(string $filePath = null): void 410 | { 411 | if (!$filePath && $this->filePath) { 412 | $filePath = $this->filePath; 413 | } 414 | 415 | file_put_contents($filePath, $this->render()); 416 | } 417 | 418 | /** 419 | * Returns a new instance of PHPFunction 420 | * 421 | * @param array $args 422 | */ 423 | public function function(string $name, array $args): PHPFunction 424 | { 425 | return new PHPFunction($name, $args); 426 | } 427 | 428 | /** 429 | * Returns a new instance of PHPConstant 430 | */ 431 | public function constant(string $name): PHPConstant 432 | { 433 | return new PHPConstant($name); 434 | } 435 | 436 | /** 437 | * Get the printed AST as PHP code 438 | */ 439 | public function render(): string 440 | { 441 | return $this->printer->render($this->ast, $this->lexer) . "\n"; 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /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/Contracts/DataFileLexerInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function parse(string $string): array; 20 | } 21 | -------------------------------------------------------------------------------- /src/Contracts/DataFilePrinterInterface.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/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/Exceptions/ConfigWriterException.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/Printer/ArrayPrinter.php: -------------------------------------------------------------------------------- 1 | $options Dictionary of formatting options 26 | */ 27 | public function __construct(array $options = []) 28 | { 29 | if (!isset($options['shortArraySyntax'])) { 30 | $options['shortArraySyntax'] = true; 31 | } 32 | 33 | parent::__construct($options); 34 | } 35 | 36 | /** 37 | * Proxy of `prettyPrintFile` to allow for adding lexer token checking support during render. 38 | * Pretty prints a file of statements (includes the opening lexer = $lexer; 51 | 52 | $p = "prettyPrint($stmts); 53 | 54 | if ($stmts[0] instanceof Stmt\InlineHTML) { 55 | $p = preg_replace('/^<\?php\s+\?>\n?/', '', $p); 56 | } 57 | if ($stmts[count($stmts) - 1] instanceof Stmt\InlineHTML) { 58 | $p = preg_replace('/<\?php$/', '', rtrim($p)); 59 | } 60 | 61 | $this->lexer = null; 62 | 63 | return $p; 64 | } 65 | 66 | /** 67 | * @param array $nodes 68 | * @param bool $trailingComma 69 | * @return string 70 | */ 71 | protected function pMaybeMultiline(array $nodes, bool $trailingComma = false) 72 | { 73 | if ($this->hasNodeWithComments($nodes) || (isset($nodes[0]) && $nodes[0] instanceof Expr\ArrayItem)) { 74 | return $this->pCommaSeparatedMultiline($nodes, $trailingComma) . $this->nl; 75 | } else { 76 | return $this->pCommaSeparated($nodes); 77 | } 78 | } 79 | 80 | /** 81 | * Pretty prints a comma-separated list of nodes in multiline style, including comments. 82 | * 83 | * The result includes a leading newline and one level of indentation (same as pStmts). 84 | * 85 | * @param array $nodes Array of Nodes to be printed 86 | * @param bool $trailingComma Whether to use a trailing comma 87 | * 88 | * @return string Comma separated pretty printed nodes in multiline style 89 | */ 90 | protected function pCommaSeparatedMultiline(array $nodes, bool $trailingComma): string 91 | { 92 | $this->indent(); 93 | 94 | $result = ''; 95 | $lastIdx = count($nodes) - 1; 96 | foreach ($nodes as $idx => $node) { 97 | if ($node !== null) { 98 | $comments = $node->getComments(); 99 | 100 | if ($comments) { 101 | $result .= $this->pComments($comments); 102 | } 103 | 104 | $result .= $this->nl . $this->p($node); 105 | } else { 106 | $result = trim($result) . "\n"; 107 | } 108 | if ($trailingComma || $idx !== $lastIdx) { 109 | $result .= ','; 110 | } 111 | } 112 | 113 | $this->outdent(); 114 | return $result; 115 | } 116 | 117 | /** 118 | * Render an array expression 119 | * 120 | * @param Expr\Array_ $node Array expression node 121 | * 122 | * @return string Comma separated pretty printed nodes in multiline style 123 | */ 124 | protected function pExpr_Array(Expr\Array_ $node): string 125 | { 126 | $default = $this->options['shortArraySyntax'] 127 | ? Expr\Array_::KIND_SHORT 128 | : Expr\Array_::KIND_LONG; 129 | 130 | $ops = $node->getAttribute('kind', $default) === Expr\Array_::KIND_SHORT 131 | ? ['[', ']'] 132 | : ['array(', ')']; 133 | 134 | if (!count($node->items) && $comments = $this->getNodeComments($node)) { 135 | // the array has no items, we can inject whatever we want 136 | return sprintf( 137 | '%s%s%s%s%s', 138 | // opening control char 139 | $ops[0], 140 | // indent and add nl string 141 | $this->indent(), 142 | // join all comments with nl string 143 | implode($this->nl, $comments), 144 | // outdent and add nl string 145 | $this->outdent(), 146 | // closing control char 147 | $ops[1] 148 | ); 149 | } 150 | 151 | if ($comments = $this->getCommentsNotInArray($node)) { 152 | // array has items, we have detected comments not included within the array, therefore we have found 153 | // trailing comments and must append them to the end of the array 154 | return sprintf( 155 | '%s%s%s%s%s%s', 156 | // opening control char 157 | $ops[0], 158 | // render the children 159 | $this->pMaybeMultiline($node->items, true), 160 | // add 1 level of indentation 161 | str_repeat(' ', 4), 162 | // join all comments with the current indentation 163 | implode($this->nl . str_repeat(' ', 4), $comments), 164 | // add a trailing nl 165 | $this->nl, 166 | // closing control char 167 | $ops[1] 168 | ); 169 | } 170 | 171 | // default return 172 | return $ops[0] . $this->pMaybeMultiline($node->items, true) . $ops[1]; 173 | } 174 | 175 | /** 176 | * Increase indentation level. 177 | * Proxied to allow for nl return 178 | * 179 | * @return string 180 | */ 181 | protected function indent(): string 182 | { 183 | $this->indentLevel += 4; 184 | $this->nl .= ' '; 185 | return $this->nl; 186 | } 187 | 188 | /** 189 | * Decrease indentation level. 190 | * Proxied to allow for nl return 191 | * 192 | * @return string 193 | */ 194 | protected function outdent(): string 195 | { 196 | assert($this->indentLevel >= 4); 197 | $this->indentLevel -= 4; 198 | $this->nl = "\n" . str_repeat(' ', $this->indentLevel); 199 | return $this->nl; 200 | } 201 | 202 | /** 203 | * Get all comments that have not been attributed to a node within a node array 204 | * 205 | * @param Expr\Array_ $nodes Array of nodes 206 | * 207 | * @return array Comments found 208 | */ 209 | protected function getCommentsNotInArray(Expr\Array_ $nodes): array 210 | { 211 | if (!$comments = $this->getNodeComments($nodes)) { 212 | return []; 213 | } 214 | 215 | return array_filter($comments, function ($comment) use ($nodes) { 216 | return !$this->commentInNodeList($nodes->items, $comment); 217 | }); 218 | } 219 | 220 | /** 221 | * Recursively check if a comment exists in an array of nodes 222 | * 223 | * @param Node[] $nodes Array of nodes 224 | * @param string $comment The comment to search for 225 | * 226 | * @return bool 227 | */ 228 | protected function commentInNodeList(array $nodes, string $comment): bool 229 | { 230 | foreach ($nodes as $node) { 231 | if (isset($node->value) && $node->value instanceof Expr\Array_ && $this->commentInNodeList($node->value->items, $comment)) { 232 | return true; 233 | } 234 | if ($nodeComments = $node->getAttribute('comments')) { 235 | foreach ($nodeComments as $nodeComment) { 236 | if ($nodeComment->getText() === $comment) { 237 | return true; 238 | } 239 | } 240 | } 241 | } 242 | 243 | return false; 244 | } 245 | 246 | /** 247 | * Check the lexer tokens for comments within the node's start & end position 248 | * 249 | * @param Node $node Node to check 250 | * 251 | * @return array|null 252 | */ 253 | protected function getNodeComments(Node $node): ?array 254 | { 255 | $tokens = $this->lexer->getTokens(); 256 | $pos = $node->getAttribute('startTokenPos'); 257 | $end = $node->getAttribute('endTokenPos'); 258 | $endLine = $node->getAttribute('endLine'); 259 | $content = []; 260 | 261 | while (++$pos < $end) { 262 | if (!isset($tokens[$pos]) || (!is_array($tokens[$pos]) && $tokens[$pos] !== ',')) { 263 | break; 264 | } 265 | 266 | if ($tokens[$pos][0] === T_WHITESPACE || $tokens[$pos] === ',') { 267 | continue; 268 | } 269 | 270 | list($type, $string, $line) = $tokens[$pos]; 271 | 272 | if ($line > $endLine) { 273 | break; 274 | } 275 | 276 | if ($type === T_COMMENT || $type === T_DOC_COMMENT) { 277 | $content[] = $string; 278 | } elseif ($content) { 279 | break; 280 | } 281 | } 282 | 283 | return empty($content) ? null : $content; 284 | } 285 | 286 | /** 287 | * Prints reformatted text of the passed comments. 288 | * 289 | * @param \PhpParser\Comment[] $comments List of comments 290 | * 291 | * @return string Reformatted text of comments 292 | */ 293 | protected function pComments(array $comments): string 294 | { 295 | $formattedComments = []; 296 | 297 | foreach ($comments as $comment) { 298 | $formattedComments[] = str_replace("\n", $this->nl, $comment->getReformattedText()); 299 | } 300 | 301 | $padding = $comments[0]->getStartLine() !== $comments[count($comments) - 1]->getEndLine() ? $this->nl : ''; 302 | 303 | return "\n" . $this->nl . trim($padding . implode($this->nl, $formattedComments)) . "\n"; 304 | } 305 | 306 | /** 307 | * @param Expr\Include_ $node 308 | * @return string 309 | */ 310 | protected function pExpr_Include(Expr\Include_ $node) 311 | { 312 | static $map = [ 313 | Expr\Include_::TYPE_INCLUDE => 'include', 314 | Expr\Include_::TYPE_INCLUDE_ONCE => 'include_once', 315 | Expr\Include_::TYPE_REQUIRE => 'require', 316 | Expr\Include_::TYPE_REQUIRE_ONCE => 'require_once', 317 | ]; 318 | 319 | return $map[$node->type] . '(' . $this->p($node->expr) . ')'; 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/Printer/EnvPrinter.php: -------------------------------------------------------------------------------- 1 |