Skip to content

Commit 889519c

Browse files
committed
Fix array shape issues
1 parent 5d251c0 commit 889519c

15 files changed

Lines changed: 444 additions & 179 deletions

File tree

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
122122
### Fixed
123123

124124
- **A `"\x8b"` escape no longer takes the server down with it.** A hex or octal escape in a double-quoted string decodes to a raw byte, and a string built that way (`strpos($output, "\x8b")`, a gzip magic number, a binary delimiter) is text no character encoding can express. PHPantom read those literals as though they were ordinary source text, which is undefined behaviour: on the command line the analysis died part-way through, in the editor the server exited, and in a release build it silently read whatever happened to follow in memory. Every place a literal's value is read now checks it first, so a literal like this is simply one PHPantom has nothing to say about. The rest of the file is analysed as before.
125+
- **An array literal keeps the value written at each position.** `[$violation, $file, $line]` collapsed to a list of everything it held, so every way of reading one slot back out gave the same `RuleViolation|string|int`: destructuring the row with `[$violation, $file, $line] = $row`, indexing it with `$row[1]`, or pulling it out of the collection it was pushed into. Rows written this way now record what sits at each position, so a slot read is the one value that slot holds, and passing it on no longer reports a type mismatch against everything the row happened to contain. This already worked for a literal nested inside another; it now holds wherever the literal is written. A literal that spreads another array, or one long enough that its arity is beside the point, still describes itself as a list.
126+
- **A literal keyed on something PHP works out at runtime is described by its keys and values.** `[$name => 1]` was reported as `array{mixed: 1}`, a shape with a field named after the key's type rather than after anything in the code, and a read off it found neither the key that was written nor the one that was asked for. Such a literal is now an `array<K, V>` built from the key and value types it does hold, and a key that is not written as a plain string or integer keeps the type it resolves to, so `[Event::class => $handler]` satisfies a parameter declared `array<class-string, …>`. PHP coerces `null`, `true` and `false` before using them as keys, so those now land on the `''`, `1` and `0` entries they index at runtime.
127+
- **`(object) []` is a `stdClass`.** Casting an empty array to an object produced an `object{}`, a shape with no properties, which nothing else in the engine produces and which was rejected by every parameter declared `stdClass`. It is now the `stdClass` PHP builds. A cast of a non-empty array still keeps the properties it names.
128+
- **`+` between two arrays keeps their keys.** The compound `+=` already merged both operands' keys, but writing the same union as `$merged = $defaults + $overrides` collapsed it to a bare `array`, so nothing about the merged array completed or hovered. Both spellings now go through the same merge.
125129
- **`$matches` outside the guard says the match may not have happened.** `preg_match()` fills its out-parameter with the keys the pattern describes, and that shape was applied wherever the call appeared, the code after a guard that may not have been taken included. So a group read on a line PHP reaches with the empty array a failed match leaves behind was typed as a plain `string`, and nothing said the key might not be there. The call now writes what it leaves either way, and the condition that tests its result decides which of the two a branch is looking at: inside the guard the keys are there and a group read is a `string`, the branch that runs on a failed match gets the empty array, and where the two rejoin the keys are marked as ones that may be missing. Comparing the result guards exactly as the bare call does, in an `if`, an `elseif`, a guard clause that returns on a failed match, and a `while`. `preg_match_all()` is unaffected, since a failed match there still writes one empty list per capture group.
126130
- **Reading a key a shape marks optional carries the `null` a missing offset yields.** A `@var array{file: string, type?: string}` says the `type` entry may not be there, but reading it produced the same `string` a required key does, so a value that is absent at runtime was passed on as though it could not be. The read now says it may be missing, which is what makes the `??` and the `isset()` around it mean something. A key the shape requires is unchanged.
127131
- **`!empty($row['name'])` proves the key is there.** `isset($row['name'])` narrowed the entry it named, but the `!empty()` spelling of the same check only ever narrowed a plain variable, so a proof about an array entry or a property path was dropped. Both spellings now record what they prove, in an expression position (a ternary, a `match (true)` arm) as much as in an `if` body, so the guarded read no longer reports the falsy half the guard ruled out.

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ within the same impact tier.
2525

