Skip to content

Commit 32da761

Browse files
committed
Custom Eloquent collection swapping
1 parent b695b0c commit 32da761

10 files changed

Lines changed: 899 additions & 13 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- **String-aware completion suppression.** Completion is now suppressed inside string literals (single-quoted strings, nowdocs, and plain text in double-quoted strings and heredocs) to reduce noise from accidental triggers. Interpolation contexts are detected and allowed through: `{$expr->}` brace interpolation and simple `"$var->"` property access both produce completions as expected. Array shape key completion (`$arr['`) is unaffected.
3030
- **Nested key completion for literal arrays.** When a variable is assigned a literal array with nested associative arrays (e.g. `$cfg = ['db' => ['host' => 'x', 'port' => 3306]]`), completing `$cfg['db']['` now offers the nested keys. Previously only a `@var array{...}` docblock produced nested completions. Works at arbitrary nesting depth, inside class methods, and at the top level.
3131
- **Generator yield type inference inside generator bodies.** When a function or method declares `@return Generator<TKey, TValue, TSend, TReturn>`, variables inside the generator body are now typed from the annotation. Variables that appear as the operand of a `yield` statement are inferred as TValue (the 2nd generic parameter), and variables assigned from a yield expression (`$var = yield $expr`) are inferred as TSend (the 3rd generic parameter). Works in class methods, top-level functions, key-value yields (`yield $k => $v`), cross-file resolution via PSR-4, and all four Generator type parameter arities. Explicit assignments still take priority over yield inference.
32+
- **Custom Eloquent collections.** Models that declare a custom collection via `#[CollectedBy(CustomCollection::class)]` or `/** @use HasCollection<CustomCollection> */ use HasCollection;` now resolve to the custom collection class instead of the standard `Illuminate\Database\Eloquent\Collection`. Custom collection methods appear in completions after `Model::where(...)->get()->`, after `Model::get()->`, and on relationship properties that return a collection of the model. The attribute takes priority over the trait when both are present. Works with short names, fully-qualified names, cross-file PSR-4 resolution, and same-file definitions. Models without a custom collection continue to use the standard Collection.
3233

3334
### Changed
3435

example.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,32 @@ public function demo(): void
889889
}
890890

891891

892+
// ── Custom Eloquent Collections ─────────────────────────────────────────────
893+
// Models with #[CollectedBy(CustomCollection::class)] or
894+
// /** @use HasCollection<CustomCollection> */ use HasCollection;
895+
// resolve to the custom collection class instead of the standard
896+
// Illuminate\Database\Eloquent\Collection. This means custom methods
897+
// like topRated() and averageRating() appear in completions after ->get().
898+
899+
class CustomCollectionDemo
900+
{
901+
public function demo(): void
902+
{
903+
// Builder chain → custom collection (via #[CollectedBy] on Review)
904+
$reviews = Review::where('published', true)->get();
905+
$reviews->topRated(); // custom method from ReviewCollection
906+
$reviews->averageRating(); // custom method from ReviewCollection
907+
$reviews->count(); // inherited from standard Collection
908+
$reviews->first(); // inherited — returns Review|null
909+
910+
// Relationship properties also use the custom collection
911+
$review = new Review();
912+
$review->replies->topRated(); // HasMany<Review> → ReviewCollection
913+
$review->replies->averageRating(); // ReviewCollection method
914+
}
915+
}
916+
917+
892918
// ── Match Class-String Forwarding to Conditional Return Types ───────────────
893919
// When a variable holds a ::class value from a match expression and is then
894920
// passed to a function/method with @template T + @param class-string<T> +
@@ -2600,6 +2626,32 @@ class BlogTag extends \Illuminate\Database\Eloquent\Model
26002626
public function getLabel(): string { return ''; }
26012627
}
26022628

2629+
// ── Custom Eloquent Collection Demo Models ──────────────────────────────────
2630+
2631+
/**
2632+
* @template TKey of array-key
2633+
* @template TModel
2634+
* @extends \Illuminate\Database\Eloquent\Collection<TKey, TModel>
2635+
*/
2636+
class ReviewCollection extends \Illuminate\Database\Eloquent\Collection
2637+
{
2638+
/** @return array<TKey, TModel> */
2639+
public function topRated(): array { return []; }
2640+
2641+
/** @return float */
2642+
public function averageRating(): float { return 0.0; }
2643+
}
2644+
2645+
#[\Illuminate\Database\Eloquent\Attributes\CollectedBy(ReviewCollection::class)]
2646+
class Review extends \Illuminate\Database\Eloquent\Model
2647+
{
2648+
public function getTitle(): string { return ''; }
2649+
public function getRating(): int { return 0; }
2650+
2651+
/** @return \Illuminate\Database\Eloquent\Relations\HasMany<Review, $this> */
2652+
public function replies(): mixed { return $this->hasMany(Review::class); }
2653+
}
2654+
26032655
} // end namespace Demo
26042656

26052657
// ── Illuminate Stubs ────────────────────────────────────────────────────────
@@ -2656,6 +2708,15 @@ class MorphToMany {}
26562708
class HasManyThrough {}
26572709
}
26582710

