Repository: JsonMapper/JsonMapper (php)

Issue:
[Feature]: Correctly use list<class-string> from docblocks
### Description

When using JsonMapper on something like
```php
/** @var array<int, MyClass> */
public array $objects;
```
It is unable to parse the information to something that can be mapped.

### Desired solution

Since JsonMapper should adjust to the code instead of the otherway around the `list<MyClass>` should be parsed just like `array<int, MyClass>`

### Alternatives

_No response_

Pull request description:
Add list and array<> support in docblocks
| Q             | A                                                                                  |
|---------------|------------------------------------------------------------------------------------|
| Branch?       | develop |
| Bug fix?      | no                                                                             |
| New feature?  | yes                                                                             |
| Deprecations? | no                                                                             |
| Tickets       | Fix #192 |
| License       | MIT                                                                                |
| Changelog     | Add support for list<T> and array<TKey, TValue> from DocBlocks |
| Doc PR        | JsonMapper/jsonmapper.github.io#43 |

This adds support for the `list<T>` and `array<TKey, TValue>`


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03ab57a..834f2da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 ### Added
 - Include PHP 8.4 in build matrix [PR#187](https://github.com/JsonMapper/JsonMapper/pull/187)
+- Add support for list<T> and array<TKey, TValue> from DocBlocks [PR#193](https://github.com/JsonMapper/JsonMapper/pull/193)
 
 ## [2.22.3] - 2025-02-03
 ### Fixed
diff --git a/src/Middleware/DocBlockAnnotations.php b/src/Middleware/DocBlockAnnotations.php
index 45031ba..70e4d45 100644
--- a/src/Middleware/DocBlockAnnotations.php
+++ b/src/Middleware/DocBlockAnnotations.php
@@ -15,7 +15,7 @@ use Psr\SimpleCache\CacheInterface;
 
 class DocBlockAnnotations extends AbstractMiddleware
 {
-    private const DOC_BLOCK_REGEX = '/@(?P<name>[A-Za-z_-]+)[ \t]+(?P<value>[\w\[\]\\\\|]*).*$/m';
+    private const DOC_BLOCK_REGEX = '/@(?P<name>[A-Za-z_-]+)[ \t]+(?P<value>(?:[\w\[\]\\\\|<>]+(?:,\s*)?)*).*$/m';
 
     /** @var CacheInterface */
     private $cache;
@@ -79,21 +79,15 @@ class DocBlockAnnotations extends AbstractMiddleware
 
             foreach ($types as $type) {
                 $type = \trim($type);
-                $isAnArrayType = \substr($type, -2) === '[]';
+                $isAnArrayType = $this->isArrayType($type);
 
                 if (! $isAnArrayType) {
                     $builder->addType($type, ArrayInformation::notAnArray());
                     continue;
                 }
 
-                $initialBracketPosition = strpos($type, '[');
-                $dimensions = substr_count($type, '[]');
-
-                if ($initialBracketPosition !== false) {
-                    $type = substr($type, 0, $initialBracketPosition);
-                }
-
-                $builder->addType($type, ArrayInformation::multiDimension($dimensions));
+                $arrayInformation = $this->determineArrayInformation($type);
+                $builder->addType($type, $arrayInformation);
             }
 
             $property = $builder->build();
@@ -105,7 +99,7 @@ class DocBlockAnnotations extends AbstractMiddleware
         return $intermediatePropertyMap;
     }
 
-    public static function parseDocBlockToAnnotationMap(string $docBlock): AnnotationMap
+    private static function parseDocBlockToAnnotationMap(string $docBlock): AnnotationMap
     {
         // Strip away the start "/**' and ending "*/"
         if (strpos($docBlock, '/**') === 0) {
@@ -129,7 +123,7 @@ class DocBlockAnnotations extends AbstractMiddleware
     }
 
     /** @return \ReflectionProperty[] */
-    public function getObjectPropertiesIncludingParents(ObjectWrapper $object): array
+    private function getObjectPropertiesIncludingParents(ObjectWrapper $object): array
     {
         $properties = [];
         $reflectionClass = $object->getReflectedObject();
@@ -138,4 +132,47 @@ class DocBlockAnnotations extends AbstractMiddleware
         } while ($reflectionClass = $reflectionClass->getParentClass());
         return $properties;
     }
+
+    private function isArrayType(string $type): bool
+    {
+        return \substr($type, -2) === '[]'
+            || \strpos($type, 'list<') === 0
+            || \strpos($type, 'array<') === 0;
+    }
+
+    private function determineArrayInformation(string &$type): ArrayInformation
+    {
+        $levels = 0;
+        while (true) {
+            if (substr($type, -2) === '[]') {
+                $levels++;
+                $type = \substr($type, 0, -2);
+
+                continue;
+            }
+
+            if (strpos($type, 'list<') === 0) {
+                $levels++;
+                $type = \substr($type, 5, -1);
+
+                continue;
+            }
+
+            if (strpos($type, 'array<') === 0) {
+                $levels++;
+                $offset = 6;
+                $commaPosition = strpos($type, ',');
+                if (is_int($commaPosition)) {
+                    $offset = $commaPosition + 1;
+                }
+                $type = \trim(\substr($type, $offset, -1));
+
+                continue;
+            }
+
+            break;
+        }
+
+        return $levels === 0 ? ArrayInformation::notAnArray() : ArrayInformation::multiDimension($levels);
+    }
 }


Test patch:
diff --git a/tests/Implementation/ArrayOfSimpleObjects.php b/tests/Implementation/ArrayOfSimpleObjects.php
new file mode 100644
index 0000000..834e36e
--- /dev/null
+++ b/tests/Implementation/ArrayOfSimpleObjects.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace JsonMapper\Tests\Implementation;
+
+class ArrayOfSimpleObjects
+{
+    /** @var array<int, SimpleObject> */
+    public $objects = [];
+}
diff --git a/tests/Implementation/ListOfSimpleObjects.php b/tests/Implementation/ListOfSimpleObjects.php
new file mode 100644
index 0000000..cb7494a
--- /dev/null
+++ b/tests/Implementation/ListOfSimpleObjects.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace JsonMapper\Tests\Implementation;
+
+class ListOfSimpleObjects
+{
+    /** @var list<SimpleObject> */
+    public $objects = [];
+}
diff --git a/tests/Integration/FeatureSupportsMappingToArrayTypesTest.php b/tests/Integration/FeatureSupportsMappingToArrayTypesTest.php
index 4c974b6..ea7df7e 100644
--- a/tests/Integration/FeatureSupportsMappingToArrayTypesTest.php
+++ b/tests/Integration/FeatureSupportsMappingToArrayTypesTest.php
@@ -5,7 +5,9 @@ declare(strict_types=1);
 namespace JsonMapper\Tests\Integration;
 
 use JsonMapper\JsonMapperFactory;
+use JsonMapper\Tests\Implementation\ArrayOfSimpleObjects;
 use JsonMapper\Tests\Implementation\ComplexObject;
+use JsonMapper\Tests\Implementation\ListOfSimpleObjects;
 use JsonMapper\Tests\Implementation\Popo;
 use JsonMapper\Tests\Implementation\SimpleObject;
 use PHPUnit\Framework\TestCase;
@@ -72,4 +74,40 @@ class FeatureSupportsMappingToArrayTypesTest extends TestCase
         // Assert
         self::assertSame(['one' => __METHOD__, 'two' => __CLASS__], $object->notes);
     }
