Repository: codeigniter4/CodeIgniter4 (PHP)

Issue:
Bug: Parser - Equal key name replace conflict
### PHP Version

8.3

### CodeIgniter4 Version

4.5.5

### CodeIgniter4 Installation Method

Composer (using `codeigniter4/appstarter`)

### Which operating systems have you tested for this bug?

macOS

### Which server did you use?

apache

### Database

MySQL 8.3.0

### What happened?

Using the same key name in an array although on different levels, leads to a premature substitution of `{key}`, when that `{key}` is referring in a nested one on a loop substitution.

(Based on the Steps to Reproduce section)

With the current Parser behaviour, both ```{id}``` vars will be replaced with their respective value but given that the second ```{id}``` is inside ```{blog_entries}```, by 'blindly' replacing all instances of ```{variable}```, ```{blog_entries}{id}{/blog_entries}``` will turn into ```{blog_entries}1{/blog_entries}``` which will fail to replace the ```{blog_entries}{id}{/blog_entries}``` with the proper data when it reaches to the blog_entries array.


### Steps to Reproduce

```
$data = [
    'id'   => 'id_1',
    'blog_title'   => 'My Blog Title',
    'blog_heading' => 'My Blog Heading',
    'blog_entries' => [
        ['id' => 'sub_id_1', 'title' => 'Title 1', 'body' => 'Body 1'],
        ['id' => 'sub_id_2', 'title' => 'Title 2', 'body' => 'Body 2'],
        ['id' => 'sub_id_3', 'title' => 'Title 3', 'body' => 'Body 3'],
        ['id' => 'sub_id_4', 'title' => 'Title 4', 'body' => 'Body 4'],
        ['id' => 'sub_id_5', 'title' => 'Title 5', 'body' => 'Body 5']
    ]
];

$template = <<<EOD
    ID: {id}
    title: {blog_title}
    Blog Entries:
    {blog_entries}
    {id} - {title} - {body}
{/blog_entries}
EOD;

$parser = service('parser');

print $parser->setData($data)->renderString($template);
```

Returns:

```
ID: id_1
title: My Blog Title Blog 
Entries: 
id_1 - Title 1 - Body 1
id_1 - Title 2 - Body 2
id_1 - Title 3 - Body 3
id_1 - Title 4 - Body 4
id_1 - Title 5 - Body 5
```

Going a level down

```
$data = [
    'id'   => 'id_1',
    'blog_title'   => 'My Blog Title',
    'blog_heading' => 'My Blog Heading',
    'blog_entries' => [
        ['items' =>
            [
                ['id' => 'sub_id_1', 'title' => 'Title 1', 'body' => 'Body 1'],
                ['id' => 'sub_id_2', 'title' => 'Title 2', 'body' => 'Body 2'],
                ['id' => 'sub_id_3', 'title' => 'Title 3', 'body' => 'Body 3'],
                ['id' => 'sub_id_4', 'title' => 'Title 4', 'body' => 'Body 4'],
                ['id' => 'sub_id_5', 'title' => 'Title 5', 'body' => 'Body 5']
            ]
        ]
    ]
];

$template = <<<EOD
  ID: {id}
  title: {blog_title}
  Blog Entries:
  {blog_entries}
{items}
  {id} - {title} - {body}
{/items}
{/blog_entries}
EOD;

$parser = service('parser');

echo $parser->setData($data)->renderString($template);
```

Returns:

```
ID: id_1
title: My Blog Title
Blog Entries: 
{items} id_1 - {title} - {body} {/items}
```

And it fails to properly replace the rest of the data.


### Expected Output

```
ID: id_1
title: My Blog Title
Blog Entries: 
sub_id_1 - Title 1 - Body 1
sub_id_2 - Title 2 - Body 2
sub_id_3 - Title 3 - Body 3
sub_id_4 - Title 4 - Body 4
sub_id_5 - Title 5 - Body 5
```

### Anything else?

This behaviour seems to stem from how the ```Parser::parse()``` function acts. From my understanding, when the parsing occurs the method loops over the data passed to it and if the data to be replaced isn't an array (Pair) then, it immediately replaces the content (Single). Which means, if there is multiple keys with the same identifier down the line, they will get replaced all at once, breaking further processing specially when using Pairs like ```{blog_entries}{items}{id}{items}{/blog_entries}```.

