Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Laravel

- **Laravel connection names follow the receiver's type.** Connection names in instance calls and model or job properties now complete, navigate, show hover details and references, and report unknown names against the appropriate config file. Contributed by @shuvroroy.
- **Container call parameters.** The parameter keys passed to `app()->call()` complete from the callable's parameters and go to them. Contributed by @shuvroroy.
- **Laravel asset paths and Inertia pages.** The paths `asset()`, `mix()`, `@vite` and `Vite::asset()` name, and the pages `Inertia::render()`, `inertia()` and `Route::inertia()` render, complete from the files on disk, jump to the file, and are reported when nothing is there. An unknown Inertia page gets a quick fix that creates it. Page directories and extensions are read from `config/inertia.php`.
- **Laravel related classes.** Code lenses on an Eloquent model go to its factory, policy, observers, migrations, API resources, and custom collection, and a factory, policy, or observer gets one back to its model. An event lists the listeners that handle it, whether registered or discovered from their `handle()` type hint, and a listener links to the events it handles.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ ships, remove every row that names it.
| L46 | [`->can()` on a user model the receiver does not name](todo/laravel.md#l46-can-on-a-user-model-the-receiver-does-not-name) | Medium-High | Medium-High |
| L69 | [Benchmarks for Laravel string names and Blade](todo/laravel.md#l69-benchmarks-for-laravel-string-names-and-blade) | Medium | Low-Medium |
| L49 | [Unguarded Eloquent mass assignment diagnostic](todo/laravel.md#l49-unguarded-eloquent-mass-assignment-diagnostic) | Medium | Medium |
| L56 | [Typed Laravel connection names](todo/laravel.md#l56-typed-laravel-connection-names) | Medium | Medium |
| L58 | [Laravel rate limiter names](todo/laravel.md#l58-laravel-rate-limiter-names) | Medium | Medium |
| BL20 | [Port Bladestan's tests](todo/blade.md#bl20-port-bladestans-tests) | Medium | Medium |
| E7 | [Stub-based framework patches](todo/external-stubs.md#e7-stub-based-framework-patches) | Medium | Medium-High |
Expand Down
12 changes: 0 additions & 12 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,18 +442,6 @@ moving the Blade file — defer that one until the rest is in place.
Symfony service IDs and container parameters are string-key spans too,
declarations included, so their rename comes with the same work.

#### L56. Typed Laravel connection names

**Impact: Medium · Complexity: Medium**

The method name `->connection()` does not identify one config subtree: the
receiver may select `database.connections.*`, `queue.connections.*`, or
`broadcasting.connections.*`. Resolve it through the shared type engine, and
treat `->onConnection()` as a queue connection. A model's `$connection` is a
database connection, while the same property on a queueable job selects a
queue connection. Each confirmed literal should receive the same completion,
hover, navigation, diagnostics, and references as the direct facade spelling.

#### L59. Typed controller middleware names

**Impact: Low-Medium · Complexity: Medium**
Expand Down
1 change: 1 addition & 0 deletions examples/laravel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ features against a real Laravel installation.
- **Computed attributes.** Column suggestions distinguish stored values from accessors, while assignment suggestions include attributes with setters. See `Demo::computedAttributes()`.
- **Model factories.** Convention-based factories (no `@extends Factory<Model>` needed) get `create()`/`make()` returning the model, plus the dynamic `has{Relationship}()` / `for{Relationship}()` methods synthesized from the model's relationships, and `trashed()` when the model uses `SoftDeletes` — all chaining fluently (e.g. `BlogAuthor::factory()->hasPosts(3)->create()`). See `Demo::factories()`.
- **Config and env navigation.** Go-to-definition and find-references for `config('app.name')` keys (resolves to `config/app.php`) and `env('APP_KEY')` vars (resolves to `.env`).
- **Typed connection names.** Completion, hover, navigation, references, and diagnostics follow the database, queue, or broadcasting receiver and model or job properties. See `Demo::typedConnections()`.
- **View navigation.** Go-to-definition for `view('welcome')` and `View::make('admin.users.index')` (resolves to Blade templates in `resources/views/`).
- **Asset paths.** `asset('css/app.css')` and `Vite::asset()`/`@vite` paths complete from the files on disk, jump to the file, and are reported when nothing is there. Inertia page names (`Inertia::render('Users/Index')`) work the same way in a project that installs Inertia.
- **Route navigation.** Go-to-definition for `route('home')` (resolves to `->name('home')` in route files).
Expand Down
26 changes: 24 additions & 2 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use App\Mail\OrderShipped;
use App\Http\Requests\StoreBakeryRequest;
use App\Http\Requests\UpdateBakeryRequest;
use App\Jobs\SendBakeryReport;
use App\Models\Baker;
use App\Models\Bakery;
use App\Models\BakeryOrder;
Expand All @@ -28,6 +29,7 @@
use App\Models\PostCollection;
use App\Models\Review;
use App\Models\ReviewCollection;
use App\Models\ReportingCustomer;
use Database\Factories\AnnotatedPostFactory;
use Database\Factories\BlogAuthorFactory;
use Database\Factories\EditorialFactory;
Expand All @@ -38,6 +40,9 @@
use Illuminate\Container\Attributes\Log as InjectLog;
use Illuminate\Container\Attributes\Storage as InjectStorage;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory;
use Illuminate\Contracts\Queue\Factory as QueueFactory;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Client\Factory as HttpFactory;
Expand Down Expand Up @@ -1277,6 +1282,23 @@ public function namedLaravelResources(): void
Queue::connection('null');
}

public function typedConnections(
ConnectionResolverInterface $database,
QueueFactory $queue,
BroadcastFactory $broadcast,
SendBakeryReport $job,
ReportingCustomer $customer,
): void {
// Try: complete, hover, Ctrl+Click, or find references on each name.
// The receiver selects database.php, queue.php, or broadcasting.php.
$database->connection('mysql');
$queue->connection('redis');
$broadcast->connection('internal');
$job->onConnection('redis');
// Try: open these classes and complete their $connection values.
$customer->getConnectionName();
}

// ── Middleware names ────────────────────────────────────────────────

public function middlewareNames(): void
Expand All @@ -1295,8 +1317,8 @@ public function middlewareNames(): void
// Try: hover `storefront` to list the middleware the group runs.
Route::middleware('storefront')->get('/storefront', fn () => 'shop');

// Unknown middleware name → `invalid_laravel_middleware` diagnostic.
Route::get('/closed', fn () => 'closed')->withoutMiddleware('bakery.closed');
// Try: replace bakery.open with bakery.closed for an unknown-name diagnostic.
Route::get('/closed', fn () => 'closed')->withoutMiddleware('bakery.open');
}


Expand Down
21 changes: 21 additions & 0 deletions examples/laravel/app/Jobs/SendBakeryReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendBakeryReport implements ShouldQueue
{
use Queueable;

public function __construct()
{
// Try: complete or navigate redis against config/queue.php.
$this->connection = 'redis';
}

public function handle(): void
{
}
}
11 changes: 11 additions & 0 deletions examples/laravel/app/Models/ReportingCustomer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Models;

class ReportingCustomer extends Customer
{
protected $table = 'customers';

// Try: complete or navigate mysql against config/database.php.
protected $connection = 'mysql';
}
12 changes: 12 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,18 @@ public function storagePath(string $path = ''): string
$databaseManager = (new ReflectionClass(
\Illuminate\Database\DatabaseManager::class
))->newInstanceWithoutConstructor();
foreach ([
\Illuminate\Database\DatabaseManager::class => \Illuminate\Database\ConnectionResolverInterface::class,
\Illuminate\Queue\QueueManager::class => \Illuminate\Contracts\Queue\Factory::class,
\Illuminate\Broadcasting\BroadcastManager::class => \Illuminate\Contracts\Broadcasting\Factory::class,
] as $managerClass => $contract) {
check("$managerClass implements $contract", is_subclass_of($managerClass, $contract));
check("$contract selects its connection by name", (new ReflectionMethod($contract, 'connection'))->getParameters()[0]->getName() === 'name');
}
$reportJob = new \App\Jobs\SendBakeryReport();
check('SendBakeryReport selects the redis queue connection', $reportJob->connection === 'redis');
check('Queueable::onConnection retains the job and selects its connection', $reportJob->onConnection('redis') === $reportJob && $reportJob->connection === 'redis');
check('ReportingCustomer selects the mysql database connection', (new \App\Models\ReportingCustomer())->getConnectionName() === 'mysql');
$parseConnectionName = new ReflectionMethod($databaseManager, 'parseConnectionName');
foreach (['read', 'write', 'direct'] as $role) {
check(
Expand Down
127 changes: 114 additions & 13 deletions src/blade/typed_receiver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ impl Backend {
.or_else(|| self.get_file_content_arc(uri))
}

/// Resolve the receiver of every candidate site and keep the spans of
/// the ones that turn out to be renders.
/// Resolve the receiver of every candidate site and keep the confirmed
/// string-key spans, including property defaults on their enclosing class.
pub(crate) fn confirm_typed_receiver_sites(
&self,
uri: &str,
Expand Down Expand Up @@ -159,13 +159,19 @@ impl Backend {
for call in calls {
// The receiver is resolved where the call is written, so a
// local reassigned earlier reads as it does there.
let var_ctx = loaders.var_ctx_at(content, call.receiver.span().start.offset);
let Some(ty) =
let ty = if let Some(receiver) = call.receiver {
let var_ctx = loaders.var_ctx_at(content, receiver.span().start.offset);
crate::type_engine::variable::iterable_elements::resolve_expression_type(
call.receiver,
&var_ctx,
receiver, &var_ctx,
)
else {
} else {
crate::class_lookup::find_class_at_offset(
&file_ctx.classes,
call.sites[0].0.start,
)
.map(|class| PhpType::named(class.fqn()))
};
let Some(ty) = ty else {
continue;
};
for (site, index) in call.sites {
Expand Down Expand Up @@ -284,6 +290,13 @@ impl Backend {
return (name == Some(StringKeyKind::YiiRoute))
.then_some(StringKeyKind::YiiUrlArgument);
}
let connection = matches!(
receiver,
ReceiverClass::LaravelConnection
| ReceiverClass::LaravelQueueConnection
| ReceiverClass::LaravelConnectionProperty
);
let ty = if connection { ty.unwrap_nullable() } else { ty };
// A Drupal target is not worth a subtype check outside Drupal: a
// Symfony project's `get()` is never asked about the config factory.
if let Some((_, kind)) = receiver.targets().iter().find(|(fqn, kind)| {
Expand All @@ -293,6 +306,42 @@ impl Backend {
}) {
return Some(*kind);
}
if connection && let crate::php_type::TypeKind::Union(members) = ty.kind() {
let mut kinds = members
.iter()
.filter(|ty| !ty.is_null())
.map(|ty| self.typed_receiver_key_kind(ty, method, index, receiver, class_loader));
let kind = kinds.next()??;
return kinds.all(|other| other == Some(kind)).then_some(kind);
}
// Queueable supplies the connection behavior through a trait, not
// a subtype relationship. Follow the same ancestor/trait walk used
// for member declarations, including traits used by a parent.
if matches!(
receiver,
ReceiverClass::LaravelQueueConnection | ReceiverClass::LaravelConnectionProperty
) {
let queueable = |class: &crate::types::ClassInfo| {
class
.fqn()
.eq_ignore_ascii_case("Illuminate\\Bus\\Queueable")
};
if ty.top_level_class_names().iter().any(|name| {
class_loader(crate::Fqn::new(name)).is_some_and(|class| {
queueable(&class)
|| crate::inheritance::find_declaring_ancestor(
&class,
class_loader,
&queueable,
)
.is_some()
})
}) {
return Some(StringKeyKind::ConfigResource(
crate::symbol_map::LaravelConfigResource::QueueConnection,
));
}
}
// A class that uses core's `ConfigFormBaseTrait` has its `config()`,
// and a trait is no type a target can name.
if receiver == ReceiverClass::ConfigShortcut && self.is_drupal_project() {
Expand Down Expand Up @@ -560,7 +609,8 @@ fn empty_spans() -> Arc<Vec<SymbolSpan>> {
/// One method call that names at least one candidate key, paired with the
/// receiver whose type decides what it names, and the method it calls.
struct ReceiverCall<'ast, 'arena, 'sites> {
receiver: &'ast Expression<'arena>,
/// `None` for a property default, whose enclosing class supplies the type.
receiver: Option<&'ast Expression<'arena>>,
method: &'ast str,
/// Each candidate, with the position of the argument it is in.
sites: Vec<(&'sites TypedReceiverSite, usize)>,
Expand Down Expand Up @@ -637,24 +687,75 @@ impl<'ast, 'arena, 'w, 'sites>
mago_syntax::walker::Walker<'ast, 'arena, CollectCtx<'w, 'ast, 'arena, 'sites>>
for ReceiverCallWalker<'_, 'sites>
{
fn walk_in_method_call(
fn walk_in_call(
&self,
node: &'ast MethodCall<'arena>,
node: &'ast Call<'arena>,
ctx: &mut CollectCtx<'w, 'ast, 'arena, 'sites>,
) {
let ClassLikeMemberSelector::Identifier(method) = &node.method else {
let (object, selector, arguments) = match node {
Call::Method(call) => (call.object, &call.method, &call.argument_list),
Call::NullSafeMethod(call) => (call.object, &call.method, &call.argument_list),
_ => return,
};
let ClassLikeMemberSelector::Identifier(method) = selector else {
return;
};
let mut sites = Vec::new();
for (index, argument) in node.argument_list.arguments.iter().enumerate() {
for (index, argument) in arguments.arguments.iter().enumerate() {
self.matching_sites(argument.value(), index, &mut sites);
}
if !sites.is_empty() {
ctx.calls.push(ReceiverCall {
receiver: node.object,
receiver: Some(object),
method: crate::atom::bytes_to_str(method.value),
sites,
});
}
}

fn walk_in_assignment(
&self,
node: &'ast Assignment<'arena>,
ctx: &mut CollectCtx<'w, 'ast, 'arena, 'sites>,
) {
let Some(receiver) =
crate::symbol_map::extraction::laravel::connection_assignment_receiver(node)
else {
return;
};
let mut sites = Vec::new();
self.matching_sites(node.rhs, 0, &mut sites);
if !sites.is_empty() {
ctx.calls.push(ReceiverCall {
receiver: Some(receiver),
method: "",
sites,
});
}
}

fn walk_in_property(
&self,
node: &'ast Property<'arena>,
ctx: &mut CollectCtx<'w, 'ast, 'arena, 'sites>,
) {
let items = match node {
Property::Plain(plain) => plain.items.as_slice(),
Property::Hooked(hooked) => std::slice::from_ref(&hooked.item),
};
for item in items {
if let PropertyItem::Concrete(item) = item {
let mut sites = Vec::new();
self.matching_sites(item.value, 0, &mut sites);
sites.retain(|(site, _)| site.receiver == ReceiverClass::LaravelConnectionProperty);
if !sites.is_empty() {
ctx.calls.push(ReceiverCall {
receiver: None,
method: "",
sites,
});
}
}
}
}
}
Loading
Loading