Skip to content

Commit 6618d29

Browse files
committed
array_filter() reports the type the filter leaves behind
1 parent e2751b3 commit 6618d29

11 files changed

Lines changed: 344 additions & 11 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
107107

108108
### Fixed
109109

110+
- **`array_filter()` reports the type the filter leaves behind.** A callback that tests the value it is handed proves something about every entry that survives, but the result kept whatever element type went in, so `array_filter($values, fn ($v) => $v !== null)` still looked like it could hold `null` and returning it from a function declared `int[]` was reported as a type error. The surviving values are now narrowed the way the body of an `if` narrows the variable it guards, whether the test is written as an `is_…()` call, a comparison against `null`, an `instanceof` check, or a callable string such as `'is_int'`. The keys were already narrowed this way in the modes that hand the callback a key, and both halves narrow together under `ARRAY_FILTER_USE_BOTH`. Closes #376.
110111
- **A deprecation warning belongs to the variable it is written on.** The deprecated-usage check typed its subject from a cache keyed by variable name and class, so two methods of the same class that reuse a parameter name shared one entry and whichever type was bound first won. A Laravel controller with a `Request $request` method above a `PendingRequest $request` method reported `Illuminate\Http\Request::get is deprecated` on the HTTP client call, where `get()` is the ordinary way to make a request, and renaming either parameter made the warning vanish. Every subject is now typed in the scope it is written in, so each method, closure, and `instanceof` branch gets its own answer, and a genuine deprecation is still reported no matter which order the methods appear in.
111112
- **A generic argument left out takes the default its `@template` declares.** `@template TAsync of bool = false` says that a use of the class without a generic argument means `false`, but the parameter was widened to its upper bound instead, so a conditional return keyed on it always picked the else branch. Laravel's HTTP client is where this shows up: `PendingRequest` declares synchronous mode as its default, so an ordinary `Http::get()` looked like it returned a promise rather than a `Response`, and `json()` along with the rest of the response API appeared to be missing on it. A plain request now resolves to `Response` whether it is made through `PendingRequest`, the client factory, or the `Http` facade, and `async()` still resolves to `PromiseInterface` through all three. Contributed by @shuvroroy (#377).
112113
- **A conditional assertion narrows the value the call was written on.** `if (filled($search))` left a `?string` nullable inside the branch, so passing it on to something that expects a `string` was reported as an error. Two things stood in the way, and Laravel's `filled()` and `blank()` hit both. An asserted type written as a union (`!=null|''`, which is how the pair is annotated) matched no type guard at all and narrowed nothing; ruling one out now rules out each of its members on its own. And a tag written in the equality form was being inverted into the branch it does not name, which typed every filled value as `numeric|bool`. Those tags promise something in one direction only, so they are now left out of the opposite branch, while the subtype form (`!null`) keeps narrowing both. Closes #375.

