Skip to content

Commit d2f751d

Browse files
committed
Implement first-class callable immediate invocation and precise binary
expression type inference
1 parent ac62fed commit d2f751d

12 files changed

Lines changed: 729 additions & 192 deletions

File tree

docs/CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- **Closure and arrow function inlay hints.** When a closure or arrow function is passed to a callable-typed parameter, inlay hints show inferred parameter types and the return type. Types are derived from the enclosing callable signature with generic substitution from sibling arguments.
2424
- **Return type inference from method bodies.** Methods without a declared return type or `@return` docblock now have their return type inferred from `return` statements, improving completion, hover, and diagnostics for untyped code.
2525
- **Untyped property type inference from constructor.** Properties without type declarations are resolved by inspecting the constructor body for assignments and promoted parameter defaults. PHPDoc generation uses the inferred type. Contributed by @lucasacoutinho in https://github.com/AJenbo/phpantom_lsp/pull/81.
26-
- **Binary expression type inference.** Hover and variable resolution now show result types for all binary operators: arithmetic (`int|float`), concatenation (`string`), comparison and logical (`bool`), bitwise (`int`), and `+` distinguishes array union from numeric addition based on operand types.
26+
- **Binary expression type inference.** Hover and variable resolution now show result types for all binary operators with operand-type-aware arithmetic (`int + int``int`, `int + float``float`, `int / int``int|float`). Concatenation returns `string`, comparison and logical return `bool`, bitwise returns `int` (or `string` when both operands are strings), spaceship returns `int`, and `+` distinguishes array union from numeric addition. Compound assignments (`/=`, `*=`, `%=`, `**=`, `.=`, `<<=`, `&=`, `|=`, `^=`, `??=`) update the variable's type with the same operand-aware rules. Incrementing a numeric string literal (`$a = "123"; $a++`) produces `int|float`. Parenthesized binary expressions resolve correctly through the structural inference path.
2727
- **`array_reduce`, `array_sum`, and `array_product` return type inference.** `array_reduce()` resolves to the type of its initial value argument. `array_sum()` and `array_product()` resolve to `int|float`.
2828
- **`global` keyword variable resolution.** Variables imported with `global $var` now resolve to their top-level type, enabling completion, hover, and go-to-definition.
2929
- **Nested array shape inference from multi-level key assignments.** Assignments like `$b['a']['b'] = 'x'` now produce a nested array shape type (`array{a: array{b: string}}`), enabling array key completion for arrays built incrementally with nested keys.
@@ -33,9 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333

3434
### Fixed
3535

36-
- **`isset()` narrowing.** `isset($x)` in a condition now strips `null` from the variable's type in the truthy branch (matching `$x !== null` semantics). `!isset($x)` narrows to `null` only. Both simple variables and property access (`$obj->prop`) are supported, and multiple arguments (`isset($a, $b)`) narrow all listed variables. Guard clauses (`if (!isset($x)) { return; }`) strip null from the variable in the code that follows.
36+
- **First-class callable invocation return types.** Immediately invoking a first-class callable (`Foo::method(...)()`, `$this->method(...)()`, `self::method(...)()`, `static::method(...)()`, `parent::method(...)()`, `func(...)()`) now resolves to the underlying method or function's return type. Previously these expressions returned no type.
37+
- **`isset()` and `empty()` narrowing.** `isset($x)` in a condition now strips `null` from the variable's type in the truthy branch (matching `$x !== null` semantics). `!isset($x)` narrows to `null` only. `!empty($x)` strips `null` from nullable types (e.g. `string|null` narrows to `string`). Both simple variables and property access (`$obj->prop`) are supported, and multiple arguments (`isset($a, $b)`) narrow all listed variables. Guard clauses (`if (!isset($x)) { return; }`) strip null from the variable in the code that follows.
3738
- **Hover scales linearly on large files.** Processing many hover requests on the same file (e.g. a test suite with 80+ assertion calls) no longer takes O(n²) time. The first hover on a method body walks it once and caches scope snapshots; every subsequent hover on the same file content is an O(log n) lookup with no re-walk.
3839

40+
- **`@return numeric` pseudo-type.** Functions annotated with `@return numeric` now resolve to the `numeric` pseudo-type instead of `string`. Arithmetic on `numeric` operands correctly infers `int|float`, and `$a++` on a `numeric` variable yields `int|float`. The underlying cause was that same-file function calls in multi-namespace files fell through to an incorrect fallback when the file-level namespace differed from the function's namespace.
3941
- **`parent::__construct()` with `@extends` generics.** Calling `parent::__construct($arg)` in a child class that specifies `@extends Parent<Concrete>` no longer produces false-positive type errors for the substituted parameter types.
4042
- **Array access on bare `array` and `mixed` types.** Accessing a key on a parameter typed as plain `array` (e.g. `$params['key']`) now resolves to `mixed` instead of an empty type, eliminating false-positive type errors downstream (e.g. `$x = $params['key'] ?? null` followed by an `is_string()` guard).
4143
- **Analyzer and LSP no longer hang on files with deeply nested loops.** Files with multiple levels of foreach/while/for inside if-branches no longer cause exponential blowup.

docs/todo/bugs.md

Lines changed: 0 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -9,81 +9,6 @@ to second-guess upstream output.
99

1010

1111

