Skip to content

Commit 76cf863

Browse files
committed
array_filter keeps what its callback proves about the keys
1 parent 08a43d8 commit 76cf863

13 files changed

Lines changed: 490 additions & 38 deletions

File tree

docs/CHANGELOG.md

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

122122
### Fixed
123123

124+
- **`array_filter` keeps what its callback proves about the keys.** In the two modes that hand the callback the key (`ARRAY_FILTER_USE_KEY` and `ARRAY_FILTER_USE_BOTH`) the result was reported with the key type it went in with, so `array_filter($data, fn ($k) => is_string($k), ARRAY_FILTER_USE_KEY)` still claimed integer keys the call exists to remove, and passing it on to a parameter declared `array<string, …>` was reported as a mismatch. The keys that survive are now read off the callback the same way an `if (is_string($k))` body is read, whether it is written inline or named (`'is_string'`), and a callback that admits every key it could receive leaves the type alone.
124125
- **An element write refines what the array already holds.** A write into a variable whose keys were already tracked was thrown away rather than recorded: `$row[] = $pen` on a `array{name: string}` left the shape exactly as it was, and so did `$row[$key] = 1`, so the value that had just been written was not there to read back. Both are now applied. An append takes the next free integer key beside the keys already tracked, an append one level down extends what that key holds instead of leaving it at the value it was initialised with, and a write through a key only known at runtime widens the shape to the keys and values it and the existing entries describe together, since a runtime key may land on any of them. The reverse mistake is gone too: writing a literal key into an array declared by key and value type (`array<string, int>`) rebuilt it as a shape holding that one key, discarding every other key it was known to hold, and appending to a string-keyed array called the result a `list`. Both now keep the array's key and value types and fold the written pair into them.
125126
- **`+=` no longer forgets what the right side contributes.** Where the left side had no tracked type, `$config += ['slot' => $default]` produced a bare `array`, so the key it had just added was not there to complete or hover on. `+` only accepts arrays, so whatever the left side held was one, and the result now carries what the right side contributes. Two positional arrays union index by index as PHP does, rather than standing down because their entries are written without keys.
126127
- **An array shape answers the `list` and `non-empty-array` promises from its own entries.** A shape was compared against those types by name alone, so `array{}` satisfied a `non-empty-array` parameter and `array{name: string}` satisfied a `list`, while a real list of values written as `array{string, int}` did not satisfy `list`. A shape is now non-empty when it names a key that is always there, and a list when its keys run `0, 1, 2, …` in order with any optional entry at the end.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ unlikely to move the needle for most users.
9999
| D17 | [`docblock_native_mismatch` only judges nullability](todo/diagnostics.md#d17-docblock_native_mismatch-only-judges-nullability) | Low | Medium |
100100
| | **[Bug Fixes](todo/bugs.md)** | | |
101101
| B151 | [`?T` and `T\|null` are judged by different rules](todo/bugs.md#b151-t-and-tnull-are-judged-by-different-rules) | High | Low-Medium |
102-
| B152 | [`array_filter` with `ARRAY_FILTER_USE_KEY` does not narrow the key type](todo/bugs.md#b152-array_filter-with-array_filter_use_key-does-not-narrow-the-key-type) | Low-Medium | Medium |
103102
| | **[Code Actions](todo/actions.md)** | | |
104103
| A40 | [Generate method from call](todo/actions.md#a40-generate-method-from-call) | Medium-High | Medium |
105104
| A41 | [Create class from non-existing name](todo/actions.md#a41-create-class-from-non-existing-name) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -76,22 +76,4 @@ have been guarded out) rather than removing it outright.
7676

7777
## Array types
7878

79-
### B152. `array_filter` with `ARRAY_FILTER_USE_KEY` does not narrow the key type
80-
81-
**Impact: Low-Medium · Effort: Medium**
82-
83-
```php
84-
/** @return array<string> $data */
85-
$data = array_filter($data, fn (string|int $k): bool => is_string($k), ARRAY_FILTER_USE_KEY);
86-
$data = $this->viewData($view) + $data; // reported: array<string|int, string>
87-
```
88-
89-
`array_filter` preserves its input type verbatim, so the callback's
90-
proof about the keys is dropped. That is invisible on its own, but the
91-
key type surfaces the moment the result is merged with `+` or passed to
92-
a parameter declared `array<string, …>` (2 sites in Bladestan). The
93-
`ARRAY_FILTER_USE_BOTH` mode has the same gap for the key half.
94-
95-
**Fix:** read the callback's assertions about its key parameter (the
96-
same reconciliation an `if (is_string($k))` body already gets) and
97-
rebuild the result's key type from what survives.
79+
No outstanding items.

examples/php/completion.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3163,6 +3163,11 @@ public function keysAndValues(Scaffolding\ScaffoldingArrayFunc $src): void
31633163
$present = array_filter($src->optionalLabels());
31643164
strtoupper($present['ink']); // array<string, string>
31653165

3166+
// ARRAY_FILTER_USE_KEY hands the callback the key, so what the
3167+
// callback proves about it describes the keys that survive.
3168+
$stringKeyed = array_filter($src->mixedKeys(), fn($key) => is_string($key), ARRAY_FILTER_USE_KEY);
3169+
strtoupper(array_keys($stringKeyed)[0]); // list<string>: the int key is gone
3170+
31663171
// An all-int array cannot sum to a float.
31673172
$total = array_sum($src->weights());
31683173
intdiv($total, 1); // int

examples/php/scaffolding/assertions.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ function runDemoAssertions(): void
209209
assert(array_keys((new Scaffolding\ScaffoldingArrayFunc())->labels()) === [0, 1], 'array_keys() over a list yields int keys');
210210
assert(array_search('gel', (new Scaffolding\ScaffoldingArrayFunc())->labels()) === 1, 'array_search() over a list yields an int key');
211211
assert(array_key_first((new Scaffolding\ScaffoldingArrayFunc())->byName()) === 'blue', 'array_key_first() over a string-keyed array yields a string');
212+
$stringKeyed = array_filter((new Scaffolding\ScaffoldingArrayFunc())->mixedKeys(), fn($key) => is_string($key), ARRAY_FILTER_USE_KEY);
213+
assert(array_keys($stringKeyed) === ['ink'], 'array_filter() with ARRAY_FILTER_USE_KEY keeps only the keys its callback approves of');
212214

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

examples/php/scaffolding/scaffolding.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,9 @@ public function labels(): array { return ['ink', 'gel']; }
765765
/** @return array<string, string|null> */
766766
public function optionalLabels(): array { return ['ink' => 'ink', 'gel' => null]; }
767767

768+
/** @return array<string|int, string> */
769+
public function mixedKeys(): array { return ['ink' => 'gel', 7 => 'nib']; }
770+
768771
/** @return list<int> */
769772
public function weights(): array { return [2, 3, 4]; }
770773
}

src/type_engine/call_resolution/arg_type_resolution.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,28 @@ impl ArrayFuncArgs for TextArrayFuncArgs<'_, '_> {
124124
fn callback_inferred_return_type(&self, index: usize, param_type: &PhpType) -> Option<PhpType> {
125125
Backend::infer_closure_return_type_from_body(self.arg_text(index)?, param_type, self.ctx)
126126
}
127+
128+
fn arg_atom_text(&self, index: usize) -> Option<String> {
129+
let text = self.arg_text(index)?.trim();
130+
let atom = crate::util::strip_fqn_prefix(text);
131+
let is_atom =
132+
!atom.is_empty() && atom.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
133+
is_atom.then(|| atom.to_string())
134+
}
135+
136+
fn callback_param_narrowing(
137+
&self,
138+
index: usize,
139+
param_index: usize,
140+
subject: &PhpType,
141+
) -> Option<PhpType> {
142+
crate::type_engine::variable::callback_narrowing::narrow_callback_param_text(
143+
self.arg_text(index)?,
144+
param_index,
145+
subject,
146+
Some(&self.ctx.class_loader),
147+
)
148+
}
127149
}
128150

129151
impl Backend {

src/type_engine/types/narrowing/guards.rs

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,112 @@ pub(crate) fn guard_kind_to_narrowed_type(kind: TypeGuardKind) -> PhpType {
768768
/// The interface every non-array iterable implements.
769769
const TRAVERSABLE_FQN: &str = "Traversable";
770770

771+
/// The domain a `is_*()` builtin tests its argument against.
772+
///
773+
/// Returns `None` for any other function name.
774+
pub(crate) fn type_guard_kind_from_name(name: &str) -> Option<TypeGuardKind> {
775+
Some(match name.trim_start_matches('\\') {
776+
"is_array" => TypeGuardKind::Array,
777+
"is_string" => TypeGuardKind::String,
778+
"is_int" | "is_integer" | "is_long" => TypeGuardKind::Int,
779+
"is_float" | "is_double" | "is_real" => TypeGuardKind::Float,
780+
"is_bool" => TypeGuardKind::Bool,
781+
"is_object" => TypeGuardKind::Object,
782+
"is_numeric" => TypeGuardKind::Numeric,
783+
"is_callable" => TypeGuardKind::Callable,
784+
"is_null" => TypeGuardKind::Null,
785+
"is_scalar" => TypeGuardKind::Scalar,
786+
"is_resource" => TypeGuardKind::Resource,
787+
"is_iterable" => TypeGuardKind::Iterable,
788+
_ => return None,
789+
})
790+
}
791+
792+
/// Narrow `ty` to the values the `is_*()` builtin named by `name`
793+
/// accepts.
794+
///
795+
/// Returns `None` when `name` is not a type guard, when every value of
796+
/// `ty` already passes it, and when none can.
797+
pub(crate) fn narrow_type_by_guard_name(
798+
name: &str,
799+
ty: &PhpType,
800+
class_loader: GuardClassLoader<'_>,
801+
) -> Option<PhpType> {
802+
let kind = type_guard_kind_from_name(name)?;
803+
let narrowed = filter_type_by_guard(ty, kind, true, class_loader)?;
804+
(!narrowed.is_empty_sentinel()).then_some(narrowed)
805+
}
806+
807+
/// Narrow `ty` to the values of `var_name` that can make `condition`
808+
/// truthy.
809+
///
810+
/// `&&` chains narrow through each operand in turn, `||` chains join what
811+
/// each branch admits, and a negated guard narrows by exclusion. Returns
812+
/// `None` when the condition says nothing about `var_name` (so the caller
813+
/// keeps the type it has) and when it admits no value at all, since an
814+
/// empty type is never a useful answer for a caller that only knows the
815+
/// condition held.
816+
pub(crate) fn narrow_type_by_condition(
817+
condition: &Expression<'_>,
818+
var_name: &str,
819+
ty: &PhpType,
820+
class_loader: GuardClassLoader<'_>,
821+
) -> Option<PhpType> {
822+
let narrowed = narrow_by_condition_inner(condition, var_name, ty, class_loader)?;
823+
(!narrowed.is_empty_sentinel()).then_some(narrowed)
824+
}
825+
826+
fn narrow_by_condition_inner(
827+
condition: &Expression<'_>,
828+
var_name: &str,
829+
ty: &PhpType,
830+
class_loader: GuardClassLoader<'_>,
831+
) -> Option<PhpType> {
832+
match condition {
833+
Expression::Parenthesized(inner) => {
834+
return narrow_by_condition_inner(inner.expression, var_name, ty, class_loader);
835+
}
836+
Expression::Binary(bin)
837+
if matches!(
838+
bin.operator,
839+
BinaryOperator::And(_) | BinaryOperator::LowAnd(_)
840+
) =>
841+
{
842+
// Both operands hold, so the right-hand one narrows whatever
843+
// the left-hand one left behind.
844+
let lhs = narrow_by_condition_inner(bin.lhs, var_name, ty, class_loader);
845+
let base = lhs.as_ref().unwrap_or(ty);
846+
return narrow_by_condition_inner(bin.rhs, var_name, base, class_loader).or(lhs);
847+
}
848+
Expression::Binary(bin)
849+
if matches!(
850+
bin.operator,
851+
BinaryOperator::Or(_) | BinaryOperator::LowOr(_)
852+
) =>
853+
{
854+
// Either operand may be what let the value through, so the
855+
// answer is what they admit between them. An operand that says
856+
// nothing about `var_name` admits everything, which makes the
857+
// whole condition uninformative.
858+
let lhs = narrow_by_condition_inner(bin.lhs, var_name, ty, class_loader)?;
859+
let rhs = narrow_by_condition_inner(bin.rhs, var_name, ty, class_loader)?;
860+
let members: Vec<PhpType> = [lhs, rhs]
861+
.into_iter()
862+
.filter(|m| !m.is_empty_sentinel())
863+
.collect();
864+
return match members.len() {
865+
0 => Some(PhpType::empty_sentinel()),
866+
1 => members.into_iter().next(),
867+
_ => Some(PhpType::join_runtime_value_types(members)),
868+
};
869+
}
870+
_ => {}
871+
}
872+
873+
let (kind, negated) = try_extract_type_guard(condition, var_name)?;
874+
filter_type_by_guard(ty, kind, !negated, class_loader)
875+
}
876+
771877
/// Try to extract a type-guard function call on a variable.
772878
///
773879
/// Matches `is_array($var)`, `is_string($var)`, etc. (with optional
@@ -791,21 +897,7 @@ pub(crate) fn try_extract_type_guard(
791897
}
792898
_ => return None,
793899
};
794-
let kind = match func_name {
795-
"is_array" => TypeGuardKind::Array,
796-
"is_string" => TypeGuardKind::String,
797-
"is_int" | "is_integer" | "is_long" => TypeGuardKind::Int,
798-
"is_float" | "is_double" | "is_real" => TypeGuardKind::Float,
799-
"is_bool" => TypeGuardKind::Bool,
800-
"is_object" => TypeGuardKind::Object,
801-
"is_numeric" => TypeGuardKind::Numeric,
802-
"is_callable" => TypeGuardKind::Callable,
803-
"is_null" => TypeGuardKind::Null,
804-
"is_scalar" => TypeGuardKind::Scalar,
805-
"is_resource" => TypeGuardKind::Resource,
806-
"is_iterable" => TypeGuardKind::Iterable,
807-
_ => return None,
808-
};
900+
let kind = type_guard_kind_from_name(func_name)?;
809901
let args = &fc.argument_list.arguments;
810902
if args.len() != 1 {
811903
return None;

src/type_engine/variable/array_func_rules.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
/// the handful of questions the rules ask about an argument, and the
1818
/// rules stay in one place so a fix to `array_map`'s element type
1919
/// reaches every consumer.
20-
use crate::php_type::{PhpType, TypeKind};
20+
use crate::php_type::{PhpType, TypeKind, is_array_like_name};
2121

2222
use super::{ARRAY_ELEMENT_FUNCS, ARRAY_PRESERVING_FUNCS};
2323

@@ -47,6 +47,21 @@ pub(in crate::type_engine) trait ArrayFuncArgs {
4747
/// function at `index`, with its first parameter seeded to
4848
/// `param_type`.
4949
fn callback_inferred_return_type(&self, index: usize, param_type: &PhpType) -> Option<PhpType>;
50+
51+
/// The argument at `index` written as a bare constant name or integer
52+
/// literal (`ARRAY_FILTER_USE_KEY`, `2`), with any namespace prefix
53+
/// stripped. `None` for any other expression.
54+
fn arg_atom_text(&self, index: usize) -> Option<String>;
55+
56+
/// `subject` narrowed to the values that make the closure or arrow
57+
/// function at `index` accept them through its `param_index`th
58+
/// parameter. `None` when the callback asserts nothing about it.
59+
fn callback_param_narrowing(
60+
&self,
61+
index: usize,
62+
param_index: usize,
63+
subject: &PhpType,
64+
) -> Option<PhpType>;
5065
}
5166

5267
/// For known array-producing functions, resolve the **raw output type**
@@ -83,8 +98,15 @@ pub(in crate::type_engine) fn array_func_raw_type(
8398
// drops `null`, `false`, `0`, `''` and friends. With a
8499
// callback the kept members are whatever it approves of, which
85100
// says nothing about their type.
86-
if func_name.eq_ignore_ascii_case("array_filter") && !args.has_arg(1) {
87-
return Some(filter_element_type(&raw).unwrap_or(raw));
101+
if func_name.eq_ignore_ascii_case("array_filter") {
102+
if !args.has_arg(1) {
103+
return Some(filter_element_type(&raw).unwrap_or(raw));
104+
}
105+
// A callback handed the key decides which keys survive, so
106+
// what it asserts about them describes the result.
107+
if let Some(narrowed) = filter_key_type(&raw, args) {
108+
return Some(narrowed);
109+
}
88110
}
89111
return Some(raw);
90112
}
@@ -210,6 +232,50 @@ fn filter_element_type(raw: &PhpType) -> Option<PhpType> {
210232
}
211233
}
212234

235+
/// Rebuild an `array_filter` result with its key type narrowed to what
236+
/// the callback asserts about the key it was handed.
237+
///
238+
/// Returns `None` unless the call runs in one of the two modes that pass
239+
/// the key (`ARRAY_FILTER_USE_KEY`, `ARRAY_FILTER_USE_BOTH`), the callback
240+
/// proves something about it, and the input carries a key type the proof
241+
/// can narrow.
242+
fn filter_key_type(raw: &PhpType, args: &dyn ArrayFuncArgs) -> Option<PhpType> {
243+
let param_index = filter_key_param_index(args)?;
244+
let key = raw.iterable_key_type()?;
245+
let narrowed = args.callback_param_narrowing(1, param_index, &key)?;
246+
// A callback that admits every key it could receive (`is_int($k) ||
247+
// is_string($k)`) leaves nothing to say, and answering with the
248+
// rebuilt union would only reorder its members.
249+
if key.is_subtype_of(&narrowed) {
250+
return None;
251+
}
252+
let value = raw.extract_value_type(false)?.clone();
253+
// A filter can drop every entry, so the result is a plain `array`
254+
// whatever refinement (`non-empty-array`, `list`) the input carried.
255+
match raw.kind() {
256+
TypeKind::Array(_) => Some(PhpType::generic_array(narrowed, value)),
257+
TypeKind::Generic(g) if is_array_like_name(g.name.as_str()) => {
258+
Some(PhpType::generic_array(narrowed, value))
259+
}
260+
_ => None,
261+
}
262+
}
263+
264+
/// Which of the callback's parameters receives the key, from
265+
/// `array_filter`'s mode argument.
266+
///
267+
/// The default mode passes only the value, so the callback says nothing
268+
/// about the keys and this returns `None`.
269+
fn filter_key_param_index(args: &dyn ArrayFuncArgs) -> Option<usize> {
270+
match args.arg_atom_text(2)?.as_str() {
271+
"ARRAY_FILTER_USE_KEY" | "2" => Some(0),
272+
// `ARRAY_FILTER_USE_BOTH` passes the value first and the key
273+
// second.
274+
"ARRAY_FILTER_USE_BOTH" | "1" => Some(1),
275+
_ => None,
276+
}
277+
}
278+
213279
/// Extract the output element type for `array_map($callback, $array)`.
214280
///
215281
/// Strategy:

0 commit comments

Comments
 (0)