+
+    public function testItHandlesPropertyDocumentedAsListOfObjects(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $json = json_encode((object) ['objects' => [(object) ['name' => 'ONE'], (object) ['name' => 'TWO']]]);
+
+        // Act
+        $object = $mapper->mapToClassFromString($json, ListOfSimpleObjects::class);
+
+        // Assert
+        $result = new ListOfSimpleObjects();
+        $result->objects = [
+            new SimpleObject('ONE'),
+            new SimpleObject('TWO'),
+        ];
+        self::assertEquals($result, $object);
+    }
+
+    public function testItHandlesPropertyDocumentedAsArrayGtLtOfObjects(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $json = json_encode((object) ['objects' => [(object) ['name' => 'ONE'], (object) ['name' => 'TWO']]]);
+
+        // Act
+        $object = $mapper->mapToClassFromString($json, ArrayOfSimpleObjects::class);
+
+        // Assert
+        $result = new ArrayOfSimpleObjects();
+        $result->objects = [
+            new SimpleObject('ONE'),
+            new SimpleObject('TWO'),
+        ];
+        self::assertEquals($result, $object);
+    }
 }
diff --git a/tests/Unit/Middleware/DocBlockAnnotationsTest.php b/tests/Unit/Middleware/DocBlockAnnotationsTest.php
index 29f674d..557898b 100644
--- a/tests/Unit/Middleware/DocBlockAnnotationsTest.php
+++ b/tests/Unit/Middleware/DocBlockAnnotationsTest.php
@@ -244,4 +244,48 @@ class DocBlockAnnotationsTest extends TestCase
             ->hasVisibility(Visibility::PUBLIC())
             ->isNotNullable();
     }
+
+    /**
+     * @covers \JsonMapper\Middleware\DocBlockAnnotations
+     */
+    public function testTypeIsCorrectlyCalculatedForList(): void
+    {
+        $middleware = new DocBlockAnnotations(new NullCache());
+        $object = new class {
+            /** @var list<int> */
+            public $listOfNumbers;
+        };
+        $propertyMap = new PropertyMap();
+        $jsonMapper = $this->createMock(JsonMapperInterface::class);
+
+        $middleware->handle(new \stdClass(), new ObjectWrapper($object), $propertyMap, $jsonMapper);
+
+        self::assertTrue($propertyMap->hasProperty('listOfNumbers'));
+        $this->assertThatProperty($propertyMap->getProperty('listOfNumbers'))
+            ->onlyHasType('int', ArrayInformation::singleDimension())
+            ->hasVisibility(Visibility::PUBLIC())
+            ->isNotNullable();
+    }
+
+    /**
+     * @covers \JsonMapper\Middleware\DocBlockAnnotations
+     */
+    public function testTypeIsCorrectlyCalculatedForArrayLtGt(): void
+    {
+        $middleware = new DocBlockAnnotations(new NullCache());
+        $object = new class {
+            /** @var array<int, string> */
+            public $listOfWords;
+        };
+        $propertyMap = new PropertyMap();
+        $jsonMapper = $this->createMock(JsonMapperInterface::class);
+
+        $middleware->handle(new \stdClass(), new ObjectWrapper($object), $propertyMap, $jsonMapper);
+
+        self::assertTrue($propertyMap->hasProperty('listOfWords'));
+        $this->assertThatProperty($propertyMap->getProperty('listOfWords'))
+            ->onlyHasType('string', ArrayInformation::singleDimension())
+            ->hasVisibility(Visibility::PUBLIC())
+            ->isNotNullable();
+    }
 }