Repository: carthage-software/mago (Rust)

Issue:
Analyzer: `array_column()` doesn't work on array of objects
### 🐞 Describe the Bug

PHP supports `array_column()` to run on an array of objects, mago's prototype doesn't support that.

### 🔄 Steps to Reproduce

1. Run mago lint on the example code

### ⚙️ Configuration (mago.toml)

```toml
-
```

### 📜 Command Output

```shell
error[invalid-argument]: Invalid argument type for argument #1 of `array_column`: expected `array<array-key, array<string('bar'), 'V.array_column() extends mixed>>`, but found `list{foo, foo, foo}`.
   ┌─ test.php:10:23
   │
10 │ var_dump(array_column($baz, 'bar'));
   │          ------------ ^^^^ This has type `list{foo, foo, foo}`
   │          │
   │          Arguments to this function are incorrect
   │
   = The provided type `list{foo, foo, foo}` is not compatible with the expected type `array<array-key, array<string('bar'), 'V.array_column() extends mixed>>`.
   = Help: Change the argument value to match `array<array-key, array<string('bar'), 'V.array_column() extends mixed>>`, or update the parameter's type declaration.

error: found 1 issues: 1 error(s)
```

### 📂 PHP Code Sample (If Applicable)

```php
<?php declare(strict_types=1);

class foo {
  public function __construct(public string $bar) {
  }
}

$baz = [new foo("a"), new foo("b"), new foo("c")];

