Skip to content

Commit a687c13

Browse files
committed
Laravel Builder-as-static forwarding
1 parent f0aaf09 commit a687c13

18 files changed

Lines changed: 4287 additions & 199 deletions

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **Virtual member provider abstraction.** Introduced the `VirtualMemberProvider` trait and `VirtualMembers` struct in a new `virtual_members` module. This provides a priority-ordered pipeline for synthesizing members from `@method`/`@property` tags, `@mixin` classes, and framework-specific patterns (e.g. Laravel relationships, scopes, Builder forwarding). All completion and go-to-definition call sites now use the new `resolve_class_fully` entry point, which applies base inheritance resolution followed by virtual member providers. No providers are registered yet, so behavior is unchanged. This is the foundation for upcoming Laravel support.
1313
- **Laravel relationship properties.** Classes extending `Illuminate\Database\Eloquent\Model` now get virtual properties synthesized from relationship methods. A method returning `HasMany<Post, $this>` produces a `$posts` property typed as `\Illuminate\Database\Eloquent\Collection<Post>`, and `HasOne<Profile, $this>` produces a `$profile` property typed as `Profile`. Supports `HasOne`, `HasMany`, `BelongsTo`, `BelongsToMany`, `MorphOne`, `MorphMany`, `MorphTo`, `MorphToMany`, and `HasManyThrough`. Generic type parameters are extracted from Larastan-style `@return` annotations. The synthesized properties sit at the highest virtual member priority, beating `@property` tags from ide-helper and `@mixin` members. Works with `$this->`, instance variables, relationship methods defined in traits, indirect Model subclasses (through a BaseModel), fully-qualified return types, and cross-file PSR-4 resolution. Chaining through relationship properties (e.g. `$user->profile->getBio()`) resolves to the related model's members.
1414
- **Laravel scope methods.** Methods starting with `scope` on Eloquent model classes (e.g. `scopeActive`, `scopeOfType`) now produce virtual methods with the prefix stripped and the first letter lowercased (`active`, `ofType`). The first `$query` parameter is removed since Laravel injects it automatically. Scope methods are available as both static and instance methods, so `User::active()` and `$user->active()` both resolve. If the scope method returns `void` or has no return type, the synthesized method defaults to `\Illuminate\Database\Eloquent\Builder<static>`. Custom return types and extra parameters beyond `$query` are preserved.
15+
- **Laravel Builder-as-static forwarding.** Static method calls on Eloquent model classes are forwarded to the Eloquent Builder. `User::where('email', $e)->orderBy('name')->get()` now resolves end-to-end. The provider loads `\Illuminate\Database\Eloquent\Builder`, fully resolves it (including `@mixin` members from `Query\Builder`), and presents its public instance methods as static virtual methods on the model. Return types are mapped so that `static`/`$this`/`self` on Builder resolve to `Builder<ConcreteModel>` (the chain continues on the builder), and template parameters like `TModel` resolve to the concrete model class. Methods like `get()` return `Collection<User>`, `first()` returns `User|null`, and chain methods like `where()` and `orderBy()` return `Builder<User>` for continued chaining. Works with indirect model subclasses, coexists with scope methods and relationship properties, and includes Query Builder methods forwarded via `@mixin`.
1516
- **Union completion sorting.** When a variable has a union type (e.g. `Dog|Cat` from a match or ternary), members shared by all types in the union (the intersection) now sort above members that only exist on a subset of types. Branch-only members display their originating class name as a label detail suffix, giving an at-a-glance visual hint in the completion popup. No completions are removed. Single-type completions are unaffected.
1617
- **Context-aware class name filtering.** Class name completions are now filtered by syntactic context. `extends` in a class declaration only offers non-final classes, `extends` in an interface only offers interfaces, `implements` offers interfaces only, `use` inside a class body offers traits only, and `instanceof` excludes traits. Constants and functions are also suppressed in these positions. Multi-line declarations and comma-separated lists are handled. Built-in (stub) classes are filtered correctly even before they are fully parsed, using a lightweight source scanner that detects the declaration keyword from the raw PHP source already in memory. Classmap entries whose source is not yet loaded pass through the filter since their kind is unknown.
1718
- **First-class callable syntax.** PHP 8.1's `strlen(...)`, `$obj->method(...)`, and `ClassName::method(...)` syntax now resolves correctly. The variable holding the callable is typed as `Closure` (for `$fn->bindTo()` etc.), and invoking it with `$fn()` resolves to the underlying function or method's return type. Works with function references, instance methods, static methods, `$this->method(...)`, `self::method(...)`, chained calls on the result, and cross-file resolution.
@@ -46,6 +47,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4647
- **Go-to-definition on foreach variable no longer jumps to a previous loop.** When the same variable name appeared in consecutive `foreach` loops, clicking `$b` in the second loop's `as $b` clause incorrectly jumped to the first loop. The resolver now checks whether the cursor line itself defines the variable and returns `None` immediately, letting the caller fall through to type-hint resolution instead of scanning backwards past the current definition site.
4748
- **Multi-extends interfaces now fully stored.** Interfaces extending multiple parents (e.g. `interface C extends A, B`) now store all parent names, not just the first one.
4849
- **Conflicting use-import resolution.** When auto-importing a class whose short name collides with an existing `use` statement (e.g. accepting `App\Exception` while `use Cassandra\Exception;` is already present), the LSP now inserts a fully-qualified reference (`\App\Exception`) at the usage site instead of adding a duplicate `use` statement that would cause a compile error. Applies to all completion sources (class index, classmap, stubs) and to catch clause completions.
50+
- **Multi-namespace file class resolution.** Files containing multiple `namespace { }` blocks (e.g. a demo namespace alongside inline stubs) now resolve classes correctly. Each class carries the namespace of its enclosing block rather than inheriting the file-level namespace. This fixes `find_class_in_ast_map` returning the wrong class when two classes share the same short name but live in different namespace blocks. Parent class names and trait references are also resolved against the correct per-block namespace. The type resolver (`type_hint_to_classes`) and all local class lookups now use a namespace-aware `find_class_by_name` helper, so a fully-qualified return type like `\Illuminate\Database\Eloquent\Builder` no longer collides with a `Demo\Builder` class that happens to appear earlier in the same file.
51+
- **`$this`/`static`/`self` return type on trait methods resolved to the using class.** When a trait method declared `@return $this`, `@return static`, or `@return self`, chaining on the result resolved to the trait itself instead of the concrete class using it. For example, `$this->withHeaders([])->` on a class using `MakesHttpRequests` would not offer the class's own methods or parent methods. The resolver now returns the owning class directly for these return types, preserving the full member set for chaining. Works same-file and cross-file via PSR-4.
52+
- **Mixin `$this`/`self`/`static` return types no longer rewritten to mixin class name.** When a mixin method (e.g. `Query\Builder::orderBy()`) declared `@return $this`, the mixin merge code rewrote it to the mixin class's short name. In files with multiple classes sharing the same short name, this resolved to the wrong class and also prevented the builder-as-static substitution map from matching. These return types are now left as-is so that `$this` resolves to the consumer class at call sites, which is semantically correct for fluent APIs.
53+
- **Go-to-definition member position search scoped to declaring class.** `find_member_position` searched the entire file linearly for `function methodName`, finding the first match regardless of which class it belonged to. In files with multiple classes defining the same method name, this jumped to the wrong class. The new `find_member_position_in_range` accepts the declaring class's byte range and restricts the search accordingly. Docblock-based searches (`@method`, `@property`) are not scoped because docblocks precede the class body.
4954

