Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **`update` command.** A new `phpantom_lsp update` subcommand downloads the latest release from GitHub and replaces the current binary. Supports `--check` (dry run, exit code 1 if update available) and `--no-confirm` (for CI). Handles `.tar.gz` (Unix) and `.zip` (Windows) archives across all 6 supported platforms. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/194.
- **`array_map` respects scalar callback return types.** `array_map(fn(Item $item): string => $item->id, $items)` now correctly infers `list<string>` instead of `list<Item>`. Previously, scalar return types like `string`, `int`, `bool` on the callback were ignored and the input array's element type was used instead. Fixes #147. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/195)
- **`array_map` infers callback return type from body.** When the callback has no explicit return type hint, the LSP now infers the return type by resolving the body expression. For example, `array_map(fn($item) => $item->id, $items)` where `$items` is `list<Item>` now correctly produces `list<string>` (from `Item::$id`'s type) instead of `list<Item>`. Works for both arrow functions and closures. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/195)
- **Static methods complete on instance access.** Member completion after `->` now offers a class's static methods alongside its instance methods, since PHP lets you call a static method through an instance (`$obj->make()`). Static properties remain excluded, as they are only reachable via `::`. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/174.
- **Array-callable navigation.** Method-name strings in array callables — `[Controller::class, 'method']` and `[$object, 'method']` — now resolve like a real member reference. This makes go-to-definition, find-references, and rename work on Laravel controller actions such as `Route::get('/', [IndexPageController::class, 'indexPage'])`.
- **Array-callable method completion.** Typing inside the method-name string of an array callable (`[Controller::class, '|']`) now offers method name completions from the resolved class, including inherited and trait methods. Works with `Class::class` constants, `$this`, and typed variables. (thanks @calebdw)
Expand Down
93 changes: 89 additions & 4 deletions src/completion/variable/raw_type_inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::parser::extract_hint_type;
use crate::php_type::PhpType;

use crate::completion::resolver::VarResolutionCtx;
use crate::types::ResolvedType;

/// Infer the raw PHPStan-style type string for an array literal
/// (`[…]` or `array(…)`) by examining its keys and resolving value
Expand Down Expand Up @@ -328,13 +329,97 @@ fn extract_array_map_element_type(
};

if let Some(ref parsed) = return_hint
&& parsed.base_name().is_some()
&& !parsed.is_untyped()
{
return return_hint;
}

// Fallback: use the input array's element type.
// No explicit return type — try to infer it from the callback body
// by resolving the body expression with the callback parameter
// seeded to the input array's element type.
let arr_expr = super::resolution::nth_arg_expr(args, 1)?;
let raw = super::resolution::resolve_arg_raw_type(arr_expr, ctx)?;
raw.extract_value_type(true).cloned()
let input_raw = super::resolution::resolve_arg_raw_type(arr_expr, ctx)?;
let input_element = input_raw.extract_value_type(true)?.clone();

if let Some(inferred) = infer_callback_return_type(callback_expr, &input_element, ctx) {
return Some(inferred);
}

// Final fallback: use the input array's element type.
Some(input_element)
}

/// Infer the return type of a callback (arrow function or closure) by
/// resolving its body expression with the first parameter seeded to
/// `param_type`.
///
/// For arrow functions: resolves `arrow.expression` directly.
/// For closures: finds the first `return` statement and resolves its
/// expression.
fn infer_callback_return_type(
callback_expr: &Expression<'_>,
param_type: &PhpType,
ctx: &VarResolutionCtx<'_>,
) -> Option<PhpType> {
let (param_name, body_expr) = match callback_expr {
Expression::ArrowFunction(arrow) => {
let param = arrow.parameter_list.parameters.first()?;
let name = bytes_to_str(param.variable.name).to_string();
(name, arrow.expression)
}
Expression::Closure(closure) => {
let param = closure.parameter_list.parameters.first()?;
let name = bytes_to_str(param.variable.name).to_string();
// Find the first return statement's expression.
let ret_expr = closure.body.statements.iter().find_map(|stmt| {
if let Statement::Return(ret) = stmt {
ret.value.as_ref()
} else {
None
}
})?;
(name, *ret_expr)
}
_ => return None,
};

// Build a scope resolver that maps the callback parameter to the
// input element type. Include ClassInfo when available so that
// property access resolution can find the class members.
let resolved_param = if let Some(class_name) = param_type.base_name() {
if let Some(cls) = (ctx.class_loader)(class_name) {
vec![ResolvedType::from_both(param_type.clone(), (*cls).clone())]
} else {
vec![ResolvedType::from_type_string(param_type.clone())]
}
} else {
vec![ResolvedType::from_type_string(param_type.clone())]
};
let scope_resolver = move |var: &str| -> Vec<ResolvedType> {
if var == param_name {
resolved_param.clone()
} else {
vec![]
}
};

// Create a synthetic context with the scope resolver.
let body_offset = body_expr.span().start.offset;
let infer_ctx = VarResolutionCtx {
var_name: "",
current_class: ctx.current_class,
all_classes: ctx.all_classes,
content: ctx.content,
cursor_offset: body_offset,
class_loader: ctx.class_loader,
loaders: ctx.loaders,
resolved_class_cache: ctx.resolved_class_cache,
enclosing_return_type: None,
top_level_scope: None,
branch_aware: false,
match_arm_narrowing: std::collections::HashMap::new(),
scope_var_resolver: Some(&scope_resolver),
};

super::foreach_resolution::resolve_expression_type(body_expr, &infer_ctx)
}
54 changes: 54 additions & 0 deletions tests/integration/diagnostics_type_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4030,3 +4030,57 @@ function test(): void {
"Hex literal 0x2 should match decimal literal 2 in union, got: {msgs:?}"
);
}

// ─── array_map callback return type (#147) ──────────────────────────────────

#[test]
fn no_false_positive_for_array_map_with_scalar_return_type() {
// array_map(fn(Item): string => ..., $items) should produce
// list<string>, not list<Item>. The callback's return type
// determines the output element type.
let php = r#"<?php
class Item {
public function __construct(public string $id) {}
}

/** @param list<string> $ids */
function takesStrings(array $ids): void {}

/** @param list<Item> $items */
function run(array $items): void {
takesStrings(array_map(fn(Item $item): string => $item->id, $items));
}
"#;
let diags = collect_with_stubs(php);
let msgs = type_error_messages(&diags);
assert!(
msgs.is_empty(),
"array_map with scalar return type should infer list<string>, got: {msgs:?}"
);
}

#[test]
fn no_false_positive_for_array_map_inferred_return_type() {
// array_map(fn($item) => $item->id, $items) — no explicit return
// type hint. The LSP should infer the return type from the body
// expression: $item->id is string, so the result is list<string>.
let php = r#"<?php
class Item {
public function __construct(public string $id) {}
}

/** @param list<string> $ids */
function takesStrings(array $ids): void {}

/** @param list<Item> $items */
function run(array $items): void {
takesStrings(array_map(fn($item) => $item->id, $items));
}
"#;
let diags = collect_with_stubs(php);
let msgs = type_error_messages(&diags);
assert!(
msgs.is_empty(),
"array_map should infer return type from body expression, got: {msgs:?}"
);
}
Loading