Skip to content

Commit 0e8a919

Browse files
committed
Callable parameter type parsing in union types
1 parent 67cf54b commit 0e8a919

6 files changed

Lines changed: 569 additions & 18 deletions

File tree

docs/CHANGELOG.md

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

4646
### Fixed
4747

48+
- **Callable parameter type parsing in union types.** `extract_callable_param_types` now handles callable signatures wrapped in unions like `(Closure(Builder<TModel>): mixed)|null`, `callable(Collection<int, TValue>, int): mixed|null`, and `null|callable(Order): bool`. Previously the parser treated the entire union as one token and failed to find the callable signature. The `split_type_token` tokenizer was also fixed to keep generic suffixes like `Collection<int, User>|null` and parenthesized groups like `(Closure(X): Y)|null` as single tokens instead of splitting at the closing `>`, `}`, or `)`. This enables closure parameter inference for Laravel methods like `Builder::whereHas()`, `BuildsQueries::chunk()`, and `Builder::with()`, where the callback parameter types use these union patterns.
4849
- **Blank lines inside method chains no longer break resolution.** A blank (or whitespace-only) line between segments of a fluent chain (e.g. `Brand::with('english')\n\n ->paginate()`) caused the backward walk in `collapse_continuation_lines` to stop prematurely, treating the empty line as the base expression. Neither completion nor go-to-definition worked in this case. The backward walk now skips blank lines, and the collapsed result omits them, so chains with cosmetic spacing resolve correctly.
4950
- **Arrow function parameter go-to-definition.** Go-to-definition on a variable used in an arrow function body (e.g. `$o` in `fn(Order $o) => $o->getItems()`) now jumps to the parameter on the same line. Previously the backward scan excluded the cursor line, so it would skip the arrow function parameter and jump to an unrelated earlier variable with the same name. The fix includes the cursor line in the scan but only accepts non-assignment definitions (parameters, foreach, catch, static/global) to preserve the existing behavior for reassignments like `$value = $value->value`. Go-to-definition on the parameter itself (the LHS `$o`) correctly resolves the type hint and jumps to the `Order` class.
5051
- **Relationship property collection type uses the related model's custom collection.** When a model like `Product` had a `HasMany<Review, $this>` relationship, the virtual property `$product->reviews` incorrectly used the owning model's custom collection (`ProductCollection`) instead of the related model's (`ReviewCollection`). The provider now loads the related model via `class_loader` and reads its `custom_collection` field. The integration test was updated to use distinct collections per model so the bug is no longer masked.

docs/todo-laravel.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,6 @@ virtual member provider design, see `ARCHITECTURE.md`.
88

99
---
1010

11-
## Missing features
12-
13-
### 2. Closure parameter inference in collection pipelines
14-
15-
`$users->map(fn($u) => $u->...)` does not infer `$u` as the
16-
collection's element type. This is a general generics/callable
17-
inference problem, not Laravel-specific, but Laravel collection
18-
pipelines are the most common place users encounter it.
19-
Other cases:
20-
- MyModel::whereIn()->chunk(self::CHUNK_SIZE, function (Collection $orders) {})
21-
- MyModel::whereHas('order', function (Builder $q) {})
22-
- MyModel::with(['translations' => function (Relation $query) {}]) // translations is the name of the relation on MyModel, Relation will become the return type of that relation
23-
24-
---
25-
2611
## Out of scope (and why)
2712

2813
| Item | Reason |

example.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,24 @@ public function explicitTypeWins(): void
17791779
// Explicit type hint takes precedence over inference.
17801780
$this->users->map(fn(Order $o) => $o->customer);
17811781
}
1782+
1783+
public function chunkInference(): void
1784+
{
1785+
// $orders is inferred as Collection from chunk's
1786+
// callable(Collection<int, TModel>, int) signature.
1787+
\App\Models\BlogAuthor::where('active', true)->chunk(100, function ($orders) {
1788+
$orders->count(); // resolves to Eloquent Collection
1789+
});
1790+
}
1791+
1792+
public function whereHasInference(): void
1793+
{
1794+
// $q is inferred as Builder from whereHas's
1795+
// Closure(Builder<TModel>): mixed signature.
1796+
\App\Models\BlogAuthor::whereHas('posts', function ($q) {
1797+
$q->where('published', true); // resolves to Builder
1798+
});
1799+
}
17821800
}
17831801