5055
### Changed
5156

docs/todo-laravel.md

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -329,14 +329,6 @@ end-to-end.
329329

330330
## Implementation sequence
331331

332-
### Step 5: LaravelModelProvider — Builder-as-static
333-
334-
- Load Eloquent Builder, extract public instance methods.
335-
- Present as static virtual methods on Model subclasses.
336-
- Handle return type mapping (`static` on Builder → Builder continuation,
337-
model template parameter → concrete model class).
338-
- Tests: verify `User::where()->orderBy()->get()` chain resolves.
339-
340332
### Step 6: Completion builder adjustments
341333

342334
- Virtual methods that are marked as "static forwarded" need to appear in
@@ -345,15 +337,6 @@ end-to-end.
345337
should respect whatever flag the provider sets.
346338
- Virtual properties from relationships should appear in `Arrow` completion.
347339

348-
### Step 7: Go-to-definition support
349-
350-
- When go-to-definition lands on a virtual property (relationship), jump to
351-
the relationship method definition.
352-
- When go-to-definition lands on a virtual method (scope), jump to the
353-
`scope*` method definition.
354-
- When go-to-definition lands on a forwarded Builder method, jump to the
355-
Builder method definition.
356-
357340
---
358341

359342
## Out of scope (and why)

example.php

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* 3. SCAFFOLDING — supporting definitions (scroll past these)
1313
*/
1414

