Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@
use UnexpectedValueException;

use function array_filter;
use function array_key_last;
use function array_pop;
use function array_shift;
use function array_unshift;
use function assert;
use function count;
use function explode;
use function in_array;
use function is_int;
use function is_numeric;
use function str_contains;
use function strtolower;
Expand Down Expand Up @@ -635,7 +637,8 @@ public static function handleByRefArrayAdjustment(

foreach ($context->vars_in_scope[$var_id]->getAtomicTypes() as $array_atomic_type) {
if ($array_atomic_type instanceof TKeyedArray) {
if ($is_array_shift && $array_atomic_type->is_list
if ($is_array_shift
&& $array_atomic_type->is_list
&& !$context->inside_loop
) {
$array_properties = $array_atomic_type->properties;
Expand All @@ -650,18 +653,79 @@ public static function handleByRefArrayAdjustment(
$array_atomic_types []= $array_atomic_type->setProperties($array_properties);
}
continue;
} elseif (!$is_array_shift && $array_atomic_type->is_list
} elseif (!$is_array_shift
&& $array_atomic_type->is_list
&& !$array_atomic_type->fallback_params
&& !$context->inside_loop
) {
$array_properties = $array_atomic_type->properties;

array_pop($array_properties);
$last_key = array_key_last($array_properties);
$last_value = $array_properties[$last_key];

if (!$array_properties) {
$array_atomic_types []= Type::getEmptyArrayAtomic();
if (!$last_value->possibly_undefined) {
// Last key is not optional - just remove it
array_pop($array_properties);

if (!$array_properties) {
$array_atomic_types[] = Type::getEmptyArrayAtomic();
} else {
$array_atomic_types[] = $array_atomic_type->setProperties($array_properties);
}
} else {
$array_atomic_types []= $array_atomic_type->setProperties($array_properties);
// Last key is optional - remove it and make the 2nd to last optional
array_pop($array_properties);

if (!$array_properties) {
$array_atomic_types[] = Type::getEmptyArrayAtomic();
} else {
// Get the new last key (was 2nd to last)
$new_last_key = array_key_last($array_properties);
$new_last_value = $array_properties[$new_last_key];

if (!$new_last_value->possibly_undefined) {
$array_properties[$new_last_key] = $new_last_value->setPossiblyUndefined(true);
} elseif (is_int($new_last_key)) {
// It's already optional, find the closest non-optional key and make it optional
for ($i = $new_last_key - 1; $i >= 0; $i--) {
if (!isset($array_properties[$i])
|| $array_properties[$i]->possibly_undefined) {
continue;
}

$array_properties[$i] = $array_properties[$i]->setPossiblyUndefined(true);
break;
}
}

$array_atomic_types[] = $array_atomic_type->setProperties($array_properties);
}
}
continue;
} elseif (!$is_array_shift && !$array_atomic_type->is_list
&& !$context->inside_loop
) {
// Regular keyed array
$array_properties = $array_atomic_type->properties;

$all_optional = true;
foreach ($array_properties as $property) {
if (!$property->possibly_undefined) {
$all_optional = false;
break;
}
}

if (!$all_optional) {
// Make all keys optional
$new_properties = [];
foreach ($array_properties as $key => $property) {
$new_properties[$key] = $property->setPossiblyUndefined(true);
}
$array_atomic_types[] = $array_atomic_type->setProperties($new_properties);
} else {
// All keys are already optional - keep the keyed array structure
$array_atomic_types[] = $array_atomic_type;
}
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
use Psalm\Type\Atomic\TNull;
use Psalm\Type\Union;

use function array_key_last;
use function is_int;

/**
* @internal
*/
Expand Down Expand Up @@ -68,13 +71,78 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev
$nullable = true;
}
} else {
// special case where we know the type of the first element
// special case for array_shift with lists
if ($function_id === 'array_shift' && $first_arg_array->is_list && isset($first_arg_array->properties[0])) {
$value_type = $first_arg_array->properties[0];
if ($value_type->possibly_undefined) {
$value_type = $value_type->setPossiblyUndefined(false);
$nullable = true;
}
} elseif ($function_id === 'array_pop' && $first_arg_array->is_list) {
// Handle keyed list
$properties = $first_arg_array->properties;

$last_key = array_key_last($properties);
$last_value = $properties[$last_key];

if (!$last_value->possibly_undefined) {
// Last key is not optional - return its type
$value_type = $last_value;
} else {
// Last key is optional
// Find the last non-optional key and collect all types from there onwards
$last_non_optional_key = null;
if (is_int($last_key)) {
for ($i = $last_key - 1; $i >= 0; $i--) {
if (isset($properties[$i]) && !$properties[$i]->possibly_undefined) {
$last_non_optional_key = $i;
break;
}
}
}

// Collect all types from last non-optional onwards (or all if none are non-optional)
$types_to_combine = [];
$start_index = $last_non_optional_key ?? 0;

if (is_int($last_key) && is_int($start_index)) {
for ($i = $start_index; $i <= $last_key; $i++) {
if (isset($properties[$i])) {
$types_to_combine[] = $properties[$i]->setPossiblyUndefined(false);
}
}
}

if ($types_to_combine !== []) {
$value_type = Type::combineUnionTypeArray($types_to_combine, null);
} else {
return Type::getNull();
}

if ($last_non_optional_key === null) {
// All keys are optional - can also be null
$nullable = true;
}
}
} elseif ($function_id === 'array_pop' && !$first_arg_array->is_list) {
// Regular keyed array (non-list)
$all_optional = true;
foreach ($first_arg_array->properties as $property) {
if (!$property->possibly_undefined) {
$all_optional = false;
break;
}
}

if ($all_optional) {
$nullable = true;
}

$value_type = $first_arg_array->getGenericValueType();

if (!$first_arg_array->isNonEmpty()) {
$nullable = true;
}
} else {
$value_type = $first_arg_array->getGenericValueType();

Expand Down
124 changes: 124 additions & 0 deletions tests/ArrayFunctionCallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,130 @@ function foo(array $arr) {
'$b' => 'int',
],
],
'arrayPopListSingleRequired' => [
'code' => '<?php
/** @var list{0: "a"} */
$array = ["a"];
$popped1 = array_pop($array);
$popped2 = array_pop($array);',
'assertions' => [
'$popped1===' => "'a'",
'$array===' => 'array<never, never>',
'$popped2===' => 'null',
],
],
'arrayPopListSingleOptional' => [
'code' => '<?php
/** @var list{0?: "a"} */
$array = ["a"];
$popped1 = array_pop($array);
$popped2 = array_pop($array);',
'assertions' => [
'$popped1===' => "'a'|null",
'$array===' => 'array<never, never>',
'$popped2===' => 'null',
],
],
'arrayPopListTwoRequired' => [
'code' => '<?php
/** @var list{0: "a", 1: "b"} */
$array = ["a", "b"];
$popped1 = array_pop($array);
$array2 = $array;
$popped2 = array_pop($array);
$popped3 = array_pop($array);',
'assertions' => [
'$popped1===' => "'b'",
'$array2===' => "list{'a'}",
'$popped2===' => "'a'",
'$array===' => 'array<never, never>',
'$popped3===' => 'null',
],
],
'arrayPopListRequiredThenOptional' => [
'code' => '<?php
/** @var list{0: "a", 1?: "b"} */
$array = ["a", "b"];
$popped1 = array_pop($array);
$array2 = $array;
$popped2 = array_pop($array);
$popped3 = array_pop($array);',
'assertions' => [
'$popped1===' => "'a'|'b'",
'$array2===' => "list{0?: 'a'}",
'$popped2===' => "'a'|null",
'$array===' => 'array<never, never>',
'$popped3===' => 'null',
],
],
'arrayPopListMultipleOptional' => [
'code' => '<?php
/** @var list{0: "a", 1: "b", 2?: "c", 3?: "d"} */
$array = ["a", "b", "c", "d"];
$popped1 = array_pop($array);
$array2 = $array;
$popped2 = array_pop($array);
$array3 = $array;
$popped3 = array_pop($array);
$array4 = $array;
$popped4 = array_pop($array);
$popped5 = array_pop($array);',
'assertions' => [
'$popped1===' => "'b'|'c'|'d'",
'$array2===' => "list{0: 'a', 1?: 'b', 2?: 'c'}",
'$popped2===' => "'a'|'b'|'c'",
'$array3===' => "list{0?: 'a', 1?: 'b'}",
'$popped3===' => "'a'|'b'|null",
'$array4===' => "list{0?: 'a'}",
'$popped4===' => "'a'|null",
'$array===' => 'array<never, never>',
'$popped5===' => 'null',
],
],
'arrayPopKeyedArrayAllRequired' => [
'code' => '<?php
/** @var array{hello: "world", 1: "b", x: "y"} */
$array = ["hello" => "world", 1 => "b", "x" => "y"];
$popped1 = array_pop($array);
$array2 = $array;
$popped2 = array_pop($array);
$popped3 = array_pop($array);',
'assertions' => [
'$popped1===' => "'b'|'world'|'y'",
'$array2===' => "array{1?: 'b', hello?: 'world', x?: 'y'}",
'$popped2===' => "'b'|'world'|'y'|null",
'$array===' => "array{1?: 'b', hello?: 'world', x?: 'y'}",
'$popped3===' => "'b'|'world'|'y'|null",
],
],
'arrayPopKeyedArrayWithOptional' => [
'code' => '<?php
/** @var array{hello: "world", 1?: "b", x: "y"} */
$array = ["hello" => "world", 1 => "b", "x" => "y"];
$popped1 = array_pop($array);
$array2 = $array;
$popped2 = array_pop($array);',
'assertions' => [
'$popped1===' => "'b'|'world'|'y'",
'$array2===' => "array{1?: 'b', hello?: 'world', x?: 'y'}",
'$array===' => "array{1?: 'b', hello?: 'world', x?: 'y'}",
'$popped2===' => "'b'|'world'|'y'|null",
],
],
'arrayPopKeyedArrayAllOptional' => [
'code' => '<?php
/** @var array{hello?: "world", 1?: "b", x?: "y"} */
$array = ["hello" => "world", 1 => "b", "x" => "y"];
$popped1 = array_pop($array);
$array2 = $array;
$popped2 = array_pop($array);',
'assertions' => [
'$popped1===' => "'b'|'world'|'y'|null",
'$array2===' => "array{1?: 'b', hello?: 'world', x?: 'y'}",
'$array===' => "array{1?: 'b', hello?: 'world', x?: 'y'}",
'$popped2===' => "'b'|'world'|'y'|null",
],
],
'SKIPPED-arrayPopNonEmptyAfterMixedArrayAddition' => [
'code' => '<?php
/** @var array */
Expand Down
Loading