docs/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ unlikely to move the needle for most users.
102102
| T10 | [Ternary expression as RHS of list destructuring](todo/type-inference.md#t10-ternary-expression-as-rhs-of-list-destructuring) | Low | Medium |
103103
| T11 | [Nested list destructuring](todo/type-inference.md#t11-nested-list-destructuring) | Low | Medium |
104104
| | **[Bugs](todo/bugs.md)** | | |
105+
| B181 | [`array_filter()` reports a `list` the filter cannot preserve](todo/bugs.md#b181-array_filter-reports-a-list-the-filter-cannot-preserve) | Low-Medium | Medium |
105106
| | **[Diagnostics](todo/diagnostics.md)** | | |
106107
| D6 | [Unreachable code diagnostic](todo/diagnostics.md#d6-unreachable-code-diagnostic) | Low-Medium | Medium |
107108
| D16 | [`unreachable_match_arm` ignores literal subject types](todo/diagnostics.md#d16-unreachable_match_arm-ignores-literal-subject-types) | Low-Medium | Medium |

docs/todo/bugs.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,31 @@ No outstanding items.
3737

3838
## Array types
3939

40-
No outstanding items.
40+
### B181. `array_filter()` reports a `list` the filter cannot preserve
41+
42+
**Impact: Low-Medium · Complexity: Medium**
43+
44+
`array_filter()` keeps the key of every entry it keeps, so filtering a
45+
`list` leaves gaps in the numbering and the result is `array<int, T>`
46+
rather than `list<T>`. PHPantom hands back the container it was given:
47+
48+
```php
49+
/** @param list<int> $values */
50+
function probe(array $values): void {
51+
$kept = array_filter($values, fn ($v) => $v > 3); // reported as list<int>
52+
$kept[0]; // [3, 4] filtered this way starts at key 1, so this is unset
53+
}
54+
```
55+
56+
The over-claim runs both ways: reading `$kept[0]` looks safe when it is
57+
not, and a function declared `@return list<int>` that hands back an
58+
unwrapped `array_filter()` result is accepted where PHPStan reports it.
59+
The rule that rebuilds the container for the preserving family lives in
60+
`type_engine/variable/array_func_rules.rs`; `array_filter` needs to drop
61+
to `array<int, T>` there while the renumbering functions around it
62+
(`array_values`, `array_merge`) keep answering `list<T>`, and the demo
63+
files and tests that currently assert `list<…>` for a filtered list need
64+
updating with it.
4165

4266
## Docblock handling
4367

examples/php/completion.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3403,6 +3403,18 @@ public function keysAndValues(Scaffolding\ScaffoldingArrayFunc $src): void
34033403
$stringKeyed = array_filter($src->mixedKeys(), fn($key) => is_string($key), ARRAY_FILTER_USE_KEY);
34043404
strtoupper(array_keys($stringKeyed)[0]); // list<string>: the int key is gone
34053405

3406+
// The default mode hands the callback the value instead, so a
3407+
// callback that tests it says as much about the entries that
3408+
// survive as the truthiness test above does.
3409+
$named = array_filter($src->optionalLabels(), fn($label) => $label !== null);
3410+
strtoupper($named['ink']); // array<string, string>: the null half is gone
3411+
3412+
// An instanceof check filters for a type, and the result carries it.
3413+
// array_filter keeps the original keys, so array_values renumbers
3414+
// them before the first entry is read back.
3415+
$pens = array_values(array_filter($src->mixedWriters(), fn($writer) => $writer instanceof Scaffolding\Pen));
3416+
$pens[0]->write(); // list<Scaffolding\Pen>, so Pen's members are here
3417+
34063418
// An all-int array cannot sum to a float.
34073419
$total = array_sum($src->weights());
34083420
intdiv($total, 1); // int

examples/php/scaffolding/assertions.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ function runDemoAssertions(): void
251251
assert(array_key_first((new Scaffolding\ScaffoldingArrayFunc())->byName()) === 'blue', 'array_key_first() over a string-keyed array yields a string');
252252
$stringKeyed = array_filter((new Scaffolding\ScaffoldingArrayFunc())->mixedKeys(), fn($key) => is_string($key), ARRAY_FILTER_USE_KEY);
253253
assert(array_keys($stringKeyed) === ['ink'], 'array_filter() with ARRAY_FILTER_USE_KEY keeps only the keys its callback approves of');
254+
$named = array_filter((new Scaffolding\ScaffoldingArrayFunc())->optionalLabels(), fn($label) => $label !== null);
255+
assert($named === ['ink' => 'ink'], 'array_filter() with a value callback keeps only the entries it approves of, so no null survives');
256+
$writers = array_values(array_filter((new Scaffolding\ScaffoldingArrayFunc())->mixedWriters(), fn($writer) => $writer instanceof Scaffolding\Pen));
257+
assert(count($writers) === 2 && $writers[0] instanceof Scaffolding\Pen, 'array_filter() with an instanceof callback keeps only that class');
254258

255259
// ── array<T>|false keeps its element type after a false check ────────
256260
$pens = Scaffolding\loadPensOrFail();

examples/php/scaffolding/scaffolding.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,9 @@ public function optionalLabels(): array { return ['ink' => 'ink', 'gel' => null]
768768
/** @return array<string|int, string> */
769769
public function mixedKeys(): array { return ['ink' => 'gel', 7 => 'nib']; }
770770

771+
/** @return list<Pen|Pencil> */
772+
public function mixedWriters(): array { return [new Pen('blue'), new Pencil(), new Pen('red')]; }
773+
771774
/** @return list<int> */
772775
public function weights(): array { return [2, 3, 4]; }
773776
}

src/type_engine/types/narrowing/guards.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,10 +878,71 @@ fn narrow_by_condition_inner(
878878
_ => {}
879879
}
880880

881+
// `$v instanceof Foo` is the type test a filter callback is most often
882+
// written with, and `$v !== null` the one `is_null()` is spelled as
883+
// when nobody reaches for a function call.
884+
if let Some(extraction) = super::try_extract_instanceof_with_negation(condition, var_name) {
885+
return narrow_type_by_instanceof(ty, &extraction, class_loader);
886+
}
887+
if let Some(expects_null) = try_extract_null_comparison(condition, var_name) {
888+
return filter_type_by_guard(ty, TypeGuardKind::Null, expects_null, class_loader);
889+
}
890+
881891
let (kind, negated) = try_extract_type_guard(condition, var_name)?;
882892
filter_type_by_guard(ty, kind, !negated, class_loader)
883893
}
884894

895+
/// Whether `expr` compares `var_name` against `null`, and whether passing
896+
/// it proves the value *is* null.
897+
///
898+
/// Only the strict operators prove a value is null: `$v == null` is also
899+
/// true for `''`, `0` and `[]`, so reporting `null` for it would claim
900+
/// more than the comparison shows. Proving a value is *not* null needs no
901+
/// such care, since anything that survives either `!==` or `!=` is
902+
/// non-null.
903+
fn try_extract_null_comparison(expr: &Expression<'_>, var_name: &str) -> Option<bool> {
904+
match expr {
905+
Expression::Parenthesized(inner) => try_extract_null_comparison(inner.expression, var_name),
906+
Expression::UnaryPrefix(prefix) if prefix.operator.is_not() => {
907+
// `!($v !== null)` proves the value is null, which only the
908+
// strict operator it negates is allowed to say.
909+
match try_extract_null_comparison(prefix.operand, var_name)? {
910+
true => None,
911+
false => Some(true),
912+
}
913+
}
914+
Expression::Binary(bin) => {
915+
let expects_null = match bin.operator {
916+
BinaryOperator::Identical(_) => true,
917+
BinaryOperator::NotIdentical(_) | BinaryOperator::NotEqual(_) => false,
918+
_ => return None,
919+
};
920+
let (subject, literal) = if is_null_literal(bin.rhs) {
921+
(bin.lhs, bin.rhs)
922+
} else {
923+
(bin.rhs, bin.lhs)
924+
};
925+
if !is_null_literal(literal) || expr_to_subject_key(subject)? != var_name {
926+
return None;
927+
}
928+
Some(expects_null)
929+
}
930+
_ => None,
931+
}
932+
}
933+
934+
/// Whether `expr` is the `null` keyword.
935+
fn is_null_literal(expr: &Expression<'_>) -> bool {
936+
match expr {
937+
Expression::Parenthesized(inner) => is_null_literal(inner.expression),
938+
Expression::Literal(Literal::Null(_)) => true,
939+
Expression::ConstantAccess(access) => bytes_to_str(access.name.value())
940+
.trim_start_matches('\\')
941+
.eq_ignore_ascii_case("null"),
942+
_ => false,
943+
}
944+
}
945+
885946
/// Try to extract a type-guard function call on a variable.
886947
///
887948
/// Matches `is_array($var)`, `is_string($var)`, etc. (with optional

src/type_engine/types/narrowing/instanceof.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use std::sync::Arc;
66

77
use crate::atom::{atom, bytes_to_str, literal_bytes_to_str};
8-
use crate::php_type::PhpType;
8+
use crate::php_type::{PhpType, TypeKind};
99
use crate::types::ClassInfo;
1010

1111
use mago_syntax::cst::*;
@@ -1002,3 +1002,95 @@ fn collect_negated_and_instanceof_classes<'b>(
10021002
}
10031003
}
10041004
}
1005+
1006+
/// Narrow `ty` to the values an `instanceof`-style check keeps (or, when
1007+
/// the check is negated, the ones it rejects).
1008+
///
1009+
/// Each member of the union answers on its own: one that already names a
1010+
/// subtype of the checked class is the more specific of the two and
1011+
/// stays as it is, one the checked class is a subtype of narrows to the
1012+
/// class, and one that is unrelated (or not an object at all) cannot
1013+
/// pass and is dropped.
1014+
///
1015+
/// Returns `None` when nothing can be said — no class loader, a class
1016+
/// name that does not resolve, a `self`/`static`/`parent` reference the
1017+
/// enclosing scope alone could resolve, or a check every member already
1018+
/// satisfies.
1019+
pub(in crate::type_engine) fn narrow_type_by_instanceof(
1020+
ty: &PhpType,
1021+
extraction: &InstanceofExtraction,
1022+
class_loader: GuardClassLoader<'_>,
1023+
) -> Option<PhpType> {
1024+
let loader = class_loader?;
1025+
let class_name = extraction.class_type.class_name()?;
1026+
if extraction.class_type.is_self_like() || class_name.eq_ignore_ascii_case("parent") {
1027+
return None;
1028+
}
1029+
loader(class_name)?;
1030+
1031+
let members = instanceof_union_members(ty);
1032+
let kept: Vec<PhpType> = members
1033+
.iter()
1034+
.filter_map(|member| {
1035+
if extraction.negated {
1036+
// Ruling the class out leaves every member that is not one
1037+
// of its instances exactly as it was.
1038+
(!crate::class_lookup::is_subtype_of_named(member, class_name, loader))
1039+
.then(|| member.clone())
1040+
} else {
1041+
instanceof_member(member, extraction, class_name, loader)
1042+
}
1043+
})
1044+
.collect();
1045+
1046+
if kept.is_empty() || kept == members {
1047+
return None;
1048+
}
1049+
// A lone survivor is that type, not a union of one: `PhpType::union`
1050+
// only collapses a single member when it deduplicated to get there.
1051+
match kept.len() {
1052+
1 => kept.into_iter().next(),
1053+
_ => Some(PhpType::union(kept)),
1054+
}
1055+
}
1056+
1057+
/// The alternatives a value of `ty` can take, with `?T` spelled out as
1058+
/// the `T|null` it stands for so each half is judged separately.
1059+
fn instanceof_union_members(ty: &PhpType) -> Vec<PhpType> {
1060+
match ty.kind() {
1061+
TypeKind::Nullable(inner) => vec![inner.clone(), PhpType::null()],
1062+
TypeKind::Union(members) => members.to_vec(),
1063+
_ => vec![ty.clone()],
1064+
}
1065+
}
1066+
1067+
/// What a positive `instanceof` check leaves of a single union member,
1068+
/// or `None` when the member cannot pass it.
1069+
fn instanceof_member(
1070+
member: &PhpType,
1071+
extraction: &InstanceofExtraction,
1072+
class_name: &str,
1073+
loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
1074+
) -> Option<PhpType> {
1075+
if !member.is_object_like() {
1076+
// `null`, a scalar and an array all fail `instanceof` outright.
1077+
return None;
1078+
}
1079+
// A member that names no class of its own (`object`, `mixed`) is
1080+
// whatever the check proves.
1081+
let Some(member_name) = member.class_name().filter(|n| loader(n).is_some()) else {
1082+
return Some(extraction.class_type.clone());
1083+
};
1084+
// An exact identity check (`get_class($v) === Foo::class`) admits the
1085+
// class itself and nothing below it, so a member naming a subclass
1086+
// cannot pass.
1087+
if extraction.exact {
1088+
return crate::class_lookup::is_subtype_of_names(class_name, member_name, loader)
1089+
.then(|| extraction.class_type.clone());
1090+
}
1091+
if crate::class_lookup::is_subtype_of_named(member, class_name, loader) {
1092+
return Some(member.clone());
1093+
}
1094+
crate::class_lookup::is_subtype_of_names(class_name, member_name, loader)
1095+
.then(|| extraction.class_type.clone())
1096+
}

src/type_engine/variable/array_func_rules.rs

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,10 @@ pub(in crate::type_engine) fn array_func_raw_type(
111111
if !args.has_arg(1) {
112112
return Some(filter_element_type(&raw).unwrap_or(raw));
113113
}
114-
// A callback handed the key decides which keys survive, so
115-
// what it asserts about them describes the result.
116-
if let Some(narrowed) = filter_key_type(&raw, args) {
117-
return Some(narrowed);
118-
}
114+
// A callback decides which entries survive, so what it
115+
// asserts about the value or the key it is handed
116+
// describes the result.
117+
return Some(filter_callback_type(&raw, args).unwrap_or(raw));
119118
}
120119
return Some(raw);
121120
}
@@ -398,21 +397,57 @@ fn filter_element_type(raw: &PhpType) -> Option<PhpType> {
398397
if truthy == *element {
399398
return None;
400399
}
400+
with_element_type(raw, truthy)
401+
}
402+
403+
/// Rebuild an iterable type around a new element type, keeping the
404+
/// container it already names.
405+
fn with_element_type(raw: &PhpType, element: PhpType) -> Option<PhpType> {
401406
match raw.kind() {
402-
TypeKind::Array(_) => Some(PhpType::array_of(truthy)),
407+
TypeKind::Array(_) => Some(PhpType::array_of(element)),
403408
TypeKind::Generic(g) if !g.args.is_empty() => {
404409
let mut args = g.args.clone();
405410
// Same `<TKey, TValue>` convention `extract_value_type` reads:
406411
// the value is the second argument when there are two or more,
407412
// and the lone argument otherwise (`list<V>`).
408413
let value_idx = if args.len() >= 2 { 1 } else { args.len() - 1 };
409-
args[value_idx] = truthy;
414+
args[value_idx] = element;
410415
Some(PhpType::generic_atom(g.name, args))
411416
}
412417
_ => None,
413418
}
414419
}
415420

