Skip to content

Commit ac80c80

Browse files
authored
feat: new env merger and if_exists conditions (#12)
1 parent ec8fa93 commit ac80c80

20 files changed

+1127
-653
lines changed

README.md

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,18 @@ Files are a described as key-value pairs.
8484

8585
If a string is given, it must be a path to the source file.
8686

87-
| Parameter | Type | Comments |
88-
|------------------------------|------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
89-
| **type** | string | Type of file.<br/><br/>**Choices:**<ul><li>`text`</li><li>`php_array`</li><li>`json`</li><li>`yaml`</li><li>`docker_compose`</li></ul>**Default:** `text`<br/>**Optional** |
90-
| **destination** | string | Path of the destination file in the project that will be created or merged.<br/><br/>**Required** |
91-
| **source** | string | Path of the source file in the package which content will be used to create or merge in the destination file.<br/><br/>**Required** if **content** isn't defined |
92-
| **content** | string | Text to merge in the destination file.<br/><br/>**Required** if **source** isn't defined |
93-
| **entries** | array<string, mixed> | Key-value pairs used to fill a PHP or JSON array.<br/><br/>**Required** if **type** is of type `php_array` or `json` |
94-
| **filters** | {keys: array\<string>, values: array\<string>} | Filters for **entries** when **type** is `php_array`.<br/><br/>**Choices:**<ul><li>`keys`<ul><li>`class_constant` Convert the given string to a class constant. As an example, `'Williarin\Cook'` becomes `Williarin\Cook::class`</li></ul></li><li>`values`<ul><li>`class_constant` See above</li><li>`single_line_array` If the value is an array, it will be exported on a single line</li></ul></li></ul>**Optional** |
95-
| **valid_sections** | array\<string> | Used if **type** is `yaml` or `json` in order to restrict which top-level parameters need to be merged.<br/><br/>Example: `[parameters, services]`<br/><br/>**Optional** |
96-
| **blank_line_after** | array\<string> | Used if **type** is `yaml` in order to add a blank line under the merged section.<br/><br/>Example: `[services]`<br/><br/>**Optional** |
97-
| **uninstall_empty_sections** | boolean | Used if **type** is `yaml` in order to remove an empty recipe section when uninstalling the recipe.<br/><br/>**Default:** `false`<br/>**Optional** |
87+
| Parameter | Type | Comments |
88+
|------------------------------|------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
89+
| **type** | string | Type of file.<br/><br/>**Choices:**<ul><li>`text`</li><li>`php_array`</li><li>`json`</li><li>`yaml`</li><li>`env`</li><li>`docker_compose`</li></ul>**Default:** `text`<br/>**Optional** |
90+
| **destination** | string | Path of the destination file in the project that will be created or merged.<br/><br/>**Required** |
91+
| **source** | string | Path of the source file in the package which content will be used to create or merge in the destination file.<br/><br/>**Required** if **content** isn't defined |
92+
| **content** | string | Text to merge in the destination file.<br/><br/>**Required** if **source** isn't defined |
93+
| **entries** | array<string, mixed> | Key-value pairs used to fill a PHP or JSON array.<br/><br/>**Required** if **type** is of type `php_array` or `json` |
94+
| **filters** | {keys: array\<string>, values: array\<string>} | Filters for **entries** when **type** is `php_array`.<br/><br/>**Choices:**<ul><li>`keys`<ul><li>`class_constant` Convert the given string to a class constant. As an example, `'Williarin\Cook'` becomes `Williarin\Cook::class`</li></ul></li><li>`values`<ul><li>`class_constant` See above</li><li>`single_line_array` If the value is an array, it will be exported on a single line</li></ul></li></ul>**Optional** |
95+
| **valid_sections** | array\<string> | Used if **type** is `yaml` or `json` in order to restrict which top-level parameters need to be merged.<br/><br/>Example: `[parameters, services]`<br/><br/>**Optional** |
96+
| **blank_line_after** | array\<string> | Used if **type** is `yaml` in order to add a blank line under the merged section.<br/><br/>Example: `[services]`<br/><br/>**Optional** |
97+
| **uninstall_empty_sections** | boolean | Used if **type** is `yaml` in order to remove an empty recipe section when uninstalling the recipe.<br/><br/>**Default:** `false`<br/>**Optional** |
98+
| **if_exists** | string | Used if **type** is `text` or `env`. <br/><br/>**Choices:**<ul><li>For type `type`<ul><li>`append` Adds content to the end of an existing file, or creates a new one.</li><li>`overwrite` Overwrites existing content, or creates a new file.</li><li>`ignore` Doesn't alter an existing file, or creates a new file.</li></ul></li><li>For type `env`<ul><li>`comment` Comments same name env vars</li><li>`delete` Delete same name env vars</li></ul></li></ul>**Default:** `append` for `text` type. `comment` for `env` type.<br/>**Optional** |
9899

99100
#### Directories
100101

@@ -114,33 +115,92 @@ You can use colors using [Symfony Console](https://symfony.com/doc/current/conso
114115

115116
The text merger can be used to extend any text-based file such as:
116117
* .gitignore
117-
* .env
118118
* Makefile
119119

120120
As it's the default merger, you can simply use the `destination: source` format in the recipe.
121121

122+
**Example 1:** append to an existing file
123+
124+
Given `yourrepo/recipe/.gitignore` with this content:
125+
```
126+
# Ignore the .env file
127+
.env
128+
```
129+
With this recipe:
130+
```yaml
131+
files:
132+
.gitignore: recipe/.gitignore
133+
```
134+
The created `.gitignore` file will look like this:
135+
```
136+
###> yourname/yourrepo ###
137+
# Ignore the .env file
138+
.env
139+
###< yourname/yourrepo ###
140+
```
141+
142+
The `###> yourname/yourrepo ###` opening comment and `###< yourname/yourrepo ###` closing comment are used by Cook to identify the recipe in the file.
143+
If you're familiar with Symfony Flex, the syntax is the same.
144+
145+
**Example 2:** overwrite an existing file
146+
147+
If you want to overwrite the existing file, you can use the `if_exists` parameter.
148+
149+
```yaml
150+
files:
151+
.gitignore:
152+
source: recipe/.gitignore
153+
if_exists: overwrite
154+
```
155+
This will replace the entire content of the `.gitignore` file with the content of `recipe/.gitignore`.
156+
157+
**Example 3:** ignore an existing file
158+
159+
If you want to ignore the existing file, you can use the `if_exists` parameter.
160+
161+
```yaml
162+
files:
163+
.gitignore:
164+
source: recipe/.gitignore
165+
if_exists: ignore
166+
```
167+
This will not alter the existing `.gitignore` file, and will not create a new one if it doesn't exist.
168+
169+
#### Env
170+
171+
The env merger is used to add new environment variables to an existing `.env` file or create a new one if it doesn't exist.
172+
122173
**Example 1:** merge or create a `.env` file with a given source file
123174

124175
Given `yourrepo/recipe/.env` with this content:
125176
```dotenv
126177
SOME_ENV_VARIABLE='hello'
127178
ANOTHER_ENV_VARIABLE='world'
128179
```
180+
And an existing `.env` file in the project with this content:
181+
```dotenv
182+
# Existing environment variables
183+
SOME_ENV_VARIABLE='foo'
184+
```
129185
With this recipe:
130186
```yaml
131187
files:
132-
.env: recipe/.env
188+
.env:
189+
type: env
190+
source: recipe/.env
133191
```
134192
The created `.env` file will look like this:
135193
```dotenv
194+
# Existing environment variables
195+
#SOME_ENV_VARIABLE='foo'
196+
136197
###> yourname/yourrepo ###
137198
SOME_ENV_VARIABLE='hello'
138199
ANOTHER_ENV_VARIABLE='world'
139200
###< yourname/yourrepo ###
140201
```
141202

142-
The `###> yourname/yourrepo ###` opening comment and `###< yourname/yourrepo ###` closing comment are used by Cook to identify the recipe in the file.
143-
If you're familiar with Symfony Flex, the syntax is the same.
203+
The existing `SOME_ENV_VARIABLE` is commented out to avoid conflicts with the new value, this is the default behavior of the env merger.
144204

145205
**Example 2:** merge or create a `.env` file with a string input
146206

composer.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,10 @@
2121
"composer/composer": "^2.3",
2222
"ergebnis/composer-normalize": "^2.29",
2323
"kubawerlos/php-cs-fixer-custom-fixers": "^3.11",
24-
"mockery/mockery": "^1.5",
2524
"nikic/php-parser": "^4.15 || ^5.0",
2625
"php-parallel-lint/php-parallel-lint": "^1.3",
2726
"phpro/grumphp": "^1.13 || ^2.4",
28-
"phpunit/phpunit": "^9.5.22",
27+
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
2928
"symfony/var-dumper": "^6.4 || ^7.0",
3029
"symplify/coding-standard": "^12.0",
3130
"symplify/easy-coding-standard": "^12.0"

src/Merger/AbstractMerger.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
namespace Williarin\Cook\Merger;
66

77
use Composer\IO\IOInterface;
8-
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
8+
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
99
use Symfony\Component\DependencyInjection\ServiceLocator;
1010
use Symfony\Component\Filesystem\Filesystem;
1111
use Williarin\Cook\Filter\Filter;
@@ -17,7 +17,7 @@ public function __construct(
1717
protected IOInterface $io,
1818
protected StateInterface $state,
1919
protected Filesystem $filesystem,
20-
#[TaggedLocator(Filter::class, defaultIndexMethod: 'getName')]
20+
#[AutowireLocator(Filter::class, defaultIndexMethod: 'getName')]
2121
private ServiceLocator $filters,
2222
) {
2323
}

src/Merger/EnvMerger.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Williarin\Cook\Merger;
6+
7+
final class EnvMerger extends AbstractMerger
8+
{
9+
use TextMergerUninstallTrait;
10+
11+
public static function getName(): string
12+
{
13+
return 'env';
14+
}
15+
16+
public function merge(array $file): void
17+
{
18+
if (($recipeContent = $this->getSourceContent($file)) === null) {
19+
return;
20+
}
21+
22+
$destinationPathname = $this->getDestinationRealPathname($file);
23+
$destContent = $this->filesystem->exists($destinationPathname) ? file_get_contents($destinationPathname) : '';
24+
$originalDestContent = $destContent;
25+
26+
$ownBlockPattern = sprintf(
27+
"/\n?%s.*%s\n?/simU",
28+
preg_quote($this->getRecipeIdOpeningComment(), '/'),
29+
preg_quote($this->getRecipeIdClosingComment(), '/')
30+
);
31+
$destContent = preg_replace($ownBlockPattern, '', $destContent);
32+
33+
$recipeVars = $this->parseEnvVariables($recipeContent);
34+
$ifExistsPolicy = $file['if_exists'] ?? 'comment';
35+
36+
if (!empty($recipeVars)) {
37+
$lines = explode("\n", $destContent);
38+
$newLines = [];
39+
$modified = false;
40+
41+
foreach ($lines as $line) {
42+
$isDuplicate = false;
43+
if (trim($line) !== '' && !str_starts_with(trim($line), '#')) {
44+
foreach ($recipeVars as $varName) {
45+
if (preg_match('/^\s*(?:export\s+)?' . preg_quote($varName) . '\s*=/i', $line)) {
46+
$isDuplicate = true;
47+
break;
48+
}
49+
}
50+
}
51+
52+
if ($isDuplicate) {
53+
$modified = true;
54+
if ($ifExistsPolicy === 'comment') {
55+
$newLines[] = '#' . $line;
56+
}
57+
} else {
58+
$newLines[] = $line;
59+
}
60+
}
61+
62+
if ($modified) {
63+
$destContent = implode("\n", $newLines);
64+
}
65+
}
66+
67+
$recipeBlock = $this->wrapRecipeId(rtrim($recipeContent, "\n"));
68+
$destContent = rtrim($destContent);
69+
if ($destContent !== '') {
70+
$destContent .= "\n\n";
71+
}
72+
$destContent .= $recipeBlock;
73+
74+
if (trim($originalDestContent) === trim($destContent)) {
75+
return;
76+
}
77+
78+
$fileExists = $this->filesystem->exists($destinationPathname);
79+
$this->filesystem->mkdir(\dirname($destinationPathname));
80+
$this->filesystem->dumpFile($destinationPathname, $destContent);
81+
82+
$this->io->write(sprintf('%s file: %s', $fileExists ? 'Updated' : 'Created', $destinationPathname));
83+
}
84+
85+
private function parseEnvVariables(string $content): array
86+
{
87+
$vars = [];
88+
preg_match_all('/^\s*(?:export\s+)?(\w+)\s*=/m', $content, $matches);
89+
90+
if (!empty($matches[1])) {
91+
$vars = array_unique($matches[1]);
92+
}
93+
94+
return $vars;
95+
}
96+
}

0 commit comments

Comments
 (0)