15-
namespace Demo;
15+
namespace Demo {
1616

1717
use Exception;
1818
use Stringable;
@@ -821,6 +821,12 @@ public function switchInsideCase(string $driver): void
821821
// Methods starting with "scope" (e.g. scopeActive) produce virtual methods with
822822
// the prefix stripped and first letter lowercased (e.g. active). The $query
823823
// parameter is removed. Scopes are available as both static and instance methods.
824+
//
825+
// Eloquent Builder methods are forwarded as static methods on model classes.
826+
// User::where('active', true)->orderBy('name')->get() resolves end-to-end.
827+
// Return types are mapped: Builder chain methods return Builder<ConcreteModel>,
828+
// and TModel parameters resolve to the concrete model class. Methods from
829+
// Query\Builder (via @mixin) are included as well.
824830

825831
class BlogAuthor extends \Illuminate\Database\Eloquent\Model
826832
{
@@ -869,6 +875,14 @@ public function demo(): void
869875
// Scope methods — static access
870876
BlogAuthor::active(); // also available as static
871877
BlogAuthor::ofGenre('fiction'); // $genre parameter preserved, $query stripped
878+
879+
// Builder-as-static forwarding
880+
BlogAuthor::where('active', true); // returns Builder<BlogAuthor>
881+
BlogAuthor::where('active', 1)->get(); // returns Collection<BlogAuthor>
882+
BlogAuthor::where('active', 1)->first(); // returns BlogAuthor|null
883+
BlogAuthor::orderBy('name')->limit(10)->get(); // full chain resolution
884+
// Query\Builder methods (@mixin) are also forwarded:
885+
BlogAuthor::whereIn('id', [1, 2])->groupBy('genre')->get();
872886
}
873887
}
874888

@@ -2584,4 +2598,101 @@ class BlogTag extends \Illuminate\Database\Eloquent\Model
25842598
public function getLabel(): string { return ''; }
25852599
}
25862600

2601+
} // end namespace Demo
2602+
2603+
// ── Illuminate Stubs ────────────────────────────────────────────────────────
2604+
// Minimal stubs matching real Laravel classes so that the Eloquent demo models
2605+
// above resolve Builder methods, relationship properties, and scope forwarding
2606+
// without requiring a real Laravel installation.
2607+
2608+
namespace Illuminate\Database\Eloquent {
2609+
2610+
abstract class Model {
2611+
/** @return \Illuminate\Database\Eloquent\Builder<static> */
2612+
public static function query() {}
2613+
}
2614+
2615+
/**
2616+
* @template TModel of \Illuminate\Database\Eloquent\Model
2617+
*
2618+
* @mixin \Illuminate\Database\Query\Builder
2619+
*/
2620+
class Builder implements \Illuminate\Contracts\Database\Eloquent\Builder {
2621+
/** @use \Illuminate\Database\Concerns\BuildsQueries<TModel> */
2622+
use \Illuminate\Database\Concerns\BuildsQueries;
25872623

2624+
/**
2625+
* @param (\Closure(static): mixed)|string|array $column
2626+
* @return $this
2627+
*/
2628+
public function where($column, $operator = null, $value = null, $boolean = 'and') {}
2629+
2630+
/** @return \Illuminate\Database\Eloquent\Collection<int, TModel> */
2631+
public function get($columns = ['*']) { return new Collection(); }
2632+
}
2633+
2634+
/**
2635+
* @template TKey of array-key
2636+
* @template TModel of \Illuminate\Database\Eloquent\Model
2637+
*/
2638+
class Collection {
2639+
/** @return TModel|null */
2640+
public function first(): mixed { return null; }
2641+
public function count(): int { return 0; }
2642+
}
2643+
}
2644+
2645+
namespace Illuminate\Database\Eloquent\Relations {
2646+
class HasMany {}
2647+
class HasOne {}
2648+
class BelongsTo {}
2649+
class BelongsToMany {}
2650+
class MorphOne {}
2651+
class MorphMany {}
2652+
class MorphTo {}
2653+
class MorphToMany {}
2654+
class HasManyThrough {}
2655+
}
2656+
2657+
namespace Illuminate\Database\Concerns {
2658+
2659+
/**
2660+
* @template TValue
2661+
*/
2662+
trait BuildsQueries {
2663+
/** @return TValue|null */
2664+
public function first($columns = ['*']) { return null; }
2665+
}
2666+
}
2667+
2668+
namespace Illuminate\Database\Query {
2669+
2670+
class Builder {
2671+
/**
2672+
* @param string $column
2673+
* @return $this
2674+
*/
2675+
public function whereIn($column, $values, $boolean = 'and', $not = false) { return $this; }
2676+
2677+
/** @return $this */
2678+
public function groupBy(...$groups) { return $this; }
2679+
2680+
/** @return $this */
2681+
public function orderBy($column, $direction = 'asc') { return $this; }
2682+
2683+
/** @return $this */
2684+
public function limit($value) { return $this; }
2685+
2686+
/**
2687+
* @return \Illuminate\Support\Collection<int, \stdClass>
2688+
*/
2689+
public function get($columns = ['*']) {}
2690+
}
2691+
}
2692+
2693+
namespace Illuminate\Contracts\Database\Eloquent {
2694+
/**
2695+
* @mixin \Illuminate\Database\Eloquent\Builder
2696+
*/
2697+
interface Builder {}
2698+
}

0 commit comments

Comments
 (0)