17841802

@@ -2878,6 +2896,20 @@ public function where($column, $operator = null, $value = null, $boolean = 'and'
28782896

28792897
/** @return \Illuminate\Database\Eloquent\Collection<int, TModel> */
28802898
public function get($columns = ['*']) { return new Collection(); }
2899+
2900+
/**
2901+
* @param string $relation
2902+
* @param (\Closure(\Illuminate\Database\Eloquent\Builder<TModel>): mixed)|null $callback
2903+
* @return static
2904+
*/
2905+
public function whereHas(string $relation, ?\Closure $callback = null): static { return $this; }
2906+
2907+
/**
2908+
* @param array<array-key, array|(\Closure(\Illuminate\Database\Eloquent\Relations\Relation): mixed)|string>|string $relations
2909+
* @param (\Closure(\Illuminate\Database\Eloquent\Relations\Relation): mixed)|string|null $callback
2910+
* @return static
2911+
*/
2912+
public function with($relations, $callback = null): static { return $this; }
28812913
}
28822914

28832915
/**
@@ -2892,6 +2924,17 @@ public function count(): int { return 0; }
28922924
}
28932925

28942926
namespace Illuminate\Database\Eloquent\Relations {
2927+
/**
2928+
* @template TRelated of \Illuminate\Database\Eloquent\Model
2929+
* @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
2930+
* @template TResult
2931+
*/
2932+
class Relation {
2933+
/** @return static */
2934+
public function where(string $column, $operator = null, $value = null): static { return $this; }
2935+
/** @return static */
2936+
public function orderBy(string $column, string $direction = 'asc'): static { return $this; }
2937+
}
28952938
class HasMany {}
28962939
class HasOne {}
28972940
class BelongsTo {}
@@ -2924,6 +2967,12 @@ trait HasCollection {}
29242967
trait BuildsQueries {
29252968
/** @return TValue|null */
29262969
public function first($columns = ['*']) { return null; }
2970+
2971+
/**
2972+
* @param callable(\Illuminate\Support\Collection<int, TValue>, int): mixed $callback
2973+
* @return bool
2974+
*/
2975+
public function chunk(int $count, callable $callback): bool { return true; }
29272976
}
29282977
}
29292978

@@ -2952,6 +3001,27 @@ public function get($columns = ['*']) {}
29523001
}
29533002
}
29543003

