phpantom_lsp 0.7.0

Fast PHP language server with deep type intelligence. Generics, Laravel, PHPStan annotations. Ready in an instant.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
//! Laravel Eloquent Model virtual member provider.
//!
//! Synthesizes virtual members for classes that extend
//! `Illuminate\Database\Eloquent\Model`.  This is the highest-priority
//! virtual member provider: its contributions beat `@method` /
//! `@property` / `@mixin` members (PHPDocProvider).
//!
//! Currently implements:
//!
//! - **Relationship properties.** Methods returning a known Eloquent
//!   relationship type (e.g. `HasOne`, `HasMany`, `BelongsTo`) produce
//!   a virtual property with the same name.  The property type is
//!   inferred from the relationship's generic parameters (Larastan-style
//!   `@return HasMany<Post, $this>` annotations) or, as a fallback,
//!   from the first `::class` argument in the method body text.
//!
//! - **Scope methods.** Methods whose name starts with `scope` (e.g.
//!   `scopeActive`, `scopeVerified`) produce a virtual method with the
//!   `scope` prefix stripped and the first letter lowercased (e.g.
//!   `active`, `verified`).  Methods decorated with `#[Scope]`
//!   (Laravel 11+) are also recognized: their own name is used
//!   directly as the public-facing scope name (e.g.
//!   `#[Scope] protected function active()` becomes `active()`).
//!   The first `$query` parameter is removed.
//!   Scope methods are available as both static and instance methods
//!   so they resolve for `User::active()` and `$user->active()`.
//!
//! - **Builder-as-static forwarding.** Laravel's `Model::__callStatic()`
//!   forwards static calls to `static::query()`, which returns an
//!   Eloquent Builder.  This provider loads
//!   `\Illuminate\Database\Eloquent\Builder`, fully resolves it
//!   (including its `@mixin` on `Query\Builder`), and presents its
//!   public instance methods as static virtual methods on the model.
//!   Return types are mapped so that `static`/`$this`/`self` resolve
//!   to `Builder<ConcreteModel>` (the chain continues on the builder)
//!   and template parameters like `TModel` resolve to the concrete
//!   model class.  This makes `User::where(...)->orderBy(...)->get()`
//!   resolve end-to-end.
//!
//! - **Cast properties.** Entries in the `$casts` property array or
//!   `casts()` method body produce typed virtual properties.  Cast type
//!   strings are mapped to PHP types (e.g. `datetime` → `\Carbon\Carbon`,
//!   `boolean` → `bool`, `decimal:2` → `float`).  Custom cast classes
//!   are resolved by loading the class and inspecting the `get()`
//!   method's return type.  When the `get()` method has no return type,
//!   the resolver falls back to the first generic argument from an
//!   `@implements CastsAttributes<TGet, TSet>` annotation on the cast
//!   class.  Enum casts resolve to the enum class itself.  Classes
//!   implementing `Castable` also resolve to themselves.  A `:argument`
//!   suffix (e.g. `Address::class.':nullable'`) is stripped before
//!   resolution.
//!
//! - **Attribute default properties.** Entries in the `$attributes`
//!   property array produce typed virtual properties as a fallback.
//!   Types are inferred from the literal default values: strings,
//!   booleans, integers, floats, `null`, and arrays.  Columns that
//!   already have a `$casts` entry are skipped, so casts always take
//!   priority.
//!
//! - **Column name properties.** Column names from `$fillable`,
//!   `$guarded`, `$hidden`, and `$appends` produce `mixed`-typed
//!   virtual properties as a last-resort fallback.  Columns already
//!   covered by `$casts` or `$attributes` are skipped.
//!
//! - **`where{PropertyName}()` dynamic methods.** Laravel's
//!   `Builder::__call()` translates calls like `whereBrandId($value)`
//!   into `where('brand_id', $value)`.  For each known column on the
//!   model (from all property sources: `$casts`, `$attributes`,
//!   `$fillable`/`$guarded`/`$hidden`/`$appends`, `$dates`, timestamps,
//!   relationship `*_count` properties, `@property` annotations, and
//!   accessor-derived properties), a virtual `where{StudlyCase}()`
//!   method is synthesized.  The method accepts a `mixed` value
//!   parameter and returns `Builder<ConcreteModel>`.  These methods
//!   appear as both instance methods on the Builder (for chaining:
//!   `$query->whereBrandId(42)`) and static methods on the model
//!   (for `User::whereName('Alice')`).

