Repository: phpactor/phpactor (php)

Issue:
Union docblock
The docblock below is generated:

```
    /**
     * @return MissingType|Type|UnionType
     */
    public static function fromTypes(Type ...$types): Type
```

- If all the union members are instances of the type then it should not generate the docblock

Pull request description:
Closure docblock
Fixes #1474 and:

- Supports adding closure docblocks
- Fixes union merging (union + union = union, not union(union))
- ...

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c57e12b4..5d94aa7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ Bug fixes:
 
 Improvements:
 
+  - [log] Include a channel prefxi in log messages
   - [wr] require `ext-pnctl` (language server would crash otherwise)
   - [wr] handle static properly #967
   - [ls] include list of diagnostic providers in status report
diff --git a/lib/CodeTransform/Adapter/WorseReflection/Transformer/UpdateDocblockTransformer.php b/lib/CodeTransform/Adapter/WorseReflection/Transformer/UpdateDocblockTransformer.php
index 659c4cbc..086d4bf6 100644
--- a/lib/CodeTransform/Adapter/WorseReflection/Transformer/UpdateDocblockTransformer.php
+++ b/lib/CodeTransform/Adapter/WorseReflection/Transformer/UpdateDocblockTransformer.php
@@ -42,13 +42,18 @@ class UpdateDocblockTransformer implements Transformer
         foreach ($methods as $method) {
             $classBuilder = $builder->class($method->class()->name()->short());
             $methodBuilder = $classBuilder->method($method->name());
-            $replacement = $method->frame()->returnType()->toLocalType($method->scope())->generalize();
+            $replacement = $method->frame()->returnType();
+            $localReplacement = $replacement->toLocalType($method->scope())->generalize();
+
+            foreach ($replacement->classTypes() as $classType) {
+                $builder->use($classType->__toString());
+            }
 
             if (!$method->docblock()->isDefined()) {
                 $methodBuilder->docblock("\n\n".$this->format->indent(
                     <<<EOT
                         /**
-                         * @return {$replacement->__toString()}
+                         * @return {$localReplacement->__toString()}
                          */
                         EOT
                 ,
@@ -103,26 +108,34 @@ class UpdateDocblockTransformer implements Transformer
                     continue;
                 }
 
+                // do not try it for overriden methods
+                if ($method->original()->declaringClass()->name() != $class->name()) {
+                    continue;
+                }
+
                 // it's void
                 if (false === $actualReturnType->isDefined()) {
                     continue;
                 }
 
-                if (!$claimedReturnType->isClass() && !$claimedReturnType->isArray()) {
+                if (
+                    !$claimedReturnType->isClass() && !$claimedReturnType->isArray() && !$claimedReturnType->isClosure()
+                ) {
                     continue;
                 }
 
-                if ($actualReturnType->instanceof($claimedReturnType)->isTrue()) {
-                    if (!$actualReturnType instanceof GenericClassType) {
-                        continue;
-                    }
+                if ($actualReturnType->isClosure()) {
+                    $methods[] = $method;
+                    continue;
+                }
+
+                if ($claimedReturnType->isClass() && !$actualReturnType instanceof GenericClassType) {
+                    continue;
                 }
 
                 // the docblock matches the generalized return type
                 // it's OK
-                if (
-                    $claimedReturnType->equals($actualReturnType)
-                ) {
+                if ($claimedReturnType->equals($actualReturnType)) {
                     continue;
                 }
         
diff --git a/lib/WorseReflection/Core/Inference/FunctionStub/ArrayMapStub.php b/lib/WorseReflection/Core/Inference/FunctionStub/ArrayMapStub.php
index 31408845..214b8382 100644
--- a/lib/WorseReflection/Core/Inference/FunctionStub/ArrayMapStub.php
+++ b/lib/WorseReflection/Core/Inference/FunctionStub/ArrayMapStub.php
@@ -9,6 +9,7 @@ use Phpactor\WorseReflection\Core\Inference\FunctionStub;
 use Phpactor\WorseReflection\Core\Inference\NodeContext;
 use Phpactor\WorseReflection\Core\Inference\NodeContextResolver;
 use Phpactor\WorseReflection\Core\TypeFactory;
+use Phpactor\WorseReflection\Core\Type\ClosureType;
 
 class ArrayMapStub implements FunctionStub
 {
@@ -31,6 +32,11 @@ class ArrayMapStub implements FunctionStub
             return $context;
         }
 
-        return $context->withType(TypeFactory::array($args[0]->type()));
+        $closureType = $args[0]->type();
+        if (!$closureType instanceof ClosureType) {
+            return $context;
+        }
+
+        return $context->withType(TypeFactory::array($closureType->returnType));
     }
 }
diff --git a/lib/WorseReflection/Core/Inference/Resolver/AnonymousFunctionCreationExpressionResolver.php b/lib/WorseReflection/Core/Inference/Resolver/AnonymousFunctionCreationExpressionResolver.php
index 48cb3aa3..73b38fc7 100644
--- a/lib/WorseReflection/Core/Inference/Resolver/AnonymousFunctionCreationExpressionResolver.php
+++ b/lib/WorseReflection/Core/Inference/Resolver/AnonymousFunctionCreationExpressionResolver.php
@@ -4,11 +4,13 @@ namespace Phpactor\WorseReflection\Core\Inference\Resolver;
 
 use Microsoft\PhpParser\Node;
 use Microsoft\PhpParser\Node\Expression\AnonymousFunctionCreationExpression;
+use Microsoft\PhpParser\Node\Parameter;
 use Phpactor\WorseReflection\Core\Inference\Frame;
 use Phpactor\WorseReflection\Core\Inference\NodeContext;
 use Phpactor\WorseReflection\Core\Inference\NodeContextFactory;
 use Phpactor\WorseReflection\Core\Inference\NodeContextResolver;
 use Phpactor\WorseReflection\Core\Inference\Resolver;
+use Phpactor\WorseReflection\Core\Type\ClosureType;
 use Phpactor\WorseReflection\Core\Util\NodeUtil;
 
 class AnonymousFunctionCreationExpressionResolver implements Resolver
@@ -22,6 +24,19 @@ class AnonymousFunctionCreationExpressionResolver implements Resolver
             $node->returnTypeList
         );
 
+        $args = [];
+        /** @phpstan-ignore-next-line [TR] No trust */
+        if ($node->parameters) {
+            foreach ($node->parameters->getChildNodes() as $parameter) {
+                if (!$parameter instanceof Parameter) {
+                    continue;
+                }
+                $args[] = $resolver->resolveNode($frame, $parameter)->type();
+            }
+        }
+
+        $type = new ClosureType($args, $type);
+
         return NodeContextFactory::create(
             $node->getText(),
             $node->getStartPosition(),
diff --git a/lib/WorseReflection/Core/Inference/Resolver/ArrowFunctionCreationExpressionResolver.php b/lib/WorseReflection/Core/Inference/Resolver/ArrowFunctionCreationExpressionResolver.php
index cc47e9b7..9069cf5e 100644
--- a/lib/WorseReflection/Core/Inference/Resolver/ArrowFunctionCreationExpressionResolver.php
+++ b/lib/WorseReflection/Core/Inference/Resolver/ArrowFunctionCreationExpressionResolver.php
@@ -4,11 +4,13 @@ namespace Phpactor\WorseReflection\Core\Inference\Resolver;
 
 use Microsoft\PhpParser\Node;
 use Microsoft\PhpParser\Node\Expression\ArrowFunctionCreationExpression;
+use Microsoft\PhpParser\Node\Parameter;
 use Phpactor\WorseReflection\Core\Inference\Frame;
 use Phpactor\WorseReflection\Core\Inference\NodeContext;
 use Phpactor\WorseReflection\Core\Inference\NodeContextFactory;
 use Phpactor\WorseReflection\Core\Inference\NodeContextResolver;
 use Phpactor\WorseReflection\Core\Inference\Resolver;
+use Phpactor\WorseReflection\Core\Type\ClosureType;
 use Phpactor\WorseReflection\Core\Util\NodeUtil;
 
 class ArrowFunctionCreationExpressionResolver implements Resolver
@@ -16,14 +18,25 @@ class ArrowFunctionCreationExpressionResolver implements Resolver
     public function resolve(NodeContextResolver $resolver, Frame $frame, Node $node): NodeContext
     {
         assert($node instanceof ArrowFunctionCreationExpression);
-        $type = NodeUtil::typeFromQualfiedNameLike(
+        $returnType = NodeUtil::typeFromQualfiedNameLike(
             $resolver->reflector(),
             $node,
             $node->returnTypeList
         );
 
-        if (!$type->isDefined()) {
-            $type = $resolver->resolveNode($frame, $node->resultExpression)->type()->generalize();
+        $args = [];
+        /** @phpstan-ignore-next-line [TR] No trust */
+        if ($node->parameters) {
+            foreach ($node->parameters->getChildNodes() as $parameter) {
+                if (!$parameter instanceof Parameter) {
+                    continue;
+                }
+                $args[] = $resolver->resolveNode($frame, $parameter)->type();
+            }
+        }
+
+        if (!$returnType->isDefined()) {
+            $returnType = $resolver->resolveNode($frame, $node->resultExpression)->type()->generalize();
         }
 
         return NodeContextFactory::create(
@@ -31,7 +44,7 @@ class ArrowFunctionCreationExpressionResolver implements Resolver
             $node->getStartPosition(),
             $node->getEndPosition(),
             [
-                'type' => $type,
+                'type' => new ClosureType($args, $returnType),
             ]
         );
     }
diff --git a/lib/WorseReflection/Core/Inference/Resolver/BinaryExpressionResolver.php b/lib/WorseReflection/Core/Inference/Resolver/BinaryExpressionResolver.php
index ff824c6a..5beb2beb 100644
--- a/lib/WorseReflection/Core/Inference/Resolver/BinaryExpressionResolver.php
+++ b/lib/WorseReflection/Core/Inference/Resolver/BinaryExpressionResolver.php
@@ -80,6 +80,9 @@ class BinaryExpressionResolver implements Resolver
         Type $right,
         int $operator
     ): Type {
+        if ($operator === TokenKind::QuestionQuestionToken) {
+            return $this->nullCoalesce($left, $right);
+        }
         if ($left instanceof Concatable) {
             switch ($operator) {
             case TokenKind::DotToken:
@@ -87,7 +90,6 @@ class BinaryExpressionResolver implements Resolver
                 return $left->concat($right);
             }
         }
-
         if ($left instanceof Comparable) {
             switch ($operator) {
             case TokenKind::EqualsEqualsEqualsToken:
@@ -231,4 +233,17 @@ class BinaryExpressionResolver implements Resolver
 
         return $context;
     }
+
+    private function nullCoalesce(Type $left, Type $right): Type
+    {
+        if ($left->isNullable()) {
+            return TypeFactory::union($left->stripNullable(), $right);
+        }
+
+        if (!$left->isNull()) {
+            return $left;
+        }
+
+        return $right;
+    }
 }
diff --git a/lib/WorseReflection/Core/Type.php b/lib/WorseReflection/Core/Type.php
index 218bb339..4c9de63e 100644
--- a/lib/WorseReflection/Core/Type.php
+++ b/lib/WorseReflection/Core/Type.php
@@ -6,6 +6,7 @@ use Closure;
 use Phpactor\WorseReflection\Core\Reflection\ReflectionScope;
 use Phpactor\WorseReflection\Core\Type\ArrayType;
 use Phpactor\WorseReflection\Core\Type\ClassType;
+use Phpactor\WorseReflection\Core\Type\ClosureType;
 use Phpactor\WorseReflection\Core\Type\Generalizable;
 use Phpactor\WorseReflection\Core\Type\MissingType;
 use Phpactor\WorseReflection\Core\Type\NullableType;
@@ -48,6 +49,11 @@ abstract class Type
         return $this instanceof ClassType;
     }
 
+    public function isClosure(): bool
+    {
+        return $this instanceof ClosureType;
+    }
+
     public function isArray(): bool
     {
         return $this instanceof ArrayType;
@@ -55,7 +61,7 @@ abstract class Type
 
     public function isNullable(): bool
     {
-        return $this instanceof NullableType;
+        return false;
     }
 
     public function addToUnion(Type $type): UnionType
@@ -99,7 +105,8 @@ abstract class Type
      */
     public function toLocalType(ReflectionScope $scope): self
     {
-        return $this->map(fn (Type $type) => $scope->resolveLocalType($type));
+        // TODO: do not modify type by reference
+        return $this->map(fn (Type $type) => $scope->resolveLocalType(clone $type));
     }
 
     public static function fromTypes(Type ...$types): Type
@@ -132,8 +139,18 @@ abstract class Type
         return Trinary::fromBoolean($type->equals($this));
     }
 
+    public function isNull(): bool
+    {
+        return false;
+    }
+
+    public function stripNullable(): Type
+    {
+        return $this;
+    }
+
     /**
-     * @param Closure(Type): self $mapper
+     * @param Closure(Type): Type $mapper
      */
     protected function map(Closure $mapper): Type
     {
diff --git a/lib/WorseReflection/Core/Type/ArrayType.php b/lib/WorseReflection/Core/Type/ArrayType.php
index 6bd2f4d4..d9557950 100644
--- a/lib/WorseReflection/Core/Type/ArrayType.php
+++ b/lib/WorseReflection/Core/Type/ArrayType.php
@@ -5,6 +5,7 @@ namespace Phpactor\WorseReflection\Core\Type;
 use Closure;
 use Phpactor\WorseReflection\Core\Trinary;
 use Phpactor\WorseReflection\Core\Type;
+use Phpactor\WorseReflection\Core\Types;
 
 class ArrayType extends Type implements IterableType
 {
@@ -56,6 +57,11 @@ class ArrayType extends Type implements IterableType
         return Trinary::fromBoolean($type instanceof ArrayType);
     }
 
+    public function toTypes(): Types
+    {
+        return new Types([$this->valueType]);
+    }
+
     protected function map(Closure $mapper): Type
     {
         return new self($this->keyType->map($mapper), $this->valueType->map($mapper));
diff --git a/lib/WorseReflection/Core/Type/NullType.php b/lib/WorseReflection/Core/Type/NullType.php
index 9389a383..b8e08121 100644
--- a/lib/WorseReflection/Core/Type/NullType.php
+++ b/lib/WorseReflection/Core/Type/NullType.php
@@ -21,4 +21,9 @@ final class NullType extends PrimitiveType
     {
         return Trinary::fromBoolean($type instanceof MixedType || $type instanceof NullType);
     }
+
+    public function isNull(): bool
+    {
+        return true;
+    }
 }
diff --git a/lib/WorseReflection/Core/Type/NullableType.php b/lib/WorseReflection/Core/Type/NullableType.php
index dd731709..750fda2b 100644
--- a/lib/WorseReflection/Core/Type/NullableType.php
+++ b/lib/WorseReflection/Core/Type/NullableType.php
@@ -38,4 +38,19 @@ class NullableType extends Type
     {
         return new Types([new NullType(), $this->type]);
     }
+
+    public function isNull(): bool
+    {
+        return true;
+    }
+
+    public function isNullable(): bool
+    {
+        return true;
+    }
+
+    public function stripNullable(): Type
+    {
+        return $this->type;
+    }
 }
diff --git a/lib/WorseReflection/Core/Type/UnionType.php b/lib/WorseReflection/Core/Type/UnionType.php
index ee8a12b4..4477696d 100644
--- a/lib/WorseReflection/Core/Type/UnionType.php
+++ b/lib/WorseReflection/Core/Type/UnionType.php
@@ -18,7 +18,21 @@ final class UnionType extends Type
     public function __construct(Type ...$types)
     {
         $unique = [];
+        $toMerge = [];
+        $hasNull = false;
         foreach ($types as $type) {
+            if ($type instanceof UnionType) {
+                foreach ($type->types as $utype) {
+                    $unique[$utype->__toString()] = $utype;
+                }
+                continue;
+            }
+            if ($type instanceof NullableType) {
+                $type = $type->type;
+                $null = TypeFactory::null();
+                $unique[$null->__toString()] = $null;
+            }
+
             $unique[$type->__toString()] = $type;
         }
         $this->types = array_values($unique);
@@ -69,6 +83,10 @@ final class UnionType extends Type
             return $type;
         }
 
+        if (count($this->types) === 2 && $this->isNullable()) {
+            return TypeFactory::nullable($this->stripNullable());
+        }
+
         return $this;
     }
 
@@ -160,6 +178,30 @@ final class UnionType extends Type
         return (new self(...array_merge($this->types, [$type])))->filter();
     }
 
+    public function isNull(): bool
+    {
+        $reduced = $this->reduce();
+        return $reduced instanceof NullType;
+    }
+
+    public function isNullable(): bool
+    {
+        foreach ($this->types as $type) {
+            if ($type->isNull()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function stripNullable(): Type
+    {
+        return (new self(...array_filter($this->types, function (Type $type) {
+            return !$type instanceof NullType;
+        })))->reduce();
+    }
+
     protected function map(Closure $mapper): Type
     {
         return new self(...array_map($mapper, $this->types));


Test patch:
diff --git a/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/CompleteConstructorTest.php b/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/CompleteConstructorTest.php
index b751adc6..999f4fe0 100644
--- a/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/CompleteConstructorTest.php
+++ b/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/CompleteConstructorTest.php
@@ -259,7 +259,7 @@ class CompleteConstructorTest extends WorseTestCase
                 class Foobar
                 {
                     /**
-                     * @var string|null
+                     * @var ?string
                      */
                     private $foo;
 
diff --git a/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/UpdateDocblockTransformerTest.php b/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/UpdateDocblockTransformerTest.php
index 5818cbf8..cd0a2c1d 100644
--- a/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/UpdateDocblockTransformerTest.php
+++ b/lib/CodeTransform/Tests/Adapter/WorseReflection/Transformer/UpdateDocblockTransformerTest.php
@@ -18,6 +18,10 @@ class UpdateDocblockTransformerTest extends WorseTestCase
     public function testTransform(string $example, string $expected): void
     {
         $source = SourceCode::fromString($example);
+        $this->workspace()->put(
+            'Example.php',
+            '<?php namespace Namespaced; class NsTest { /** @return Baz[] */public function bazes(): array {}} class Baz{}'
+        );
         $reflector = $this->reflectorForWorkspace($example);
         $transformer = $this->createTransformer($reflector);
         $transformed = $transformer->transform($source)->apply($source);
@@ -179,6 +183,44 @@ class UpdateDocblockTransformerTest extends WorseTestCase
                 EOT
         ];
 
+        yield 'permit wider return types in union' => [
+            <<<'EOT'
+                <?php
+
+                abstract class Foo {}
+                class ConcreteFoo extends Foo {}
+                class Baz extends Foo {}
+
+                class Foobar {
+                    public function baz(): Foo
+                    {
+                        if ($bar) {
+                            return new Baz();
+                        }
+                        return new ConcreteFoo();
+                    }
+                }
+                EOT
+            ,
+            <<<'EOT'
+                <?php
+
+                abstract class Foo {}
+                class ConcreteFoo extends Foo {}
+                class Baz extends Foo {}
+
+                class Foobar {
+                    public function baz(): Foo
+                    {
+                        if ($bar) {
+                            return new Baz();
+                        }
+                        return new ConcreteFoo();
+                    }
+                }
+                EOT
+        ];
+
         yield 'but adds generic types' => [
             <<<'EOT'
                 <?php
@@ -274,6 +316,105 @@ class UpdateDocblockTransformerTest extends WorseTestCase
                 }
                 EOT
         ];
+
+        yield 'adds docblock for closure' => [
+            <<<'EOT'
+                <?php
+
+                class Foobar {
+                    public function baz(): Closure
+                    {
+                        return function (string $foo): int {};
+                    }
+                }
+                EOT
+            ,
+            <<<'EOT'
+                <?php
+
+                class Foobar {
+
+                    /**
+                     * @return Closure(string): int
+                     */
+                    public function baz(): Closure
+                    {
+                        return function (string $foo): int {};
+                    }
+                }
+                EOT
+        ];
+
+        yield 'imports classes' => [
+            <<<'EOT'
+                <?php
+
+                use Namespaced\NsTest;
+
+                class Foobar {
+                    public function baz(): array
+                    {
+                        return (new NsTest())->bazes();
+                    }
+                }
+                EOT
+            ,
+            <<<'EOT'
+                <?php
+
+                use Namespaced\Baz;
+                use Namespaced\NsTest;
+
+                class Foobar {
+
+                    /**
+                     * @return Baz[]
+                     */
+                    public function baz(): array
+                    {
+                        return (new NsTest())->bazes();
+                    }
+                }
+                EOT
+        ];
+
+        yield 'inherited type' => [
+            <<<'EOT'
+                <?php
+
+                abstract class Foobag {
+                    /**
+                     * @return Baz[]
+                     */
+                    public function baz(): array {}
+                }
+
+                class Foobar extends Foobag {
+                    public function baz(): array
+                    {
+                        return [new Baz()];
+                    }
+                }
+                EOT
+            ,
+            <<<'EOT'
+                <?php
+
+                abstract class Foobag {
+                    /**
+                     * @return Baz[]
+                     */
+                    public function baz(): array {}
+                }
+
+                class Foobar extends Foobag {
+                    public function baz(): array
+                    {
+                        return [new Baz()];
+                    }
+                }
+                EOT
+        ];
     }
 
     /**
diff --git a/lib/WorseReflection/Tests/Inference/anonymous_function/as_closure.test b/lib/WorseReflection/Tests/Inference/anonymous_function/as_closure.test
new file mode 100644
index 00000000..253f73e3
--- /dev/null
+++ b/lib/WorseReflection/Tests/Inference/anonymous_function/as_closure.test
@@ -0,0 +1,3 @@
+<?php
+
+wrAssertType('Closure(): string', function (): string {});
diff --git a/lib/WorseReflection/Tests/Inference/anonymous_function/as_closure_with_args.test b/lib/WorseReflection/Tests/Inference/anonymous_function/as_closure_with_args.test
new file mode 100644
index 00000000..bb71a012
--- /dev/null
+++ b/lib/WorseReflection/Tests/Inference/anonymous_function/as_closure_with_args.test
@@ -0,0 +1,3 @@
+<?php
+
+wrAssertType('Closure(string,int): string', function (string $a, int $b): string {});
diff --git a/lib/WorseReflection/Tests/Inference/arrow_function/as_closure.test b/lib/WorseReflection/Tests/Inference/arrow_function/as_closure.test
new file mode 100644
index 00000000..bafb27a1
--- /dev/null
+++ b/lib/WorseReflection/Tests/Inference/arrow_function/as_closure.test
@@ -0,0 +1,3 @@
+<?php
+
+wrAssertType('Closure(): null', fn () => null);
diff --git a/lib/WorseReflection/Tests/Inference/arrow_function/as_closure_with_args.test b/lib/WorseReflection/Tests/Inference/arrow_function/as_closure_with_args.test
new file mode 100644
index 00000000..6243d338
--- /dev/null
+++ b/lib/WorseReflection/Tests/Inference/arrow_function/as_closure_with_args.test
@@ -0,0 +1,3 @@
+<?php
+
+wrAssertType('Closure(string,int): string', fn (string $a, int $b) => 'foo');
diff --git a/lib/WorseReflection/Tests/Inference/function/arrow_function.test b/lib/WorseReflection/Tests/Inference/arrow_function/parameter.test
similarity index 100%
rename from lib/WorseReflection/Tests/Inference/function/arrow_function.test
rename to lib/WorseReflection/Tests/Inference/arrow_function/parameter.test
diff --git a/lib/WorseReflection/Tests/Inference/function/arrow_function_2.test b/lib/WorseReflection/Tests/Inference/arrow_function/parameter2.test
similarity index 100%
rename from lib/WorseReflection/Tests/Inference/function/arrow_function_2.test
rename to lib/WorseReflection/Tests/Inference/arrow_function/parameter2.test
diff --git a/lib/WorseReflection/Tests/Inference/function/arrow_function_3.test b/lib/WorseReflection/Tests/Inference/arrow_function/parameter3.test
similarity index 100%
rename from lib/WorseReflection/Tests/Inference/function/arrow_function_3.test
rename to lib/WorseReflection/Tests/Inference/arrow_function/parameter3.test
diff --git a/lib/WorseReflection/Tests/Inference/combination/assert.test b/lib/WorseReflection/Tests/Inference/combination/assert.test
index 99180a43..b443dbed 100644
--- a/lib/WorseReflection/Tests/Inference/combination/assert.test
+++ b/lib/WorseReflection/Tests/Inference/combination/assert.test
@@ -5,7 +5,7 @@ function stringOrNull(): null|string {}
 function fooOrNull(): Foo|null {}
 
 $foo = stringOrNull();
-wrAssertType('null|string', $foo); 
+wrAssertType('?string', $foo); 
 
 // empty: 0|0|""|"0"|array{}|false|null
 //
diff --git a/lib/WorseReflection/Tests/Inference/function/array_map.test b/lib/WorseReflection/Tests/Inference/function/array_map.test
index 475e407a..6d33b6c9 100644
--- a/lib/WorseReflection/Tests/Inference/function/array_map.test
+++ b/lib/WorseReflection/Tests/Inference/function/array_map.test
@@ -2,12 +2,14 @@
 
 wrAssertType(
     'string[]',
-    array_map(fn () => 'hello', [10, 20, 30])
+    array_map(fn () => 'hello', [10, 20, 30]),
+    'arrow',
 );
 
 wrAssertType(
     'string[]',
     array_map(function (): string {
         return 'hello';
-    }, [10, 20, 30])
+    }, [10, 20, 30]),
+    'anonymous'
 );
diff --git a/lib/WorseReflection/Tests/Inference/null-coalesce/null-coalesce_null.test b/lib/WorseReflection/Tests/Inference/null-coalesce/null-coalesce_null.test
new file mode 100644
index 00000000..ae9dca23
--- /dev/null
+++ b/lib/WorseReflection/Tests/Inference/null-coalesce/null-coalesce_null.test
@@ -0,0 +1,7 @@
+<?php
+
+$foo = 'string';
+$bar = 123;
+$baz = $foo ?? $bar;
+
+wrAssertType('"string"', $baz, 'assumes left if type left is not null');
diff --git a/lib/WorseReflection/Tests/Inference/null-coalesce/null-coalesce_nullable.test b/lib/WorseReflection/Tests/Inference/null-coalesce/null-coalesce_nullable.test
new file mode 100644
index 00000000..53700eb8
--- /dev/null
+++ b/lib/WorseReflection/Tests/Inference/null-coalesce/null-coalesce_nullable.test
@@ -0,0 +1,9 @@
+<?php
+
+$bar = 123;
+/** @var null|string $nullable */
+$nullable;
+
+$baz = $nullable ?? $bar;
+
+wrAssertType('string|123', $baz, 'assumes union if left is nullable');
diff --git a/lib/WorseReflection/Tests/Unit/Core/Type/UnionTypeTest.php b/lib/WorseReflection/Tests/Unit/Core/Type/UnionTypeTest.php
index 188b9d67..5d875cb7 100644
--- a/lib/WorseReflection/Tests/Unit/Core/Type/UnionTypeTest.php
+++ b/lib/WorseReflection/Tests/Unit/Core/Type/UnionTypeTest.php
@@ -214,6 +214,37 @@ class UnionTypeTest extends TestCase
         )->__toString());
     }
 
+    public function testDedupesNullOnConstruct(): void
+    {
+        self::assertEquals('null|One|Two', TypeFactory::union(
+            TypeFactory::nullable(TypeFactory::class('One')),
+            TypeFactory::class('Two'),
+            TypeFactory::class('One'),
+            TypeFactory::null(),
+            TypeFactory::null(),
+        )->__toString());
+    }
+
+    public function testMergeUnionts(): void
+    {
+        self::assertEquals(
+            TypeFactory::union(
+                TypeFactory::class('Two'),
+                TypeFactory::class('One'),
+                TypeFactory::string(),
+                TypeFactory::int()
+            ),
+            TypeFactory::union(
+                TypeFactory::class('Two'),
+                TypeFactory::class('One'),
+                TypeFactory::union(
+                    TypeFactory::string(),
+                    TypeFactory::int()
+                )
+            )
+        );
+    }
+
     /**
      * @return Type[]
      */