var_dump(array_column($baz, 'bar'));
```

### 🖥️ Operating System

Linux

### 📦 How did you install Mago?

Built from Source

### 📝 Additional Context

_No response_


Discussion:
This would require a special handler for `array_column`, it is one of those builtin functions that does not really have a good signature, and it is full of "magic".



Patch:
diff --git a/crates/analyzer/src/invocation/special_function_like_handler/mod.rs b/crates/analyzer/src/invocation/special_function_like_handler/mod.rs
index 7210adbc4..2c90f5e31 100644
--- a/crates/analyzer/src/invocation/special_function_like_handler/mod.rs
+++ b/crates/analyzer/src/invocation/special_function_like_handler/mod.rs
@@ -10,6 +10,7 @@ use crate::invocation::special_function_like_handler::psl::str_component::StrCom
 use crate::invocation::special_function_like_handler::psl::type_component::TypeComponentFunctionsHandler;
 use crate::invocation::special_function_like_handler::random::RandomFunctionsHandler;
 use crate::invocation::special_function_like_handler::spl::iterator::IteratorFunctionsHandler;
+use crate::invocation::special_function_like_handler::standard::array::ArrayFunctionsHandler;
 use crate::invocation::special_function_like_handler::standard::string::StringFunctionsHandler;
 
 mod core;
@@ -41,6 +42,7 @@ pub fn handle_special_functions<'ctx, 'ast, 'arena>(
         &GetCurrentClosureMethodHandler,
         // Standard PHP function handlers
         &StringFunctionsHandler,
+        &ArrayFunctionsHandler,
         // SPL function handlers
         &IteratorFunctionsHandler,
         // Random extension function handlers
diff --git a/crates/analyzer/src/invocation/special_function_like_handler/standard/array.rs b/crates/analyzer/src/invocation/special_function_like_handler/standard/array.rs
new file mode 100644
index 000000000..e025538c7
--- /dev/null
+++ b/crates/analyzer/src/invocation/special_function_like_handler/standard/array.rs
@@ -0,0 +1,118 @@
+use std::borrow::Cow;
+
+use mago_atom::concat_atom;
+use mago_codex::get_class_like;
+use mago_codex::ttype::atomic::TAtomic;
+use mago_codex::ttype::atomic::array::TArray;
+use mago_codex::ttype::atomic::array::keyed::TKeyedArray;
+use mago_codex::ttype::atomic::array::list::TList;
+use mago_codex::ttype::atomic::object::TObject;
+use mago_codex::ttype::atomic::scalar::TScalar;
+use mago_codex::ttype::get_array_parameters;
+use mago_codex::ttype::union::TUnion;
+use mago_span::HasSpan;
+
+use crate::artifacts::AnalysisArtifacts;
+use crate::context::Context;
+use crate::context::block::BlockContext;
+use crate::invocation::Invocation;
+use crate::invocation::special_function_like_handler::SpecialFunctionLikeHandlerTrait;
+use crate::invocation::special_function_like_handler::utils::get_argument;
+use crate::visibility::check_property_read_visibility;
+
+#[derive(Debug)]
+pub struct ArrayFunctionsHandler;
+
+impl SpecialFunctionLikeHandlerTrait for ArrayFunctionsHandler {
+    fn get_return_type<'ctx, 'ast, 'arena>(
+        &self,
+        context: &mut Context<'ctx, 'arena>,
+        block_context: &BlockContext<'ctx>,
+        artifacts: &AnalysisArtifacts,
+        function_like_name: &str,
+        invocation: &Invocation<'ctx, 'ast, 'arena>,
+    ) -> Option<TUnion> {
+        match function_like_name {
+            "array_column" => {
+                let array_argument = get_argument(invocation.arguments_source, 0, vec!["array"])?;
+                let array_type = artifacts.get_expression_type(array_argument)?;
+
+                let array = array_type.get_single_array()?;
+
+                let array_parameters = get_array_parameters(array, context.codebase);
+                let obj = array_parameters.1.get_single_named_object()?;
+
+                let class_like = get_class_like(context.codebase, &obj.name)?;
+
+                let column_key_argument = get_argument(invocation.arguments_source, 1, vec!["column_key"])?;
+                let column_key_type = artifacts.get_expression_type(column_key_argument)?;
+
+                let column_type = if !column_key_type.is_null() {
+                    let column_key_property_name = column_key_type.get_single_literal_string_value()?;
+                    let column_key_property =
+                        class_like.properties.get(&concat_atom!("$", column_key_property_name))?;
+
+                    if !check_property_read_visibility(
+                        context,
+                        block_context,
+                        &class_like.name,
+                        concat_atom!("$", column_key_property_name).into(),
+                        column_key_argument.span(),
+                        column_key_property.span,
+                    ) {
+                        return None;
+                    }
+
+                    column_key_property.type_metadata.as_ref()?.type_union.clone()
+                } else {
+                    TUnion::from_atomic(TAtomic::Object(TObject::Named(obj.clone())))
+                };
+
+                let index_key_argument = get_argument(invocation.arguments_source, 2, vec!["index_key"]);
+                let index_key_type = index_key_argument.and_then(|argument| artifacts.get_expression_type(argument));
+
+                let mut index_type = None;
+                if let (Some(index_key_argument), Some(index_key_type)) = (index_key_argument, index_key_type) {
+                    let index_key_property_name = index_key_type.get_single_literal_string_value();
+                    let index_key_property = index_key_property_name
+                        .and_then(|property_name| class_like.properties.get(&concat_atom!("$", property_name)));
+
+                    if let Some(index_key_property) = index_key_property {
+                        if !check_property_read_visibility(
+                            context,
+                            block_context,
+                            &class_like.name,
+                            concat_atom!("$", index_key_property.name.0).into(),
+                            index_key_argument.span(),
+                            index_key_property.span,
+                        ) {
+                            return None;
+                        }
+
+                        index_type = match index_key_property.type_metadata.as_ref()?.type_union.get_single() {
+                            TAtomic::Scalar(scalar @ TScalar::ArrayKey)
+                            | TAtomic::Scalar(scalar @ TScalar::Integer(_))
+                            | TAtomic::Scalar(scalar @ TScalar::String(_))
+                            | TAtomic::Scalar(scalar @ TScalar::ClassLikeString(_)) => Some(scalar),
+                            _ => None,
+                        };
+                    }
+                }
+
+                if let Some(index_type) = index_type {
+                    let keyed_array = TKeyedArray::new_with_parameters(
+                        Box::new(TUnion::from_atomic(TAtomic::Scalar(index_type.clone()))),
+                        Box::new(column_type),
+                    );
+
+                    return Some(TUnion::from_atomic(TAtomic::Array(TArray::Keyed(keyed_array))));
+                }
+
+                let list = TList::new(Box::new(column_type));
+
+                Some(TUnion::from_single(Cow::Owned(TAtomic::Array(TArray::List(list)))))
+            }
+            _ => None,
+        }
+    }
+}
diff --git a/crates/analyzer/src/invocation/special_function_like_handler/standard/mod.rs b/crates/analyzer/src/invocation/special_function_like_handler/standard/mod.rs
index d245b8521..d223e888c 100644
--- a/crates/analyzer/src/invocation/special_function_like_handler/standard/mod.rs
+++ b/crates/analyzer/src/invocation/special_function_like_handler/standard/mod.rs
@@ -1,1 +1,2 @@
+pub mod array;
 pub mod string;
diff --git a/crates/codex/src/ttype/union.rs b/crates/codex/src/ttype/union.rs
index cd2eeb402..72b35c8a5 100644
--- a/crates/codex/src/ttype/union.rs
+++ b/crates/codex/src/ttype/union.rs
@@ -964,16 +964,16 @@ impl TUnion {
         if self.is_single() { self.get_single().to_array_key() } else { None }
     }
 
-    pub fn get_single_key_of_array_like(self) -> Option<TUnion> {
+    pub fn get_single_key_of_array_like(&self) -> Option<TUnion> {
         if !self.is_single() {
             return None;
         }
 
-        match self.get_single_owned() {
+        match self.get_single() {
             TAtomic::Array(array) => match array {
                 TArray::List(_) => Some(get_int()),
-                TArray::Keyed(keyed_array) => match keyed_array.parameters {
-                    Some((k, _)) => Some(*k),
+                TArray::Keyed(keyed_array) => match &keyed_array.parameters {
+                    Some((k, _)) => Some(*k.clone()),
                     None => Some(get_arraykey()),
                 },
             },
diff --git a/crates/prelude/assets/extensions/standard.php b/crates/prelude/assets/extensions/standard.php
index 7f5ad2db6..2c6c84bc6 100644
--- a/crates/prelude/assets/extensions/standard.php
+++ b/crates/prelude/assets/extensions/standard.php
@@ -4456,7 +4456,7 @@ function array_count_values(array $array): array
  * @template K as array-key
  * @template V
  *
- * @param array<array-key, array<K, V>> $array
+ * @param array<array-key, array<K, V>|object>|list<array<K,V>|object> $array
  * @param K|null $column_key
  * @param K|null $index_key
  *


Test patch:
diff --git a/crates/analyzer/tests/cases/issue_275.php b/crates/analyzer/tests/cases/issue_275.php
new file mode 100644
index 000000000..d43be0e3a
--- /dev/null
+++ b/crates/analyzer/tests/cases/issue_275.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+class Pair
+{
+    public function __construct(
+        public string $key,
+        public int $value,
+    ) {}
+}
+
+/**
+ * @param list<Pair> $array
+ * @mago-expect analysis:type-confirmation
+ */
+function value_key(array $array): array {
+    $v = array_column($array, 'value', 'key');
+    Mago\confirm($v, 'array<string, int>');
+    return $v;
+}
+
+/**
+ * @param list<Pair> $array
+ * @mago-expect analysis:type-confirmation
+ */
+function no_value_key(array $array): array {
+    $v = array_column($array, null, 'key');
+    Mago\confirm($v, 'array<string, Pair>');
+    return $v;
+}
+
+/**
+ * @param list<Pair> $array
+ * @mago-expect analysis:type-confirmation
+ */
+function no_value_no_key(array $array): array {
+    $v = array_column($array, null);
+    Mago\confirm($v, 'list<Pair>');
+    return $v;
+}
+
+/**
+ * @param list<Pair> $array
+ * @mago-expect analysis:type-confirmation
+ */
+function value_no_key(array $array): array {
+    $v = array_column($array, 'value', null);
+    Mago\confirm($v, 'list<int>');
+    return $v;
+}
+
+$v = [new Pair('a', 1)];
+$column = array_column($v, 'value');
+
+/** @mago-expect analysis:type-confirmation */
+Mago\confirm($column, 'list<int>');
diff --git a/crates/analyzer/tests/mod.rs b/crates/analyzer/tests/mod.rs
index 7d549a4d7..452d1b185 100644
--- a/crates/analyzer/tests/mod.rs
+++ b/crates/analyzer/tests/mod.rs
@@ -160,6 +160,7 @@ test_case!(arrow_function_inherits_method_templates);
 test_case!(all_paths_return_value);
 
 // Github Issues
+test_case!(issue_275);
 test_case!(issue_306);
 test_case!(issue_355);
 test_case!(issue_357);