421+
/// Rebuild an `array_filter` result with what its callback proves about
422+
/// the entries it keeps.
423+
///
424+
/// The callback is handed the value, the key, or both depending on the
425+
/// mode argument, and each narrows the half it arrives in. Returns
426+
/// `None` when neither is narrowed.
427+
fn filter_callback_type(raw: &PhpType, args: &dyn ArrayFuncArgs) -> Option<PhpType> {
428+
let narrowed_value = filter_value_type(raw, args);
429+
let base = narrowed_value.as_ref().unwrap_or(raw);
430+
filter_key_type(base, args).or(narrowed_value)
431+
}
432+
433+
/// Rebuild an `array_filter` result with its element type narrowed to
434+
/// what the callback asserts about the value it was handed.
435+
///
436+
/// Returns `None` unless the call runs in one of the two modes that pass
437+
/// the value (the default and `ARRAY_FILTER_USE_BOTH`) and the callback
438+
/// proves something the element type does not already say.
439+
fn filter_value_type(raw: &PhpType, args: &dyn ArrayFuncArgs) -> Option<PhpType> {
440+
let param_index = filter_value_param_index(args)?;
441+
let element = raw.extract_value_type(false)?;
442+
let narrowed = args.callback_param_narrowing(1, param_index, element)?;
443+
// A callback that admits every value it could receive says nothing,
444+
// and the rebuilt union would only reorder the members.
445+
if element.is_subtype_of(&narrowed) {
446+
return None;
447+
}
448+
with_element_type(raw, narrowed)
449+
}
450+
416451
/// Rebuild an `array_filter` result with its key type narrowed to what
417452
/// the callback asserts about the key it was handed.
418453
///
@@ -450,6 +485,23 @@ fn filter_key_type(raw: &PhpType, args: &dyn ArrayFuncArgs) -> Option<PhpType> {
450485
}
451486
}
452487

488+
/// Which of the callback's parameters receives the value, from
489+
/// `array_filter`'s mode argument.
490+
///
491+
/// The default mode passes the value alone, and `ARRAY_FILTER_USE_BOTH`
492+
/// passes it ahead of the key. `ARRAY_FILTER_USE_KEY` never shows the
493+
/// callback a value, and a mode written as anything this cannot read
494+
/// might be that one.
495+
fn filter_value_param_index(args: &dyn ArrayFuncArgs) -> Option<usize> {
496+
if !args.has_arg(2) {
497+
return Some(0);
498+
}
499+
match args.arg_atom_text(2)?.as_str() {
500+
"ARRAY_FILTER_USE_BOTH" | "1" | "0" => Some(0),
501+
_ => None,
502+
}
503+
}
504+
453505
/// Which of the callback's parameters receives the key, from
454506
/// `array_filter`'s mode argument.
455507
///

0 commit comments

Comments
 (0)