2626
| # | Item | Impact | Effort |
2727
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- |
28-
| B147 | [Array literals are not tuples: slot reads return the union of all elements](todo/bugs.md#b147-array-literals-are-not-tuples-slot-reads-return-the-union-of-all-elements) | Medium-High | Medium |
2928
| | **Release 0.10.0** | | |
3029

3130
## Sprint 7 — 1.0 release & IDE extensions
@@ -100,6 +99,7 @@ unlikely to move the needle for most users.
10099
| D17 | [`docblock_native_mismatch` only judges nullability](todo/diagnostics.md#d17-docblock_native_mismatch-only-judges-nullability) | Low | Medium |
101100
| | **[Bug Fixes](todo/bugs.md)** | | |
102101
| 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 |
103103
| | **[Code Actions](todo/actions.md)** | | |
104104
| A40 | [Generate method from call](todo/actions.md#a40-generate-method-from-call) | Medium-High | Medium |
105105
| 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: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -76,46 +76,22 @@ have been guarded out) rather than removing it outright.
7676

7777
## Array types
7878

79-
### B147. Array literals are not tuples: slot reads return the union of all elements
79+
### B152. `array_filter` with `ARRAY_FILTER_USE_KEY` does not narrow the key type
8080

81-
**Impact: Medium-High · Effort: Medium**
81+
**Impact: Low-Medium · Effort: Medium**
8282

8383
```php
84-
$rows[] = [$violation, $location, $name]; // RuleViolation, string, string
85-
foreach ($rows as $row) {
86-
[$violation, $location, $name] = $row; // each: RuleViolation|string
87-
$writer->write($location); // reported: RuleViolation|string
88-
}
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>
8987
```
9088

91-
A list literal collapses to `array<union-of-values>`, so list
92-
destructuring and constant-offset reads cannot select a slot (6 sites
93-
in PHPMD/PDepend). Two adjacent literal defects: a literal with a
94-
*non-constant* key renders as the bogus shape `array{mixed: int}`
95-
(stringifying the key's type as a field name) instead of falling back
96-
to `array<K, V>`, and `(object) []` is not recognised as `stdClass`.
97-
98-
**Fix:** keep constant-array shapes for literals (ordered slots +
99-
known keys), select slots on destructure/offset reads, fall back to a
100-
generic array only for non-constant keys.
101-
102-
### B148. Element writes do not refine tracked array state
103-
104-
**Impact: Medium · Effort: Medium-High**
105-
106-
Several forms of the same weakness (~7 sites):
107-
108-
- `$a[$k][] = $v` never updates the inner element type: a value
109-
initialised as `[]` stays `array{}` in the outgoing type even
110-
though every loop iteration appends strings.
111-
- A key written on every path through a loop body leaves
112-
`array<int, string>` where PHPStan reports
113-
`non-empty-array<int, string>`.
114-
- `$a += ['slot' => $obj]` degrades to unconstrained `array`.
115-
- A constant shape `array{item: string, qty: int}` fails the subtype
116-
check against `array<string, mixed>`, so shaped rows are rejected
117-
by a declared `array<int, array<string, mixed>>`.
118-
119-
**Fix:** refine the per-key state on nested writes (including
120-
auto-vivification), merge `+=` like an array-shape union, and make
121-
constant shapes satisfy their generic supertypes.
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.

examples/php/completion.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2841,6 +2841,38 @@ public function inferredTuples(): void
28412841
$tool = new $toolClass();
28422842
$tool->label(); // Scaffolding\Pen|Scaffolding\Pencil created from the class-string
28432843
}
2844+
2845+
// A literal written straight into a variable keeps its arity too,
2846+
// so a row pushed into a collection destructures back into the
2847+
// values it was written with rather than the union of all of them.
2848+
$entries = [];
2849+
$entries[] = [new Scaffolding\Pen(), 'sketchbook'];
2850+
foreach ($entries as $entry) {
2851+
[$writer, $surface] = $entry;
2852+
$writer->write(); // Scaffolding\Pen (slot 0, not Pen|string)
2853+
strlen($surface); // string (slot 1, not Pen|string)
2854+
}
2855+
}
2856+
2857+
public function runtimeArrayKeys(string $slot): void
2858+
{
2859+
// A key PHP only works out at runtime names no shape field, so the
2860+
// literal is described by the key and value types it does have.
2861+
$bySlot = [$slot => new Scaffolding\Pen()]; // array<string, Scaffolding\Pen>
2862+
foreach ($bySlot as $held) {
2863+
$held->write(); // Scaffolding\Pen
2864+
}
2865+
2866+
// `+` keeps the left side's keys and adds the right side's, the
2867+
// same union `+=` performs.
2868+
$merged = ['pen' => new Scaffolding\Pen()] + ['pencil' => new Scaffolding\Pencil()];
2869+
$merged['pen']->write(); // Scaffolding\Pen from the left operand
2870+
$merged['pencil']->sketch(); // Scaffolding\Pencil from the right operand
2871+
2872+
// Casting an empty array gives the property-less stdClass PHP
2873+
// builds, not an object shape.
2874+
$bare = (object) []; // stdClass
2875+
echo get_class($bare);
28442876
}
28452877
}
28462878

examples/php/scaffolding/assertions.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,32 @@ function runDemoAssertions(): void
298298
assert($tool instanceof Scaffolding\Pen || $tool instanceof Scaffolding\Pencil, 'class-string must instantiate Scaffolding\Pen|Scaffolding\Pencil');
299299
}
300300