3004+
namespace Illuminate\Support {
3005+
3006+
/**
3007+
* @template TKey of array-key
3008+
* @template TValue
3009+
*/
3010+
class Collection {
3011+
/** @return int */
3012+
public function count(): int { return 0; }
3013+
/** @return TValue|null */
3014+
public function first(): mixed { return null; }
3015+
/** @return array<TKey, TValue> */
3016+
public function all(): array { return []; }
3017+
/**
3018+
* @param callable(TValue, TKey): mixed $callback
3019+
* @return static
3020+
*/
3021+
public function each(callable $callback): static { return $this; }
3022+
}
3023+
}
3024+
29553025
namespace Illuminate\Contracts\Database\Eloquent {
29563026
/**
29573027
* @mixin \Illuminate\Database\Eloquent\Builder

src/docblock/types.rs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,11 @@ pub(crate) fn split_type_token(s: &str) -> (&str, &str) {
5353
angle_depth -= 1;
5454
// If we just closed the outermost `<`, the type ends here
5555
// (but only when we're not also inside braces or parens).
56+
// Continue consuming any union/intersection suffix so
57+
// that `Collection<int, User>|null` stays one token.
5658
if angle_depth == 0 && brace_depth == 0 && paren_depth == 0 {
5759
let end = i + c.len_utf8();
60+
let end = consume_union_intersection_suffix(s, end);
5861
return (&s[..end], &s[end..]);
5962
}
6063
}
@@ -63,8 +66,11 @@ pub(crate) fn split_type_token(s: &str) -> (&str, &str) {
6366
brace_depth -= 1;
6467
// If we just closed the outermost `{`, the type ends here
6568
// (but only when we're not also inside angle brackets or parens).
69+
// Continue consuming any union/intersection suffix so
70+
// that `array{id: int}|null` stays one token.
6671
if brace_depth == 0 && angle_depth == 0 && paren_depth == 0 {
6772
let end = i + c.len_utf8();
73+
let end = consume_union_intersection_suffix(s, end);
6874
return (&s[..end], &s[end..]);
6975
}
7076
}
@@ -91,12 +97,24 @@ pub(crate) fn split_type_token(s: &str) -> (&str, &str) {
9197
let ret_start_in_s = colon_start_in_s
9298
+ (after_colon.as_ptr() as usize
9399
- s[colon_start_in_s..].as_ptr() as usize);
94-
let end = ret_start_in_s + ret_tok.len();
100+
let mut end = ret_start_in_s + ret_tok.len();
101+
102+
// After a callable return type, continue
103+
// consuming union/intersection suffixes so
104+
// that `(Closure(Builder): mixed)|null`
105+
// is kept as one token.
106+
end = consume_union_intersection_suffix(s, end);
107+
95108
return (&s[..end], &s[end..]);
96109
}
97110
}
98-
// No return type — the token ends here (e.g. bare `callable(int)`).
99-
return (&s[..after_paren], &s[after_paren..]);
111+
// After a bare parenthesized group (no callable
112+
// return type), continue consuming any
113+
// union/intersection suffix. This handles DNF
114+
// types like `(A&B)|C` and grouped callables
115+
// like `(Closure(X): Y)|null`.
116+
let end = consume_union_intersection_suffix(s, after_paren);
117+
return (&s[..end], &s[end..]);
100118
}
101119
}
102120
c if c.is_whitespace() && angle_depth == 0 && brace_depth == 0 && paren_depth == 0 => {
@@ -109,6 +127,46 @@ pub(crate) fn split_type_token(s: &str) -> (&str, &str) {
109127
(s, "")
110128
}
111129

130+
/// After a parenthesized type group or callable return type, consume
131+
/// any `|Type` or `&Type` continuation so the full union/intersection
132+
/// is kept as a single token.
133+
///
134+
/// `pos` is the byte offset just past the already-consumed portion of
135+
/// `s`. Returns the updated end offset after consuming zero or more
136+
/// `|`/`&`-separated type parts.
137+
fn consume_union_intersection_suffix(s: &str, pos: usize) -> usize {
138+
let mut end = pos;
139+
loop {
140+
let rest = &s[end..];
141+
// Allow optional whitespace before the operator, but only if
142+
// the operator is `|` or `&` (not a plain space which would
143+
// signal the start of the next token like a parameter name).
144+
let rest_trimmed = rest.trim_start();
145+
let first = rest_trimmed.chars().next();
146+
if first == Some('|') || first == Some('&') {
147+
// Skip the operator character.
148+
let after_op = &rest_trimmed[1..];
149+
let after_op = after_op.trim_start();
150+
if after_op.is_empty() {
151+
break;
152+
}
153+
// Consume the next type token.
154+
let (tok, _) = split_type_token(after_op);
155+
if tok.is_empty() {
156+
break;
157+
}
158+
// Compute the absolute end position from the consumed
159+
// token. `after_op` is a sub-slice of `s`, so pointer
160+
// arithmetic gives us the byte offset.
161+
let tok_start_in_s = after_op.as_ptr() as usize - s.as_ptr() as usize;
162+
end = tok_start_in_s + tok.len();
163+
} else {
164+
break;
165+
}
166+
}
167+
end
168+
}
169+
112170
/// Split a type string on `|` at nesting depth 0, respecting `<…>`,
113171
/// `(…)`, and `{…}` nesting.
114172
///
@@ -991,6 +1049,34 @@ pub fn extract_callable_return_type(type_str: &str) -> Option<String> {
9911049
/// string → None
9921050
/// ```
9931051
pub fn extract_callable_param_types(type_str: &str) -> Option<Vec<String>> {
1052+
// Unwrap union types: `(Closure(X): Y)|null` → `Closure(X): Y`,
1053+
// `Closure(X): Y|null` → try `Closure(X): Y|null` first, then
1054+
// fall back to splitting on `|` and trying each part.
1055+
if let Some(result) = extract_callable_param_types_inner(type_str) {
1056+
return Some(result);
1057+
}
1058+
1059+
// Try each union member individually — handles `Closure(X)|null`,
1060+
// `null|callable(Y)`, and parenthesized groups like
1061+
// `(Closure(X): Y)|null`.
1062+
for part in split_union_depth0(type_str) {
1063+
let part = part.trim();
1064+
// Strip outer parens from grouped callables: `(Closure(X): Y)` → `Closure(X): Y`
1065+
let inner = part
1066+
.strip_prefix('(')
1067+
.and_then(|p| p.strip_suffix(')'))
1068+
.unwrap_or(part);
1069+
if let Some(result) = extract_callable_param_types_inner(inner) {
1070+
return Some(result);
1071+
}
1072+
}
1073+
1074+
None
1075+
}
1076+
1077+
/// Inner implementation: try to parse a single callable/Closure type
1078+
/// string (not a union) and extract its parameter types.
1079+
fn extract_callable_param_types_inner(type_str: &str) -> Option<Vec<String>> {
9941080
let s = type_str.strip_prefix('\\').unwrap_or(type_str);
9951081
let s = s.strip_prefix('?').unwrap_or(s);
9961082

tests/completion_closure_param_inference.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,65 @@ fn test_extract_callable_param_types_array_shape() {
625625
Some(vec!["array{name: string, age: int}".to_string()]),
626626
);
627627
}
628+
629+
#[test]
630+
fn test_extract_callable_param_types_union_with_null() {
631+
use phpantom_lsp::docblock::extract_callable_param_types;
632+
633+
// `Closure(Builder): mixed|null` — union at the top level
634+
assert_eq!(
635+
extract_callable_param_types("Closure(Builder): mixed|null"),
636+
Some(vec!["Builder".to_string()]),
637+
);
638+
639+
// `callable(User): void|null`
640+
assert_eq!(
641+
extract_callable_param_types("callable(User): void|null"),
642+
Some(vec!["User".to_string()]),
643+
);
644+
645+
// `null|callable(Order): bool`
646+
assert_eq!(
647+
extract_callable_param_types("null|callable(Order): bool"),
648+
Some(vec!["Order".to_string()]),
649+
);
650+
}
651+
652+
#[test]
653+
fn test_extract_callable_param_types_parenthesized_group() {
654+
use phpantom_lsp::docblock::extract_callable_param_types;
655+
656+
// `(Closure(Builder<Brand>): mixed)|null` — parenthesized callable in union
657+
assert_eq!(
658+
extract_callable_param_types("(Closure(Builder<Brand>): mixed)|null"),
659+
Some(vec!["Builder<Brand>".to_string()]),
660+
);
661+
662+
// `(\\Closure(\\App\\Models\\User): mixed)|string|null`
663+
assert_eq!(
664+
extract_callable_param_types("(\\Closure(\\App\\Models\\User): mixed)|string|null"),
665+
Some(vec!["\\App\\Models\\User".to_string()]),
666+
);
667+
}
668+
669+
#[test]
670+
fn test_extract_callable_param_types_parenthesized_no_union() {
671+
use phpantom_lsp::docblock::extract_callable_param_types;
672+
673+
// Bare parenthesized callable without union suffix
674+
assert_eq!(
675+
extract_callable_param_types("(Closure(Config): void)"),
676+
Some(vec!["Config".to_string()]),
677+
);
678+
}
679+
680+
#[test]
681+
fn test_extract_callable_param_types_union_non_callable_parts() {
682+
use phpantom_lsp::docblock::extract_callable_param_types;
683+
684+
// Union where no part is a callable — should return None
685+
assert_eq!(extract_callable_param_types("string|null"), None,);
686+
687+
// Union with a class name but no callable signature
688+
assert_eq!(extract_callable_param_types("Closure|null"), None,);
689+
}

0 commit comments

Comments
 (0)