12-
## B7. `empty()` narrowing resolves to `null` instead of `mixed|null`
13-
14-
**Discovered:** Psalm `TypeReconciliation/EmptyTest.php` porting
15-
(Phase 3.5B).
16-
17-
When `empty($a)` is true for a `mixed` parameter, the variable should
18-
retain its base type intersected with falsy values (`mixed|null`), not
19-
collapse entirely to `null`.
20-
21-
**When fixed:** Create `tests/psalm_assertions/type_reconciliation_empty.php`
22-
with this content and verify it passes:
23-
24-
```php
25-
<?php
26-
// Source: Psalm TypeReconciliation/EmptyTest.php
27-
namespace PsalmTest_type_reconciliation_empty_1 {
28-
/** @param mixed $a */
29-
function foo($a): void {
30-
if (empty($a)) {
31-
assertType('mixed|null', $a);
32-
}
33-
}
34-
}
35-
```
36-
37-
## B8. Binary expression type inference gaps
38-
39-
**Discovered:** SKIP audit across `tests/phpstan_nsrt/binary.php`
40-
and `tests/psalm_assertions/binary_operation.php`.
41-
42-
The hover/forward-walk pipeline does not resolve types for many
43-
binary expressions:
44-
45-
- **String concatenation with int LHS:** `$integer . $string`
46-
resolves to no type instead of `string`.
47-
- **Spaceship operator:** `'foo' <=> 'bar'` resolves to no type
48-
instead of `int`.
49-
- **Bitwise on strings:** `"x" & "y"`, `$string & "x"`, `~"a"`
50-
resolve to `int` instead of `string`. PHP applies bitwise ops
51-
character-by-character when both operands are strings.
52-
- **Logical operators:** `true && false`, `true || false`,
53-
`true xor false`, `!true` resolve to no type instead of `bool`.
54-
`xor` on bools resolves to `int` instead of `bool`.
55-
- **Compound assignment:** `/=`, `*=`, `%=`, `**=`, `<<=`, `&=`,
56-
`|=`, `^=` resolve to no type instead of the correct result type.
57-
- **Mixed arithmetic:** `1 + $mixed`, `$mixed / 1` resolve to no
58-
type instead of `float|int`.
59-
- **Exponent:** `4 ** 5` resolves to `int|float` instead of `int`
60-
(when both operands are non-negative integers).
61-
- **Numeric string increment:** `$a = "123"; $a++` resolves to
62-
`string` instead of `float|int`.
63-
- **String concat compound:** `$d -= getNumeric()` with numeric
64-
return resolves to `int` instead of `float|int`.
65-
66-
**Tests:** SKIPs in `tests/phpstan_nsrt/binary.php` (lines 153,
67-
168, 183-188, 218-224, 229-242, 247-250) and
68-
`tests/psalm_assertions/binary_operation.php` (lines 10, 17, 56,
69-
68-69, 87, 100, 165, 175-176).
70-
71-
## B10. First-class callable invocation return types
72-
73-
**Discovered:** SKIP audit of
74-
`tests/phpstan_nsrt/static-late-binding.php`.
75-
76-
`Foo::method(...)` creates a `Closure` from a method, and
77-
`Foo::method(...)()` immediately invokes it. The return type of the
78-
invocation should match the method's return type. Currently hover
79-
returns no type for these expressions.
80-
81-
This also affects `static::method(...)()`, `self::method(...)()`,
82-
`parent::method(...)()`, and `$this->method(...)()`.
83-
84-
**Tests:** SKIPs in `tests/phpstan_nsrt/static-late-binding.php`
85-
(lines 72-78, 88, 90-97).
86-
8712
## B11. Unbound template parameter resolves to raw name instead of `mixed`
8813

8914
**Discovered:** SKIP audit of

example.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2489,6 +2489,14 @@ public function demo(): void
24892489
$make = makePen(...);
24902490
$pen = $make();
24912491
$pen->color(); // assigned result from callable invocation
2492+
2493+
// Immediate invocation: method(...)() returns the method's return type
2494+
makePen(...)()->write(); // function first-class callable invoked immediately
2495+
Pen::make(...)()->color(); // static method first-class callable invoked immediately
2496+
$src->dispatch(...)()->write(); // instance method first-class callable invoked immediately
2497+
2498+
$immediate = Pen::make(...)();
2499+
$immediate->color(); // assigned result from immediate static callable invocation
24922500
}
24932501
}
24942502

@@ -6231,6 +6239,14 @@ function runDemoAssertions(): void
62316239
$methodResult = $methodCallable();
62326240
assert($methodResult instanceof Pen, 'dispatch(...)() must return Pen');
62336241

6242+
// Immediate invocation: method(...)() returns the method's return type
6243+
$immediateFunc = makePen(...)();
6244+
assert($immediateFunc instanceof Pen, 'makePen(...)() immediate must return Pen');
6245+
$immediateStatic = Pen::make(...)();
6246+
assert($immediateStatic instanceof Pen, 'Pen::make(...)() immediate must return Pen');
6247+
$immediateMethod = $src2->dispatch(...)();
6248+
assert($immediateMethod instanceof Pen, '$obj->dispatch(...)() immediate must return Pen');
6249+
62346250
// ── Class alias (use ... as) ────────────────────────────────────────
62356251
$aliasProfile = new Profile($userForProfile);
62366252
assert($aliasProfile instanceof UserProfile, 'Profile alias must be UserProfile');

0 commit comments

Comments
 (0)