mod accessors;
mod builder;
mod casts;
mod factory;
mod helpers;
pub(crate) mod patches;
mod relationships;
mod scopes;
mod where_property;

pub use helpers::extends_eloquent_model;
pub(crate) use helpers::{accessor_method_candidates, camel_to_snake};

pub(crate) use accessors::is_accessor_method;
use accessors::{
    extract_modern_accessor_type, is_legacy_accessor, is_modern_accessor,
    legacy_accessor_property_name,
};

pub(crate) use relationships::count_property_to_relationship_method;
pub use relationships::infer_relationship_from_body;
pub(crate) use relationships::{RELATION_QUERY_METHODS, resolve_relation_chain};
use relationships::{
    RelationshipKind, build_property_type, classify_relationship_typed, count_property_name,
    extract_related_type_typed,
};

pub use scopes::build_scope_methods_for_builder;
use scopes::{build_scope_methods, is_scope_method};
use where_property::{build_where_property_methods_for_class, lowercase_method_names};

use std::collections::HashMap;
use std::sync::Arc;

use builder::build_builder_forwarded_methods;
use casts::cast_type_to_php_type;
pub use factory::LaravelFactoryProvider;
pub(crate) use factory::{factory_to_model_fqn, model_to_factory_fqn};

use crate::php_type::PhpType;
use crate::types::{ClassInfo, PropertyInfo};

use super::{ResolvedClassCache, VirtualMemberProvider, VirtualMembers};

/// The fully-qualified name of the Eloquent base model.
pub(crate) const ELOQUENT_MODEL_FQN: &str = "Illuminate\\Database\\Eloquent\\Model";

/// The fully-qualified name of the Eloquent Builder class.
pub const ELOQUENT_BUILDER_FQN: &str = "Illuminate\\Database\\Eloquent\\Builder";

/// Build a substitution map that replaces `static`, `$this`, and `self`
/// with the given type.
///
/// This is used across multiple Laravel virtual member providers
/// (builder forwarding, model virtual methods, scope methods) to
/// resolve self-referencing return types to concrete model or builder
/// types.
pub(super) fn self_ref_subs(ty: PhpType) -> HashMap<String, PhpType> {
    HashMap::from([
        ("static".to_owned(), ty.clone()),
        ("$this".to_owned(), ty.clone()),
        ("self".to_owned(), ty),
    ])
}

// ─── Type-resolution helpers ────────────────────────────────────────────────
//
// Called from `completion/resolver.rs` (`type_hint_to_classes_depth`) to
// apply Eloquent-specific post-processing after a class has been resolved
// and generic substitution applied.  Keeping the framework logic here
// rather than inline in the generic resolver avoids coupling the type
// engine to Laravel conventions.