301+
// A row pushed as a literal destructures back into the values it was
302+
// written with, one per position.
303+
$entries = [];
304+
$entries[] = [new Scaffolding\Pen(), 'sketchbook'];
305+
foreach ($entries as $entry) {
306+
[$writer, $surface] = $entry;
307+
assert($writer instanceof Scaffolding\Pen, 'pushed tuple slot 0 must be Scaffolding\Pen');
308+
assert(is_string($surface), 'pushed tuple slot 1 must be a string');
309+
}
310+
311+
// A key worked out at runtime still holds the value that was written
312+
// against it.
313+
$slot = 'ink';
314+
$bySlot = [$slot => new Scaffolding\Pen()];
315+
assert($bySlot['ink'] instanceof Scaffolding\Pen, 'a runtime key must hold the value written against it');
316+
317+
// `+` keeps the left operand's keys and adds the right operand's.
318+
$merged = ['pen' => new Scaffolding\Pen()] + ['pencil' => new Scaffolding\Pencil()];
319+
assert($merged['pen'] instanceof Scaffolding\Pen, 'array union must keep the left key');
320+
assert($merged['pencil'] instanceof Scaffolding\Pencil, 'array union must add the right key');
321+
322+
// Casting an empty array produces a property-less stdClass.
323+
$bare = (object) [];
324+
assert($bare instanceof \stdClass, '(object) [] must be a stdClass');
325+
assert(get_object_vars($bare) === [], '(object) [] must have no properties');
326+
301327
// ── Indexing an ArrayAccess Object ───────────────────────────────────
302328
$penAccess = new Scaffolding\ScaffoldingPenArrayAccess();
303329
assert($penAccess[0] instanceof Scaffolding\Pen, 'ArrayAccess[0] must resolve via offsetGet(): Scaffolding\Pen');

src/php_type/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,36 @@ impl PhpType {
19131913
}
19141914
}
19151915

1916+
/// The generic array type a constant shape describes, dropping the
1917+
/// per-key detail: `array{a: int, b: string}` → `array<string,
1918+
/// int|string>`, `array{User, Order}` → `list<User|Order>`.
1919+
///
1920+
/// Anything that is not an array shape is returned unchanged, so a
1921+
/// caller that only needs a container it can read a key and value type
1922+
/// off can pass any type through. An empty shape has no key or value
1923+
/// type to name and becomes a bare `array`.
1924+
pub fn generalized_array(&self) -> PhpType {
1925+
let TypeKind::ArrayShape(entries) = self.kind() else {
1926+
return self.clone();
1927+
};
1928+
if entries.is_empty() {
1929+
return PhpType::array();
1930+
}
1931+
let Some(value) = self.iterable_element_type() else {
1932+
return PhpType::array();
1933+
};
1934+
// Only an all-positional shape promises the `0, 1, 2, …` keys a
1935+
// `list` does; the moment one entry is named the result has to
1936+
// spell its key type out.
1937+
if entries.iter().all(|e| e.key.is_none()) {
1938+
return PhpType::list(value);
1939+
}
1940+
match self.iterable_key_type() {
1941+
Some(key) => PhpType::generic_array(key, value),
1942+
None => PhpType::generic_array_val(value),
1943+
}
1944+
}
1945+
19161946
/// Extract the element (value) type from an iterable, including
19171947
/// scalar element types.
19181948
///

src/type_engine/variable/array_func_rules.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ pub(in crate::type_engine) fn array_func_raw_type(
6363
.iter()
6464
.any(|f| f.eq_ignore_ascii_case(func_name))
6565
{
66-
let raw = args.arg_raw_type(0)?;
66+
// Every one of these rearranges the array (reorders, renumbers,
67+
// drops or chunks entries), so a constant shape does not survive
68+
// the call and is generalized to the container it describes.
69+
let raw = args.arg_raw_type(0)?.generalized_array();
6770
// Only a parameterised iterable carries an element type worth
6871
// preserving; a bare `array`/`iterable` is a `Named` kind with no
6972
// value argument to extract, so the rule declines and the stub's
@@ -144,7 +147,7 @@ pub(in crate::type_engine) fn array_func_element_type(
144147
// A scalar element is the honest answer for `array_pop(list<string>)`
145148
// just as `User` is for `list<User>`, so the element type is read
146149
// without `skip_scalar`.
147-
return args.arg_raw_type(0)?.extract_value_type(false).cloned();
150+
return args.arg_raw_type(0)?.iterable_element_type();
148151
}
149152

150153
// `array_sum`/`array_product` are declared `int|float` because the
@@ -154,7 +157,7 @@ pub(in crate::type_engine) fn array_func_element_type(
154157
// `@return TValue` would answer `string` for `array_sum(list<string>)`
155158
// rather than the `int|float` PHP actually produces.
156159
if matches!(func_name, "array_sum" | "array_product") {
157-
let element = args.arg_raw_type(0)?.extract_value_type(false)?.clone();
160+
let element = args.arg_raw_type(0)?.iterable_element_type()?;
158161
let members: Vec<&PhpType> = match element.kind() {
159162
TypeKind::Union(m) => m.iter().collect(),
160163
_ => vec![&element],
@@ -222,7 +225,7 @@ fn array_map_element_type(args: &dyn ArrayFuncArgs) -> Option<PhpType> {
222225
return Some(declared);
223226
}
224227

225-
let input_element = args.arg_raw_type(1)?.extract_element_type()?.clone();
228+
let input_element = args.arg_raw_type(1)?.iterable_element_type()?;
226229

227230
if let Some(inferred) = args.callback_inferred_return_type(0, &input_element) {
228231
return Some(inferred);

0 commit comments

Comments
 (0)