2711+
namespace Illuminate\Database\Eloquent\Attributes {
2712+
class CollectedBy {}
2713+
}
2714+
2715+
namespace Illuminate\Database\Eloquent {
2716+
/** @template TCollection */
2717+
trait HasCollection {}
2718+
}
2719+
26592720
namespace Illuminate\Database\Concerns {
26602721

26612722
/**

src/completion/resolver.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,50 @@ impl Backend {
10571057

10581058
match found {
10591059
Some(cls) => {
1060+
// ── Custom Eloquent collection swapping ────────────────
1061+
// When the resolved class is the standard Eloquent
1062+
// Collection and one of the generic type args is a model
1063+
// with a `custom_collection` declared (via
1064+
// `#[CollectedBy]` or `@use HasCollection<X>`), swap to
1065+
// the custom collection class so that its own methods
1066+
// (e.g. `topRated()`) appear in completions.
1067+
//
1068+
// This handles the common chain pattern:
1069+
// Model::where(...)->get()
1070+
// where Builder's `get()` returns
1071+
// `\Illuminate\Database\Eloquent\Collection<int, TModel>`
1072+
// and TModel has been substituted to the concrete model.
1073+
//
1074+
// We compare against `base_clean` (the FQN extracted from
1075+
// the type hint) rather than `cls.file_namespace` because
1076+
// `file_namespace` is not always populated when classes are
1077+
// loaded via PSR-4 / classmap.
1078+
let is_eloquent_collection = {
1079+
let bc = base_clean.strip_prefix('\\').unwrap_or(&base_clean);
1080+
bc == crate::types::ELOQUENT_COLLECTION_FQN
1081+
};
1082+
let cls = if is_eloquent_collection && !generic_args.is_empty() {
1083+
// The last generic arg is typically the model type.
1084+
let model_arg = generic_args.last().unwrap();
1085+
let model_clean = model_arg.strip_prefix('\\').unwrap_or(model_arg);
1086+
let model_class = find_class_by_name(all_classes, model_clean)
1087+
.cloned()
1088+
.or_else(|| class_loader(model_clean));
1089+
if let Some(ref mc) = model_class
1090+
&& let Some(ref coll_name) = mc.custom_collection
1091+
{
1092+
let coll_clean = coll_name.strip_prefix('\\').unwrap_or(coll_name);
1093+
find_class_by_name(all_classes, coll_clean)
1094+
.cloned()
1095+
.or_else(|| class_loader(coll_clean))
1096+
.unwrap_or(cls)
1097+
} else {
1098+
cls
1099+
}
1100+
} else {
1101+
cls
1102+
};
1103+
10601104
// Apply generic substitution if the type hint carried
10611105
// generic arguments and the class has template parameters.
10621106
// Resolve the class fully first (including trait methods,

src/parser/ast_update.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,12 @@ impl Backend {
379379
.map(|m| Self::resolve_name(m, use_map, namespace))
380380
.collect();
381381

382+
// Resolve custom collection class name to FQN
383+
if let Some(ref coll) = class.custom_collection {
384+
let resolved = Self::resolve_name(coll, use_map, namespace);
385+
class.custom_collection = Some(resolved);
386+
}
387+
382388
// Resolve type arguments in @extends, @implements, and @use
383389
// generics so that after generic substitution, return types
384390
// and property types are fully-qualified and can be resolved

src/parser/classes.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use std::collections::HashMap;
22

33
use mago_span::HasSpan;
4+
use mago_syntax::ast::attribute::AttributeList;
45
use mago_syntax::ast::class_like::method::MethodBody;
56
use mago_syntax::ast::class_like::trait_use::{
67
TraitUseAdaptation, TraitUseMethodReference, TraitUseSpecification,
78
};
9+
use mago_syntax::ast::sequence::Sequence;
810
/// Class, interface, trait, and enum extraction.
911
///
1012
/// Each class-like declaration is tagged with a [`ClassLikeKind`] so that
@@ -93,6 +95,65 @@ fn extract_class_docblock<'a>(
9395
}
9496
}
9597

98+
/// Extract the custom collection class name from a `#[CollectedBy(X::class)]` attribute.
99+
///
100+
/// Scans the class's attribute lists for an attribute whose short name is
101+
/// `CollectedBy` and extracts the first argument's text with `::class` stripped.
102+
/// Returns `None` if no such attribute exists.
103+
fn extract_collected_by_attribute(
104+
attribute_lists: &Sequence<'_, AttributeList<'_>>,
105+
content: &str,
106+
) -> Option<String> {
107+
for attr_list in attribute_lists.iter() {
108+
for attr in attr_list.attributes.iter() {
109+
let short = attr.name.last_segment();
110+
if short != "CollectedBy" {
111+
continue;
112+
}
113+
let arg_list = attr.argument_list.as_ref()?;
114+
let first_arg = arg_list.arguments.first()?;
115+
let span = first_arg.span();
116+
let start = span.start.offset as usize;
117+
let end = span.end.offset as usize;
118+
let text = content.get(start..end)?;
119+
let class_name = text.trim_end_matches("::class").trim();
120+
if !class_name.is_empty() {
121+
return Some(class_name.to_string());
122+
}
123+
}
124+
}
125+
None
126+
}
127+
128+
/// Determine the custom collection class for an Eloquent model.
129+
///
130+
/// Checks two sources in priority order:
131+
///
132+
/// 1. `#[CollectedBy(CustomCollection::class)]` attribute on the class.
133+
/// 2. `/** @use HasCollection<CustomCollection> */` in `use_generics`.
134+
///
135+
/// The attribute takes priority because it is the newer Laravel API.
136+
fn extract_custom_collection(
137+
attribute_lists: &Sequence<'_, AttributeList<'_>>,
138+
use_generics: &[(String, Vec<String>)],
139+
content: &str,
140+
) -> Option<String> {
141+
// 1. Try the #[CollectedBy] attribute first.
142+
if let Some(name) = extract_collected_by_attribute(attribute_lists, content) {
143+
return Some(name);
144+
}
145+
146+
// 2. Fall back to @use HasCollection<X>.
147+
for (trait_name, args) in use_generics {
148+
let short = trait_name.rsplit('\\').next().unwrap_or(trait_name);
149+
if short == "HasCollection" && !args.is_empty() {
150+
return Some(args[0].clone());
151+
}
152+
}
153+
154+
None
155+
}
156+
96157
impl Backend {
97158
/// Recursively walk statements and extract class information.
98159
/// This handles classes at the top level as well as classes nested
@@ -141,6 +202,10 @@ impl Backend {
141202
let start_offset = class.left_brace.start.offset;
142203
let end_offset = class.right_brace.end.offset;
143204

205+
let content = doc_ctx.map(|c| c.content).unwrap_or("");
206+
let custom_collection =
207+
extract_custom_collection(&class.attribute_lists, &use_generics, content);
208+
144209
classes.push(ClassInfo {
145210
kind: ClassLikeKind::Class,
146211
name: class_name,
@@ -166,6 +231,7 @@ impl Backend {
166231
trait_aliases,
167232
class_docblock: doc_info.raw_docblock,
168233
file_namespace: None,
234+
custom_collection,
169235
});
170236

171237
// Walk method bodies for anonymous classes.
@@ -236,6 +302,7 @@ impl Backend {
236302
trait_aliases,
237303
class_docblock: doc_info.raw_docblock,
238304
file_namespace: None,
305+
custom_collection: None,
239306
});
240307

241308
// Walk method bodies for anonymous classes.
@@ -288,6 +355,7 @@ impl Backend {
288355
trait_aliases,
289356
class_docblock: doc_info.raw_docblock,
290357
file_namespace: None,
358+
custom_collection: None,
291359
});
292360

293361
// Walk method bodies for anonymous classes.
@@ -358,6 +426,7 @@ impl Backend {
358426
trait_aliases: vec![],
359427
class_docblock: doc_info.raw_docblock,
360428
file_namespace: None,
429+
custom_collection: None,
361430
});
362431

363432
// Walk method bodies for anonymous classes.
@@ -440,6 +509,7 @@ impl Backend {
440509
trait_aliases,
441510
class_docblock: None,
442511
file_namespace: None,
512+
custom_collection: None,
443513
}
444514
}
445515

src/types.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,20 @@ pub struct ClassInfo {
520520
/// namespace blocks (e.g. `Illuminate\Database\Eloquent\Builder` vs
521521
/// `Illuminate\Database\Query\Builder`).
522522
pub file_namespace: Option<String>,
523+
/// Custom collection class for Eloquent models.
524+
///
525+
/// Detected from two Laravel mechanisms:
526+
///
527+
/// 1. The `#[CollectedBy(CustomCollection::class)]` attribute on the
528+
/// model class.
529+
/// 2. The `/** @use HasCollection<CustomCollection> */` docblock
530+
/// annotation on a `use HasCollection;` trait usage.
531+
///
532+
/// When set, the `LaravelModelProvider` replaces
533+
/// `\Illuminate\Database\Eloquent\Collection` with this class in
534+
/// relationship property types and Builder-forwarded return types
535+
/// (e.g. `get()`, `all()`).
536+
pub custom_collection: Option<String>,
523537
}
524538

525539
// ─── ClassInfo helpers ──────────────────────────────────────────────────────
@@ -563,6 +577,14 @@ pub(crate) struct FileContext {
563577
pub namespace: Option<String>,
564578
}
565579

580+
// ─── Eloquent Constants ─────────────────────────────────────────────────────
581+
582+
/// The fully-qualified name of the Eloquent Collection class.
583+
///
584+
/// Used by the `LaravelModelProvider` to detect and replace collection
585+
/// return types when a model declares a custom collection class.
586+
pub const ELOQUENT_COLLECTION_FQN: &str = "Illuminate\\Database\\Eloquent\\Collection";
587+
566588
// ─── Recursion Depth Limits ─────────────────────────────────────────────────
567589
//
568590
// Centralised constants for the maximum recursion depth allowed when

0 commit comments

Comments
 (0)