Repository: vimeo/psalm (PHP)

Issue:
Loop: uppercase literal strings inferred as non-empty-lowercase-string when combined with numeric-strings
https://psalm.dev/r/5dda068487

When a string is concat with int/numeric-string, psalm discards any literal non-lowercase-strings and just assumes they're all lowercase.

When using "normal" (non-numeric) strings it works correctly though (as the lower example shows)


Discussion: I found these snippets:
<details>
<summary>https://psalm.dev/r/5dda068487</summary>

```php
<?php

/**
 * @return int[]
 */
function getIntArray() {}

$final = array();
foreach ( getIntArray() as $id ) {
 	$final[] = 'TEXT';
    $final[] = 'some_' . $id;
}

/** @psalm-trace $final */;

/**
 * @return string[]
 */
function getStringArray() {}

$final = array();
foreach ( getStringArray() as $id ) {
 	$final[] = 'TEXT';
    $final[] = 'some_' . $id;
}

/** @psalm-trace $final */;
```

```
Psalm output (using commit 2039667):

ERROR: InvalidReturnType - 4:12 - No return statements were found for method getIntArray but return type 'array<array-key, int>' was expected

INFO: Trace - 14:27 - $final: list<non-empty-lowercase-string>

ERROR: InvalidReturnType - 17:12 - No return statements were found for method getStringArray but return type 'array<array-key, string>' was expected

INFO: Trace - 27:27 - $final: list<non-empty-string>
```
</details>




Fix (patch):
diff --git a/src/Psalm/Internal/Type/TypeCombiner.php b/src/Psalm/Internal/Type/TypeCombiner.php
index e21d41b0559..099ae82c7fc 100644
--- a/src/Psalm/Internal/Type/TypeCombiner.php
+++ b/src/Psalm/Internal/Type/TypeCombiner.php
@@ -1047,12 +1047,19 @@ private static function scrapeStringProperties(
                     && strtolower($type->value) === $type->value
                 ) {
                     // do nothing
+                } elseif (isset($combination->value_types['string'])
+                    && $combination->value_types['string'] instanceof TNonFalsyString
+                    && $type->value
+                ) {
+                    // do nothing
+                } elseif (isset($combination->value_types['string'])
+                    && $combination->value_types['string'] instanceof TNonFalsyString
+                    && $type->value === '0'
+                ) {
+                    $combination->value_types['string'] = new TNonEmptyString();
                 } elseif (isset($combination->value_types['string'])
                     && $combination->value_types['string'] instanceof TNonEmptyString
-                    && ($combination->value_types['string'] instanceof TNonFalsyString
-                        ? $type->value
-                        : $type->value !== ''
-                    )
+                    && $type->value !== ''
                 ) {
                     // do nothing
                 } else {
@@ -1103,18 +1110,53 @@ private static function scrapeStringProperties(
                         } else {
                             $combination->value_types['string'] = $type;
                         }
+                    } elseif ($type instanceof TNonFalsyString) {
+                        $has_empty_string = false;
+                        $has_falsy_string = false;
+
+                        foreach ($combination->strings as $string_type) {
+                            if ($string_type->value === '') {
+                                $has_empty_string = true;
+                                $has_falsy_string = true;
+                                break;
+                            }
+
+                            if ($string_type->value === '0') {
+                                $has_falsy_string = true;
+                            }
+                        }
+
+                        if ($has_empty_string) {
+                            $combination->value_types['string'] = new TString();
+                        } elseif ($has_falsy_string) {
+                            $combination->value_types['string'] = new TNonEmptyString();
+                        } else {
+                            $combination->value_types['string'] = $type;
+                        }
                     } elseif ($type instanceof TNonEmptyString) {
                         $has_empty_string = false;
 
                         foreach ($combination->strings as $string_type) {
-                            if (!$string_type->value) {
+                            if ($string_type->value === '') {
                                 $has_empty_string = true;
                                 break;
                             }
                         }
 
+                        $has_non_lowercase_string = false;
+                        if ($type instanceof TNonEmptyLowercaseString) {
+                            foreach ($combination->strings as $string_type) {
+                                if (strtolower($string_type->value) !== $string_type->value) {
+                                    $has_non_lowercase_string = true;
+                                    break;
+                                }
+                            }
+                        }
+
                         if ($has_empty_string) {
                             $combination->value_types['string'] = new TString();
+                        } elseif ($has_non_lowercase_string && get_class($type) !== TNonEmptyString::class) {
+                            $combination->value_types['string'] = new TNonEmptyString();
                         } else {
                             $combination->value_types['string'] = $type;
                         }