/// Swap a resolved Eloquent Collection to a model's custom collection.
///
/// When the resolved class is `Illuminate\Database\Eloquent\Collection`
/// and one of the generic type arguments is a model with a
/// `custom_collection` declared (via `#[CollectedBy]` or
/// `@use HasCollection<X>`), returns the custom collection class
/// instead.  This handles the common chain pattern:
///
/// ```php
/// Model::where(...)->get()  // returns Collection<int, TModel>
/// ```
///
/// where `TModel` has been substituted to the concrete model and the
/// model declares a custom collection like `ProductCollection`.
///
/// Returns `None` when the class is not the Eloquent Collection, has no
/// generic args, or the model does not declare a custom collection.
pub(crate) fn try_swap_custom_collection(
    cls: ClassInfo,
    base_fqn: &str,
    generic_args: &[PhpType],
    all_classes: &[Arc<ClassInfo>],
    class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> ClassInfo {
    if base_fqn != crate::types::ELOQUENT_COLLECTION_FQN || generic_args.is_empty() {
        return cls;
    }

    // The last generic arg is typically the model type.
    let model_name = match generic_args.last().unwrap().base_name() {
        Some(name) => name.to_string(),
        None => return cls,
    };
    let model_class = find_class_in(all_classes, &model_name)
        .cloned()
        .or_else(|| class_loader(&model_name).map(Arc::unwrap_or_clone));

    if let Some(ref mc) = model_class
        && let Some(coll_type) = mc.laravel().and_then(|l| l.custom_collection.as_ref())
    {
        let coll_name = coll_type.to_string();
        find_class_in(all_classes, &coll_name)
            .cloned()
            .or_else(|| class_loader(&coll_name).map(Arc::unwrap_or_clone))
            .unwrap_or(cls)
    } else {
        cls
    }
}

/// Inject scope methods and model virtual methods onto a resolved Builder.
///
/// When the resolved class is the Eloquent Builder and the first generic
/// argument is a concrete model name, injects:
///
/// 1. **Scope methods** — `scopeX` and `#[Scope]` methods from the model,
///    with the `scope` prefix stripped and the first `$query` parameter
///    removed.
///
/// 2. **Model `@method` tags** — virtual methods declared via `@method`
///    on the model or its traits (e.g. `SoftDeletes`'s `withTrashed`).
///    Laravel's `Builder::__call` forwards unknown calls to the model,
///    so these methods are effectively available on the Builder instance.
///    Return types containing `static` are remapped to
///    `Builder<ConcreteModel>` to keep the chain on the builder.
///
/// The `cls` parameter is the Builder **after** generic substitution has
/// been applied.  `raw_cls` is the pre-substitution class (needed to
/// check the FQN via `file_namespace`).
pub(crate) fn try_inject_builder_scopes(
    result: &mut ClassInfo,
    raw_cls: &ClassInfo,
    base_fqn: &str,
    generic_args: &[PhpType],
    class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) {
    if !is_eloquent_builder_fqn(base_fqn, raw_cls) || generic_args.is_empty() {
        return;
    }

    // The first (or only) generic arg is the model type.
    let model_name = match generic_args.first().unwrap().base_name() {
        Some(name) => name,
        None => return,
    };

    inject_scopes_and_model_methods(result, model_name, class_loader);
}

/// Inject scope methods and model virtual methods onto a class that has
/// a `@mixin Builder<TRelatedModel>` inherited from an ancestor.
///
/// When a class like `HasMany<ProductTranslation>` inherits
/// `@mixin Builder<TRelatedModel>` from grandparent `Relation`, the
/// mixin expansion adds Builder's own methods but does NOT inject
/// model-specific scopes.  Scopes are normally injected by
/// [`try_inject_builder_scopes`] which only fires when the resolved
/// class IS the Builder.
///
/// This function handles the inherited-mixin case: it walks the raw
/// class's parent chain, finds `@mixin Builder<X>` declarations,
/// applies the generic substitution map (built from the concrete
/// type arguments at the call site) to resolve `X` to a concrete
/// model name, and injects that model's scopes and `@method` virtual
/// methods.
pub(crate) fn try_inject_mixin_builder_scopes(
    result: &mut ClassInfo,
    raw_cls: &ClassInfo,
    generic_args: &[PhpType],
    class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) {
    use std::collections::HashMap;

    use crate::types::MAX_INHERITANCE_DEPTH;
    use crate::util::short_name;

    if generic_args.is_empty() || raw_cls.template_params.is_empty() {
        return;
    }

    // Build the substitution map from the class's own template params
    // to the concrete generic args provided at the call site.
    // e.g. for HasMany<ProductTranslation, Product>:
    //   TRelatedModel → ProductTranslation, TDeclaringModel → Product
    let mut root_subs: HashMap<String, PhpType> = HashMap::new();
    for (i, param_name) in raw_cls.template_params.iter().enumerate() {
        if let Some(arg) = generic_args.get(i) {
            root_subs.insert(param_name.clone(), arg.clone());
        }
    }

    // Walk the parent chain looking for @mixin Builder<X> declarations.
    // At each level, build a substitution map that maps the parent's
    // template params to concrete types (threading through @extends
    // generics), then check if the parent has a Builder mixin.
    //
    // We use `ClassRef` to avoid lifetime issues when alternating
    // between a borrowed initial class and owned parent classes.
    let mut current = crate::inheritance::ClassRef::Borrowed(raw_cls);
    let mut active_subs = root_subs;
    let mut depth = 0u32;

    // Also check the class itself (it might directly declare @mixin Builder<X>).
    loop {
        // Check for Builder mixin on the current class.
        if let Some(model_name) =
            find_builder_mixin_model(&current, &active_subs, raw_cls, class_loader)
        {
            inject_scopes_and_model_methods(result, &model_name, class_loader);
            return;
        }

        // Move to the parent class.
        let parent_name = match current.parent_class.as_ref() {
            Some(name) => name.clone(),
            None => break,
        };
        depth += 1;
        if depth > MAX_INHERITANCE_DEPTH {
            break;
        }
        let parent = match class_loader(&parent_name) {
            Some(p) => p,
            None => break,
        };

        // Build the substitution map for this level by combining the
        // child's @extends generics with the active substitutions.
        let parent_short = short_name(&parent.name);
        let type_args = current
            .extends_generics
            .iter()
            .find(|(name, _)| short_name(name) == parent_short)
            .map(|(_, args)| args);

        if let Some(args) = type_args {
            let mut level_subs = HashMap::new();
            for (i, param_name) in parent.template_params.iter().enumerate() {
                if let Some(arg) = args.get(i) {
                    let resolved = arg.substitute(&active_subs);
                    level_subs.insert(param_name.clone(), resolved);
                }
            }
            active_subs = level_subs;
        }
        // If no @extends generics matched, the parent's template params
        // are unbound and we can't resolve the mixin's model type, so
        // we keep the current active_subs (they won't match parent
        // template param names, which is correct — the substitution
        // will be a no-op).

        current = crate::inheritance::ClassRef::Owned(parent);
    }
}

/// Check if a class declares `@mixin Builder<X>` and return the concrete
/// model name after applying substitutions.
///
/// Returns `Some(model_name)` when `X` resolves to a concrete type (not
/// a template parameter of the root class).  Returns `None` otherwise.
fn find_builder_mixin_model(
    class: &ClassInfo,
    active_subs: &std::collections::HashMap<String, crate::php_type::PhpType>,
    root_cls: &ClassInfo,
    class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Option<String> {
    use crate::util::short_name;

    for mixin_name in &class.mixins {
        if short_name(mixin_name) != "Builder" && mixin_name != ELOQUENT_BUILDER_FQN {
            continue;
        }
        // Verify it's actually the Eloquent Builder (not some other
        // class named Builder).  If we can't load it, trust the FQN.
        if let Some(ref mixin_cls) = class_loader(mixin_name) {
            let fqn = mixin_cls.fqn();
            if fqn != ELOQUENT_BUILDER_FQN && mixin_cls.name != ELOQUENT_BUILDER_FQN {
                continue;
            }
        }

        // Find the generic args for this mixin from mixin_generics.
        let mixin_short = short_name(mixin_name);
        let mixin_args = class
            .mixin_generics
            .iter()
            .find(|(name, _)| name == mixin_name || short_name(name) == mixin_short)
            .map(|(_, args)| args.as_slice());

        // Get the first generic arg (the model type) and substitute.
        if let Some(args) = mixin_args
            && let Some(first_arg) = args.first()
        {
            let resolved = first_arg.substitute(active_subs);
            if let Some(name) = resolved.base_name()
                && !root_cls.template_params.iter().any(|p| p == name)
            {
                return Some(name.to_string());
            }
        }
    }
    None
}

/// Shared helper: inject scope methods and `@method` virtual methods
/// from a model onto a class (Builder or a class with a Builder mixin).
fn inject_scopes_and_model_methods(
    result: &mut ClassInfo,
    model_arg: &str,
    class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) {
    // 1. Inject scope methods.
    let scope_methods = build_scope_methods_for_builder(model_arg, class_loader);
    for method in scope_methods {
        let already_exists = result
            .methods
            .iter()
            .any(|m| m.name == method.name && m.is_static == method.is_static);
        if !already_exists {
            result.methods.push(method);
        }
    }

    // 2. Inject @method virtual methods from the model.
    inject_model_virtual_methods(result, model_arg, class_loader);

    // 3. Inject where{PropertyName}() dynamic methods from the model's
    //    known columns.  These are instance methods on the Builder so
    //    that `$query->whereBrandId(42)` resolves.
    if let Some(model_class) = class_loader(model_arg) {
        let existing = lowercase_method_names(&result.methods);
        let where_methods = build_where_property_methods_for_class(&model_class, &existing);
        for method in where_methods {
            if !result
                .methods
                .iter()
                .any(|m| m.name.eq_ignore_ascii_case(&method.name))
            {
                result.methods.push(method);
            }
        }
    }
}

/// Inject `@method`-declared virtual methods from a model onto a Builder.
///
/// Laravel's `Builder::__call()` forwards unknown method calls to the
/// model instance.  This means `@method` tags on the model (including
/// those inherited from traits like `SoftDeletes`) are callable on the
/// Builder.  For example:
///
/// ```php
/// // SoftDeletes declares: @method static Builder<static> withTrashed()
/// // Customer uses SoftDeletes
/// Customer::groupBy('email')->withTrashed()->first()
/// //                          ^^^^^^^^^^^^^ needs to resolve on Builder<Customer>
/// ```
///
/// This function loads the fully-resolved model, finds virtual methods
/// (those with `is_virtual = true`, which come from `@method` tags),
/// and injects them as **instance** methods on the Builder.  Return
/// types containing `static`, `self`, or `$this` are substituted with
/// `Builder<ConcreteModel>` so the chain continues on the builder.
fn inject_model_virtual_methods(
    builder: &mut ClassInfo,
    model_name: &str,
    class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) {
    use crate::php_type::PhpType;

    let model_class = match class_loader(model_name) {
        Some(c) => c,
        None => return,
    };

    if !extends_eloquent_model(&model_class, class_loader) {
        return;
    }

    // Resolve the model fully so that @method tags from traits and
    // parent classes are included.
    let resolved_model = if let Some(cache) = crate::virtual_members::active_resolved_class_cache()
    {
        crate::virtual_members::resolve_class_fully_cached(&model_class, class_loader, cache)
    } else {
        crate::virtual_members::resolve_class_fully(&model_class, class_loader)
    };

    // Build a substitution map: `static`/`self`/`$this` in return
    // types should become the concrete model name.  The `@method`
    // tags already declare the full return type (e.g.
    // `Builder<static>`), so substituting `static` → model name
    // produces `Builder<Customer>`.  Using `Builder<Model>` here
    // would double-wrap to `Builder<Builder<Customer>>`.
    let model_type = PhpType::Named(model_name.to_owned());
    let subs = self_ref_subs(model_type);

    for method in &resolved_model.methods {
        // Only inject virtual methods (from @method tags).  Real
        // methods on the model are not forwarded through Builder.
        if !method.is_virtual {
            continue;
        }

        // Skip methods already present on the builder (real methods,
        // scope methods, or previously injected methods).
        if builder
            .methods
            .iter()
            .any(|m| m.name.eq_ignore_ascii_case(&method.name))
        {
            continue;
        }

        // Clone and convert to an instance method on the builder.
        let mut forwarded = method.clone();
        forwarded.is_static = false;

        // Substitute self-referencing return types.
        if let Some(ref mut ret) = forwarded.return_type {
            *ret = ret.substitute(&subs);
        }

        builder.methods.push(forwarded);
    }
}

/// Check whether a base FQN and/or a `ClassInfo` refer to the Eloquent Builder.
///
/// Handles the three forms a Builder can appear as:
/// 1. The type hint FQN itself (e.g. from `@return Builder<User>`).
/// 2. The `ClassInfo.name` field (short name or FQN depending on source).
/// 3. The FQN constructed from `file_namespace + name` (PSR-4 loaded classes
///    where `name` is the short name only).
fn is_eloquent_builder_fqn(base_fqn: &str, cls: &ClassInfo) -> bool {
    base_fqn == ELOQUENT_BUILDER_FQN
        || cls.name == ELOQUENT_BUILDER_FQN
        || cls.fqn() == ELOQUENT_BUILDER_FQN
}

/// Find a class in a slice by name (short or FQN).
///
/// Minimal local lookup used by the collection-swap helper.  Prefers
/// namespace-aware matching when the name contains backslashes.
fn find_class_in<'a>(all_classes: &'a [Arc<ClassInfo>], name: &str) -> Option<&'a ClassInfo> {
    let short = name.rsplit('\\').next().unwrap_or(name);

    if name.contains('\\') {
        let expected_ns = name.rsplit_once('\\').map(|(ns, _)| ns);
        all_classes
            .iter()
            .find(|c| c.name == short && c.file_namespace.as_deref() == expected_ns)
            .map(|c| c.as_ref())
    } else {
        all_classes
            .iter()
            .find(|c| c.name == short)
            .map(|c| c.as_ref())
    }
}