I propose a [solution](https://github.com/cosdiabos/CodeIgniter4/commit/2dbcac8725f9b03774d798508e6e8a0ed3476ed8) in which the return of ```parsePair``` and ```parseSingle``` are stored independently, then merged together (Pairs with Single), and finally the replacement loop happens. With this approach, the replacement happens from the Pairs to Single and the output is as expected since ```{blog_entries}{items}{id}{items}{/blog_entries}``` will be ```{blog_entries}{items}sub_id_1{items}{/blog_entries}``` before the top ```{id}``` replace is triggered.


Discussion:
Confirmed. Thank you for reporting it. 

Feel free to send a PR.
PR sent!

Thanks!



Patch:
diff --git a/system/View/Parser.php b/system/View/Parser.php
index 6600b0178369..8a26cf825d3f 100644
--- a/system/View/Parser.php
+++ b/system/View/Parser.php
@@ -254,20 +254,39 @@ protected function parse(string $template, array $data = [], ?array $options = n
         // it can potentially modify any template between its tags.
         $template = $this->parsePlugins($template);
 
-        // loop over the data variables, replacing
-        // the content as we go.
+        // Parse stack for each parse type (Single and Pairs)
+        $replaceSingleStack = [];
+        $replacePairsStack  = [];
+
+        // loop over the data variables, saving regex and data
+        // for later replacement.
         foreach ($data as $key => $val) {
             $escape = true;
 
             if (is_array($val)) {
-                $escape  = false;
-                $replace = $this->parsePair($key, $val, $template);
+                $escape              = false;
+                $replacePairsStack[] = [
+                    'replace' => $this->parsePair($key, $val, $template),
+                    'escape'  => $escape,
+                ];
             } else {
-                $replace = $this->parseSingle($key, (string) $val);
+                $replaceSingleStack[] = [
+                    'replace' => $this->parseSingle($key, (string) $val),
+                    'escape'  => $escape,
+                ];
             }
+        }
+
+        // Merge both stacks, pairs first + single stacks
+        // This allows for nested data with the same key to be replaced properly
+        $replace = array_merge($replacePairsStack, $replaceSingleStack);
 
-            foreach ($replace as $pattern => $content) {
-                $template = $this->replaceSingle($pattern, $content, $template, $escape);
+        // Loop over each replace array item which
+        // holds all the data to be replaced
+        foreach ($replace as $replaceItem) {
+            // Loop over the actual data to be replaced
+            foreach ($replaceItem['replace'] as $pattern => $content) {
+                $template = $this->replaceSingle($pattern, $content, $template, $replaceItem['escape']);
             }
         }
 
diff --git a/user_guide_src/source/changelogs/v4.5.6.rst b/user_guide_src/source/changelogs/v4.5.6.rst
index b982f54b38f7..c2f396627442 100644
--- a/user_guide_src/source/changelogs/v4.5.6.rst
+++ b/user_guide_src/source/changelogs/v4.5.6.rst
@@ -33,6 +33,8 @@ Bugs Fixed
 
 - **Validation:** Fixed the `getValidated()` method that did not return valid data when validation rules used multiple asterisks.
 
+- **Parser:** Fixed bug that caused equal key names to be replaced by the key name defined first.
+
 See the repo's
 `CHANGELOG.md <https://github.com/codeigniter4/CodeIgniter4/blob/develop/CHANGELOG.md>`_
 for a complete list of bugs fixed.


Test patch:
diff --git a/tests/system/View/ParserTest.php b/tests/system/View/ParserTest.php
index 9a9f1fd8d0ac..b3c99a2872ce 100644
--- a/tests/system/View/ParserTest.php
+++ b/tests/system/View/ParserTest.php
@@ -1061,4 +1061,44 @@ public function testChangeConditionalDelimitersWorkWithJavaScriptCode(): void
             EOL;
         $this->assertSame($expected, $this->parser->renderString($template));
     }
+
+    /**
+     * @see https://github.com/codeigniter4/CodeIgniter4/issues/9245
+     */
+    public function testParseSameArrayKeyName(): void
+    {
+        $data = [
+            'type'   => 'Super Powers',
+            'powers' => [
+                [
+                    'type' => 'invisibility',
+                ],
+            ],
+        ];
+
+        $template = '{type} like {powers}{type}{/powers}';
+
+        $this->parser->setData($data);
+        $this->assertSame('Super Powers like invisibility', $this->parser->renderString($template));
+    }
+
+    public function testParseSameArrayKeyNameNested(): void
+    {
+        $data = [
+            'title'   => 'My title',
+            'similar' => [
+                ['items' => [
+                    [
+                        'title' => 'My similar title',
+                    ],
+                ],
+                ],
+            ],
+        ];
+
+        $template = '{title} with similar item {similar}{items}{title}{/items}{/similar}';
+
+        $this->parser->setData($data);
+        $this->assertSame('My title with similar item My similar title', $this->parser->renderString($template));
+    }
 }