/// Virtual member provider for Laravel Eloquent models.
///
/// When a class extends `Illuminate\Database\Eloquent\Model` (directly
/// or through an intermediate parent), this provider scans its methods
/// for Eloquent relationship return types and synthesizes virtual
/// properties for each one.
///
/// For example, a method `posts()` returning `HasMany<Post, $this>`
/// produces a virtual property `$posts` with type
/// `\Illuminate\Database\Eloquent\Collection<Post>`.
pub struct LaravelModelProvider;

/// Pre-built `Carbon\Carbon` type used for date-related virtual properties.
fn carbon_type() -> PhpType {
    PhpType::Named("Carbon\\Carbon".to_owned())
}

impl VirtualMemberProvider for LaravelModelProvider {
    /// Returns `true` if the class extends `Illuminate\Database\Eloquent\Model`.
    fn applies_to(
        &self,
        class: &ClassInfo,
        class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
    ) -> bool {
        extends_eloquent_model(class, class_loader)
    }

    /// Scan the class's methods for Eloquent relationship return types,
    /// scope methods, Builder-as-static forwarded methods, `$casts`
    /// definitions, `$attributes` defaults, and `$fillable`/`$guarded`/
    /// `$hidden`/`$appends` column names.
    fn provide(
        &self,
        class: &ClassInfo,
        class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
        cache: Option<&ResolvedClassCache>,
    ) -> VirtualMembers {
        let mut properties = Vec::new();
        let mut methods = Vec::new();
        let mut seen_props: std::collections::HashSet<String> = std::collections::HashSet::new();

        // ── Cast properties ─────────────────────────────────────────
        if let Some(laravel) = class.laravel() {
            for (column, cast_type) in &laravel.casts_definitions {
                let php_type = cast_type_to_php_type(cast_type, class_loader);
                seen_props.insert(column.clone());
                properties.push(PropertyInfo::virtual_property_typed(
                    column,
                    Some(&php_type),
                ));
            }

            // ── $dates properties (deprecated, lower priority than $casts) ──
            // Columns in `$dates` are typed as Carbon\Carbon unless already
            // covered by an explicit `$casts` entry.
            for column in &laravel.dates_definitions {
                if !seen_props.insert(column.clone()) {
                    continue;
                }
                properties.push(PropertyInfo::virtual_property_typed(
                    column,
                    Some(&carbon_type()),
                ));
            }

            // ── Attribute default properties (fallback) ─────────────
            // Only add properties for columns not already covered by $casts
            // or $dates.
            for (column, php_type) in &laravel.attributes_definitions {
                if !seen_props.insert(column.clone()) {
                    continue;
                }
                properties.push(PropertyInfo::virtual_property_typed(column, Some(php_type)));
            }

            // ── Column name properties (last-resort fallback) ───────
            // $fillable, $guarded, $hidden, and $appends provide column
            // names without type info.  Only add those not already covered.
            for column in &laravel.column_names {
                if !seen_props.insert(column.clone()) {
                    continue;
                }
                properties.push(PropertyInfo::virtual_property_typed(
                    column,
                    Some(&PhpType::mixed()),
                ));
            }

            // ── Timestamp properties ────────────────────────────────
            // When $timestamps is true (the default), synthesize
            // created_at and updated_at as Carbon\Carbon.  The column
            // names can be overridden via CREATED_AT / UPDATED_AT
            // constants, and either can be disabled by setting the
            // constant to null.
            let timestamps_enabled = laravel.timestamps.unwrap_or(true);
            if timestamps_enabled {
                let created_col = match &laravel.created_at_name {
                    Some(Some(name)) => Some(name.as_str()),
                    Some(None) => None,         // explicitly null
                    None => Some("created_at"), // default
                };
                let updated_col = match &laravel.updated_at_name {
                    Some(Some(name)) => Some(name.as_str()),
                    Some(None) => None,         // explicitly null
                    None => Some("updated_at"), // default
                };
                for col in [created_col, updated_col].into_iter().flatten() {
                    if seen_props.insert(col.to_string()) {
                        properties.push(PropertyInfo::virtual_property_typed(
                            col,
                            Some(&carbon_type()),
                        ));
                    }
                }
            }
        }

        for method in &class.methods {
            // ── Scope methods ───────────────────────────────────────
            if is_scope_method(method) {
                // Skip `#[Scope]`-attributed methods that also use
                // the `scopeX` prefix — the attribute takes priority
                // and the name is used as-is (no prefix stripping).
                let [instance_method, static_method] = build_scope_methods(method);
                methods.push(instance_method);
                methods.push(static_method);
                continue;
            }

            // ── Legacy accessors (getXAttribute) ────────────────────
            if is_legacy_accessor(method) {
                let prop_name = legacy_accessor_property_name(&method.name);
                properties.push(PropertyInfo {
                    deprecation_message: method.deprecation_message.clone(),
                    ..PropertyInfo::virtual_property_typed(&prop_name, method.return_type.as_ref())
                });
                continue;
            }

            // ── Modern accessors (Laravel 9+ Attribute casts) ───────
            if is_modern_accessor(method) {
                let prop_name = camel_to_snake(&method.name);
                let accessor_type = extract_modern_accessor_type(method);
                properties.push(PropertyInfo {
                    deprecation_message: method.deprecation_message.clone(),
                    ..PropertyInfo::virtual_property_typed(&prop_name, Some(&accessor_type))
                });
                continue;
            }

            // ── Relationship properties ─────────────────────────────
            let return_type = match method.return_type.as_ref() {
                Some(rt) => rt,
                None => continue,
            };

            let kind = match classify_relationship_typed(return_type) {
                Some(k) => k,
                None => continue,
            };

            let related_type = extract_related_type_typed(return_type);

            // For collection relationships, use the *related* model's
            // custom_collection, not the owning model's.  For example,
            // if Product has `#[CollectedBy(ProductCollection)]` and
            // Review has `#[CollectedBy(ReviewCollection)]`, then
            // `Product::reviews()` returning `HasMany<Review, $this>`
            // should produce `ReviewCollection<Review>`, not
            // `ProductCollection<Review>`.
            let custom_collection = if kind == RelationshipKind::Collection {
                related_type
                    .and_then(|t| t.base_name().and_then(class_loader))
                    .and_then(|related_class| {
                        related_class
                            .laravel
                            .as_ref()
                            .and_then(|l| l.custom_collection.as_ref().map(|c| c.to_string()))
                    })
            } else {
                None
            };

            let type_hint = build_property_type(kind, related_type, custom_collection.as_deref());

            if let Some(ref th) = type_hint {
                properties.push(PropertyInfo::virtual_property_typed(&method.name, Some(th)));
            }
        }

        // ── Relationship count properties (`*_count`) ───────────────
        // `withCount`/`loadCount` is one of the most common Eloquent
        // patterns.  For each relationship method, synthesize a
        // `{snake_name}_count` property typed as `int`.  Skip if a
        // property with that name already exists (e.g. from an explicit
        // `@property` tag).
        for method in &class.methods {
            let return_type = match method.return_type.as_ref() {
                Some(rt) => rt,
                None => continue,
            };
            if classify_relationship_typed(return_type).is_none() {
                continue;
            }
            let count_name = count_property_name(&method.name);
            if !seen_props.insert(count_name.clone()) {
                continue;
            }
            properties.push(PropertyInfo::virtual_property_typed(
                &count_name,
                Some(&PhpType::int()),
            ));
        }

        // ── Builder-as-static forwarding ────────────────────────────
        let forwarded = build_builder_forwarded_methods(class, class_loader, cache);
        methods.extend(forwarded);

        // ── where{PropertyName}() static forwarding ─────────────────
        // Laravel's Model::__callStatic() delegates to Builder, which
        // handles where{Column}() calls.  Synthesize these as static
        // methods on the model so that User::whereName('Alice') resolves.
        let existing = lowercase_method_names(&methods);
        let where_static = build_where_property_methods_for_class(class, &existing);
        for mut m in where_static {
            m.is_static = true;
            methods.push(m);
        }

        VirtualMembers {
            methods,
            properties,
            constants: Vec::new(),
        }
    }
}

#[cfg(test)]
mod tests;