diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a688d3f58..009e522e0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/todo.md b/docs/todo.md index 7d86b1cbd..c0daa16a5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 333aa9035..ec3cace17 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -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** diff --git a/examples/laravel/README.md b/examples/laravel/README.md index 0c9a65a41..c742ff1ca 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -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` 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). diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 4b048d853..5c84c3048 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -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; @@ -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; @@ -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; @@ -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 @@ -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'); } diff --git a/examples/laravel/app/Jobs/SendBakeryReport.php b/examples/laravel/app/Jobs/SendBakeryReport.php new file mode 100644 index 000000000..a01d286ac --- /dev/null +++ b/examples/laravel/app/Jobs/SendBakeryReport.php @@ -0,0 +1,21 @@ +connection = 'redis'; + } + + public function handle(): void + { + } +} diff --git a/examples/laravel/app/Models/ReportingCustomer.php b/examples/laravel/app/Models/ReportingCustomer.php new file mode 100644 index 000000000..fa58f08fa --- /dev/null +++ b/examples/laravel/app/Models/ReportingCustomer.php @@ -0,0 +1,11 @@ +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( diff --git a/src/blade/typed_receiver.rs b/src/blade/typed_receiver.rs index 81b5532e4..3fb8b2544 100644 --- a/src/blade/typed_receiver.rs +++ b/src/blade/typed_receiver.rs @@ -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, @@ -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 { @@ -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)| { @@ -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() { @@ -560,7 +609,8 @@ fn empty_spans() -> Arc> { /// 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)>, @@ -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, + }); + } + } + } + } } diff --git a/src/completion/laravel_string_keys/connections.rs b/src/completion/laravel_string_keys/connections.rs new file mode 100644 index 000000000..5eda1c597 --- /dev/null +++ b/src/completion/laravel_string_keys/connections.rs @@ -0,0 +1,136 @@ +//! Typed Laravel connection completion, sharing the indexed receiver checks. + +use super::*; +use crate::completion::eloquent_string::{extract_identifier_backwards, extract_receiver_subject}; +use crate::symbol_map::ReceiverClass; +use crate::symbol_map::extraction::laravel::connection_receiver_method; +use crate::types::AccessKind; + +impl Backend { + /// Complete a connection selected by a typed call or property value. + pub(super) fn try_laravel_connection_completion( + &self, + content: &str, + position: Position, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let cursor = crate::text_position::position_to_offset(content, position) as usize; + let (quote, _) = code.open_string?; + let kind = if let Some((callable, slot)) = scalar_argument_at(content, code) { + let (method, _) = extract_identifier_backwards(callable)?; + let (receiver, parameter) = connection_receiver_method(method)?; + if !slot.is_first_or_named(parameter) { + return None; + } + let operator = code.enclosing_paren()?.callee_operator?; + if operator.is_static { + return None; + } + let subject = extract_receiver_subject(content, &operator)?; + self.connection_subject_kind(&subject, receiver, content, cursor, ctx)? + } else { + let lhs = code.code_before.strip_suffix('=')?.trim_end(); + let (property, before) = extract_identifier_backwards(lhs)?; + if property != "connection" { + return None; + } + if before.ends_with('$') { + let name_offset = before.len() as u32 - 1; + let loaders = self.file_loaders(ctx); + let class = self.connection_property_class(content, code, ctx, name_offset)?; + let class_loader = |name: crate::Fqn<'_>| { + if name.as_str().eq_ignore_ascii_case(&class.fqn()) { + Some(Arc::clone(&class)) + } else { + loaders.resolved_class_loader()(name) + } + }; + self.typed_receiver_key_kind( + &crate::php_type::PhpType::named(class.fqn()), + "", + 0, + ReceiverClass::LaravelConnectionProperty, + &class_loader, + )? + } else { + let chars: Vec = lhs.chars().collect(); + let (subject, access) = + crate::type_engine::subject_extraction::detect_access_operator( + &chars, + chars.len(), + )?; + if access != AccessKind::Arrow { + return None; + } + self.connection_subject_kind( + &subject, + ReceiverClass::LaravelConnectionProperty, + content, + cursor, + ctx, + )? + } + }; + self.laravel_string_key_response( + content, + position, + LaravelStringKeyContext { + kind, + prefix: &content[quote + 1..cursor], + content_start_offset: quote + 1, + }, + ) + } + + /// Recover the declaration when an unfinished string prevents the + /// normal parse from recording the property being edited. + fn connection_property_class( + &self, + content: &str, + code: &CodeContext<'_>, + ctx: &FileContext, + offset: u32, + ) -> Option> { + let declares = |class: &crate::types::ClassInfo| { + class + .properties + .iter() + .any(|property| property.name_offset == offset && !property.is_static) + }; + if let Some(class) = crate::class_lookup::find_shared_class_at_offset(&ctx.classes, offset) + && declares(class) + { + return Some(Arc::clone(class)); + } + let (quote, _) = code.open_string?; + let mut patched = content[..quote].to_string(); + patched.push_str("'';"); + for bracket in code.open_brackets.iter().rev() { + patched.push(match bracket.byte { + b'(' => ')', + b'[' => ']', + _ => '}', + }); + } + patched.push(';'); + self.parse_php_resolved(&patched) + .into_iter() + .find(declares) + .map(Arc::new) + } + + fn connection_subject_kind( + &self, + subject: &str, + receiver: ReceiverClass, + content: &str, + cursor: usize, + ctx: &FileContext, + ) -> Option { + let loaders = self.file_loaders(ctx); + let rctx = loaders.resolution_ctx_at(content, cursor as u32); + let ty = Self::resolve_arg_text_to_type(subject, &rctx)?; + self.typed_receiver_key_kind(&ty, "", 0, receiver, loaders.resolved_class_loader()) + } +} diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index 4ec88ad10..e0753d56f 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -21,6 +21,7 @@ use crate::completion::source::code_context::CodeContext; use crate::symbol_map::{LaravelConfigResource, StringKeyKind}; use crate::types::FileContext; +mod connections; mod context; mod enumerate; #[cfg(test)] @@ -317,6 +318,7 @@ impl Backend { Some(&indexed_function_exists), Some(&indexed_class_exists), ) + .or_else(|| self.try_laravel_connection_completion(content, position, code, file_ctx)) } fn try_laravel_string_key_completion_inner( @@ -337,6 +339,15 @@ impl Backend { indexed_class_exists, )?; + self.laravel_string_key_response(content, position, ctx) + } + + fn laravel_string_key_response( + &self, + content: &str, + position: Position, + ctx: LaravelStringKeyContext<'_>, + ) -> Option { // `public/` and the project root hold far too much to list whole, so // a path is offered one directory at a time, like a path helper's. if let Some(base) = self.asset_completion_base(ctx.kind) { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a0778fd8d..aa89f7226 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -292,7 +292,7 @@ impl Backend { result.push((cls, block_ns.clone(), block.map(|(i, _)| i))); } } - statement if Self::is_classlike_extraction_candidate(statement) => { + statement => { let mut top_classes = Vec::new(); Self::extract_classes_from_statements( std::iter::once(statement), @@ -303,7 +303,6 @@ impl Backend { result.push((cls, None, None)); } } - _ => {} } } diff --git a/src/reference_index.rs b/src/reference_index.rs index 11c5caba8..684d86a3d 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -807,13 +807,7 @@ impl Backend { } } for kind in kinds { - entries.push(( - ReferenceIndexKey::StringKey { - kind, - key: site.key.clone(), - }, - false, - )); + entries.push((string_key_reference_key(kind, &site.key), false)); } } if is_laravel { diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index a3e2ce830..02058a71f 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -824,6 +824,8 @@ pub(super) fn extract_from_property<'a>(property: &'a Property<'a>, ctx: &mut Ex extract_from_hint_ctx(hint, &mut ctx.spans, ClassRefContext::TypeHint); } + record_connection_property(property, ctx); + // Property variable names and default value expressions. match property { Property::Plain(plain) => { diff --git a/src/symbol_map/extraction/expressions/assignment.rs b/src/symbol_map/extraction/expressions/assignment.rs index 94b7e55f9..8793d6a12 100644 --- a/src/symbol_map/extraction/expressions/assignment.rs +++ b/src/symbol_map/extraction/expressions/assignment.rs @@ -13,6 +13,9 @@ pub(super) fn extract_assignment_expr<'a>( extract_from_expression(assign.rhs, ctx, scope_start); note_route_configurator_assignment(assign, ctx); note_own_receiver_assignment(assign, ctx); + if connection_assignment_receiver(assign).is_some() { + record_connection_site(ReceiverClass::LaravelConnectionProperty, assign.rhs, ctx); + } crate::symbol_map::extraction::doctrine_dql::note_builder_assignment(assign, ctx); note_yii_query_assignment(assign, ctx, scope_start); emit_drupal_render_element_assignment(assign, ctx); diff --git a/src/symbol_map/extraction/laravel/connections.rs b/src/symbol_map/extraction/laravel/connections.rs new file mode 100644 index 000000000..3d0b7aece --- /dev/null +++ b/src/symbol_map/extraction/laravel/connections.rs @@ -0,0 +1,72 @@ +//! Connection names whose config subtree is determined by the receiver's type. + +use super::*; +use crate::virtual_members::laravel::helpers::resolved_string_literal; + +/// The receiver and parameter that identify a Laravel connection selection. +pub(crate) fn connection_receiver_method(method: &str) -> Option<(ReceiverClass, &'static str)> { + if method.eq_ignore_ascii_case("connection") { + Some((ReceiverClass::LaravelConnection, "name")) + } else if method.eq_ignore_ascii_case("onConnection") { + Some((ReceiverClass::LaravelQueueConnection, "connection")) + } else { + None + } +} + +/// Record a literal for the shared type engine to classify after indexing. +pub(in crate::symbol_map::extraction) fn record_connection_site( + receiver: ReceiverClass, + value: &Expression<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + let Some((key, start, end)) = resolved_string_literal(value, ctx.content) else { + return; + }; + ctx.typed_receiver_sites.push(TypedReceiverSite { + start: start as u32, + end: end as u32, + key: key.to_string(), + receiver, + is_write: false, + is_optional: false, + guards: 0, + }); +} + +/// Record a connection property default without assuming what its class is. +pub(in crate::symbol_map::extraction) fn record_connection_property( + property: &Property<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if property.modifiers().iter().any(Modifier::is_static) { + return; + } + let items = match property { + 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 + && item.variable.name == b"$connection" + { + record_connection_site(ReceiverClass::LaravelConnectionProperty, item.value, ctx); + } + } +} + +/// The object whose connection a plain property assignment selects. +pub(crate) fn connection_assignment_receiver<'ast, 'arena>( + assign: &'ast Assignment<'arena>, +) -> Option<&'ast Expression<'arena>> { + if !assign.operator.is_assign() { + return None; + } + let Expression::Access(Access::Property(access)) = assign.lhs else { + return None; + }; + let ClassLikeMemberSelector::Identifier(property) = &access.property else { + return None; + }; + (property.value == b"connection").then_some(access.object) +} diff --git a/src/symbol_map/extraction/laravel/method_calls.rs b/src/symbol_map/extraction/laravel/method_calls.rs index 87d4da13e..773a33ba7 100644 --- a/src/symbol_map/extraction/laravel/method_calls.rs +++ b/src/symbol_map/extraction/laravel/method_calls.rs @@ -256,6 +256,11 @@ pub(in crate::symbol_map::extraction) fn emit_laravel_method_call_spans<'a>( argument_list: &'a ArgumentList<'a>, ctx: &mut ExtractionCtx<'a>, ) { + if let Some((receiver, parameter)) = connection_receiver_method(member_name) + && let Some(value) = argument_expr_for_parameter(argument_list, parameter) + { + record_connection_site(receiver, value, ctx); + } let receiver = AstReceiver { object, in_console_command: ctx.in_console_command, diff --git a/src/symbol_map/extraction/laravel/mod.rs b/src/symbol_map/extraction/laravel/mod.rs index 978345577..4046c2e85 100644 --- a/src/symbol_map/extraction/laravel/mod.rs +++ b/src/symbol_map/extraction/laravel/mod.rs @@ -10,6 +10,7 @@ use super::*; mod attributes; mod commands; mod config; +mod connections; mod container; mod facade_calls; mod gates; @@ -24,6 +25,9 @@ mod views; pub(in crate::symbol_map::extraction) use attributes::*; pub(in crate::symbol_map::extraction) use commands::*; pub(in crate::symbol_map::extraction) use config::*; +pub(in crate::symbol_map::extraction) use connections::{ + record_connection_property, record_connection_site, +}; pub(in crate::symbol_map::extraction) use container::*; pub(in crate::symbol_map::extraction) use facade_calls::*; pub(in crate::symbol_map::extraction) use gates::*; @@ -36,6 +40,7 @@ pub(in crate::symbol_map::extraction) use spans::*; pub(in crate::symbol_map::extraction) use views::*; pub(crate) use config::{config_keys_written_by, is_config_repository_method}; +pub(crate) use connections::{connection_assignment_receiver, connection_receiver_method}; pub(crate) use container::is_laravel_container_expr; pub(crate) use facade_calls::{ argument_takes_name_keys, argument_takes_name_list, facade_static_call_arg_kind, diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index cce748019..6216add30 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -1272,10 +1272,16 @@ const SYMFONY_DENORMALIZER: &str = pub(crate) const SYMFONY_ROUTING_CONFIGURATOR_NS: &str = "Symfony\\Component\\Routing\\Loader\\Configurator\\"; -/// The classes a receiver has to be an instance of for the call it receives -/// to name a string key, and which kind of key each one makes it. +/// The classes or traits whose behavior makes a receiver's call or property +/// value name a string key, and which kind of key each one makes it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ReceiverClass { + /// A Laravel manager or factory whose `connection()` selects a config subtree. + LaravelConnection, + /// A queued dispatch or an object using Laravel's Queueable trait. + LaravelQueueConnection, + /// The connection selected by an Eloquent model or a queueable job property. + LaravelConnectionProperty, /// Laravel's view factory, whose `make()` / `first()` / `exists()` / /// `renderWhen()` / `renderUnless()` / `renderEach()` all name a /// template. @@ -1450,11 +1456,69 @@ impl ReceiverClass { /// Each class the receiver's type can be a subtype of, most specific /// first, with the kind of key the call names when it is. pub(crate) fn targets(self) -> &'static [(&'static str, StringKeyKind)] { + use super::LaravelConfigResource::{ + BroadcastConnection, DatabaseConnection, QueueConnection, + }; + use StringKeyKind::ConfigResource; use StringKeyKind::{ ContainerParameter, ServiceId, SymfonyEvent, SymfonyRoute, SymfonyTranslation, TranslationDomain, TwigTemplate, TwigVariable, View, YiiRoute, YiiView, }; match self { + Self::LaravelConnection => &[ + ( + "Illuminate\\Database\\ConnectionResolverInterface", + ConfigResource(DatabaseConnection), + ), + ( + "Illuminate\\Contracts\\Queue\\Factory", + ConfigResource(QueueConnection), + ), + ( + "Illuminate\\Contracts\\Broadcasting\\Factory", + ConfigResource(BroadcastConnection), + ), + ], + Self::LaravelQueueConnection => &[ + ( + "Illuminate\\Foundation\\Bus\\PendingDispatch", + ConfigResource(QueueConnection), + ), + ( + "Illuminate\\Foundation\\Bus\\PendingChain", + ConfigResource(QueueConnection), + ), + ( + "Illuminate\\Bus\\PendingBatch", + ConfigResource(QueueConnection), + ), + ( + "Illuminate\\Events\\QueuedClosure", + ConfigResource(QueueConnection), + ), + ( + "Illuminate\\Bus\\Queueable", + ConfigResource(QueueConnection), + ), + ], + Self::LaravelConnectionProperty => &[ + ( + "Illuminate\\Database\\Eloquent\\Model", + ConfigResource(DatabaseConnection), + ), + ( + "Illuminate\\Contracts\\Queue\\ShouldQueue", + ConfigResource(QueueConnection), + ), + ( + "Illuminate\\Events\\QueuedClosure", + ConfigResource(QueueConnection), + ), + ( + "Illuminate\\Bus\\Queueable", + ConfigResource(QueueConnection), + ), + ], // The concrete `Illuminate\View\Factory` implements the // contract, so the contract covers both spellings. Self::ViewFactory => &[("Illuminate\\Contracts\\View\\Factory", View)], @@ -1677,7 +1741,7 @@ impl ReceiverClass { } } -/// A method call that names a string key, but whose meaning lives in its +/// A call or property value that names a string key, but whose meaning lives in its /// receiver's *type* rather than its spelling: a constructor-injected /// `Factory $views` behind `$this->views->make('page')`, a mailable held in /// a local behind `$mail->view('emails.shipped')`, a `ContainerInterface diff --git a/src/virtual_members/laravel/config_keys.rs b/src/virtual_members/laravel/config_keys.rs index b09ee7cc1..77abc9541 100644 --- a/src/virtual_members/laravel/config_keys.rs +++ b/src/virtual_members/laravel/config_keys.rs @@ -441,7 +441,7 @@ pub(crate) fn find_all_config_references( } let mut locations = Vec::new(); - // Usages: walk pre-built symbol spans — no file re-parse needed. + // Resolve only candidates that could name this same config entry. for (file_uri, symbol_map) in snapshot { let Some(parsed_uri) = crate::util::parse_url(file_uri) else { continue; @@ -450,7 +450,21 @@ pub(crate) fn find_all_config_references( // one line table rather than rescanning the file per hit. let file_content = std::cell::OnceCell::new(); let lines = std::cell::OnceCell::new(); - for span in &symbol_map.spans { + let extra = symbol_map + .typed_receiver_sites + .iter() + .any(|site| { + site.receiver + .targets() + .iter() + .any(|(_, kind)| config_keys_match(target_kind, target_key, kind, &site.key)) + }) + .then(|| backend.typed_receiver_spans_for(file_uri, symbol_map)); + for span in symbol_map + .spans + .iter() + .chain(extra.iter().flat_map(|spans| spans.iter())) + { if config_span_matches(span, target_kind, target_key) { let Some(content) = file_content .get_or_init(|| backend.get_file_content_arc(file_uri)) diff --git a/tests/integration/laravel_connection_names.rs b/tests/integration/laravel_connection_names.rs new file mode 100644 index 000000000..5832929c7 --- /dev/null +++ b/tests/integration/laravel_connection_names.rs @@ -0,0 +1,601 @@ +//! Connection names selected through typed receivers and model/job properties. + +use crate::common::{ + completion_response, create_psr4_workspace, definition_locations, goto_definition_at, + hover_text, open_php, position_after, response_labels, +}; +use phpantom_lsp::Backend; +use tower_lsp_server::LanguageServer; +use tower_lsp_server::gen_lsp_types::*; + +const COMPOSER: &str = r#"{"require":{"laravel/framework":"^12.0"},"autoload":{"psr-4":{"App\\":"app/","Illuminate\\":"framework/"}}}"#; +const DATABASE: &str = " ['shared' => [], 'sqlite' => []]];"; +const QUEUE: &str = " ['shared' => [], 'sync' => []]];"; +const BROADCAST: &str = " ['shared' => [], 'reverb' => []]];"; + +async fn workspace(source: &str) -> (Backend, tempfile::TempDir, Uri) { + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("config/database.php", DATABASE), + ("config/queue.php", QUEUE), + ("config/broadcasting.php", BROADCAST), + ( + "framework/Database/ConnectionResolverInterface.php", + "connection = $connection; return $this; } }", + ), + ( + "framework/Foundation/Queue/Queueable.php", + " Vec { + let pos = position_after(source, needle); + response_labels(completion_response(backend, uri, pos.line, pos.character, None).await) +} + +const SOURCE: &str = r#"connection = 'sqlite'; } +} +class JobBase { use Queueable; } +class Job extends JobBase { + public function change(): void { $this->connection = 'sync'; } +} +class ContractJob implements \Illuminate\Contracts\Queue\ShouldQueue { + public $connection = 'sync'; +} +class Consumer { + public function __construct(private Database $db) {} + public function run(Queue $queue, Broadcast $broadcast, Job $job, + \Illuminate\Foundation\Bus\PendingDispatch $pending, + \Illuminate\Foundation\Bus\PendingChain $chain): void { + $this->db->connection('shared'); + $queue->connection('shared'); + $broadcast->connection('shared'); + $job->onConnection('shared'); + $pending->onConnection(connection: 'shared'); + $chain->onConnection('shared'); + $queue?->connection(name: 'sync'); + $this->db->CONNECTION(name: 'sqlite::read'); + $job->onConnection('sync')->onConnection('sync'); + $manager = new \Illuminate\Database\DatabaseManager(); + $manager->connection('sqlite'); + } +} +"#; + +#[tokio::test] +async fn typed_connections_complete_the_receiver_config_family() { + let (backend, _dir, uri) = workspace(SOURCE).await; + for (needle, expected) in [ + ("$this->db->connection('", vec!["shared", "sqlite"]), + ("$queue->connection('", vec!["null", "shared", "sync"]), + ("$broadcast->connection('", vec!["reverb", "shared"]), + ("$job->onConnection('", vec!["null", "shared", "sync"]), + ( + "$pending->onConnection(connection: '", + vec!["null", "shared", "sync"], + ), + ("$chain->onConnection('", vec!["null", "shared", "sync"]), + ( + "$queue?->connection(name: '", + vec!["null", "shared", "sync"], + ), + ( + "$this->db->CONNECTION(name: 'sqlite::", + vec!["sqlite::read", "sqlite::write", "sqlite::direct"], + ), + ( + "$job->onConnection('sync')->onConnection('", + vec!["null", "shared", "sync"], + ), + ("$manager->connection('", vec!["shared", "sqlite"]), + ("protected $connection = '", vec!["shared", "sqlite"]), + ("$this->connection = 'sq", vec!["sqlite"]), + ("$this->connection = 'sy", vec!["sync"]), + ("public $connection = '", vec!["null", "shared", "sync"]), + ] { + assert_eq!( + labels_at(&backend, &uri, SOURCE, needle).await, + expected, + "{needle}" + ); + } +} + +#[tokio::test] +async fn typed_connections_share_hover_definition_and_reference_identity() { + let (backend, dir, uri) = workspace(SOURCE).await; + for (needle, label, file, count) in [ + ( + "$this->db->connection('sha", + "Database connection", + "database.php", + 3, + ), + ( + "$queue->connection('sha", + "Queue connection", + "queue.php", + 5, + ), + ( + "$broadcast->connection('sha", + "Broadcast connection", + "broadcasting.php", + 2, + ), + ( + "protected $connection = 'sha", + "Database connection", + "database.php", + 3, + ), + ( + "$pending->onConnection(connection: 'sha", + "Queue connection", + "queue.php", + 5, + ), + ] { + let position = position_after(SOURCE, needle); + let hover = backend + .handle_hover(uri.as_str(), SOURCE, position) + .expect(needle); + assert!(hover_text(&hover).contains(label), "{needle}: {hover:?}"); + let locations = definition_locations( + goto_definition_at(&backend, &uri, position.line, position.character).await, + ); + assert_eq!(locations.len(), 1, "{needle}: {locations:?}"); + assert_eq!( + locations[0].uri, + Uri::from_file_path(dir.path().join("config").join(file)).unwrap() + ); + let refs = backend + .find_references(uri.as_str(), SOURCE, position, true) + .expect(needle); + assert_eq!(refs.len(), count, "{needle}: {refs:?}"); + } + for (needle, label) in [ + ("$queue?->connection(name: 'sy", "Queue connection"), + ("$this->connection = 'sq", "Database connection"), + ("$this->connection = 'sy", "Queue connection"), + ("public $connection = 'sy", "Queue connection"), + ] { + let hover = backend + .handle_hover(uri.as_str(), SOURCE, position_after(SOURCE, needle)) + .expect(needle); + assert!(hover_text(&hover).contains(label), "{needle}: {hover:?}"); + } +} + +#[tokio::test] +async fn typed_connections_diagnose_only_confirmed_literal_selections() { + let source = format!( + "{SOURCE}\nfunction invalid(Database $db, Queue $queue, Broadcast $broadcast, Job $job) {{\n$db->connection('sync');\n$queue->connection('sqlite');\n$broadcast->connection('sqlite');\n$job->onConnection('sqlite');\n$queue->connection('null');\n$db->connection('sqlite::read');\n$db->connection(null);\n$queue->connection($unknown);\n}}\nclass InvalidModel extends Model {{ protected $connection = 'sync'; }}\nclass InvalidJob extends Job {{ public function change(): void {{ $this->connection = 'sqlite'; }} }}" + ); + let (backend, _dir, uri) = workspace(&source).await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), &source, &mut diagnostics); + let invalid: Vec<_> = diagnostics + .iter() + .filter_map(|d| match &d.code { + Some(Code::String(code)) if code.starts_with("invalid_laravel_") => Some(code.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + invalid + .iter() + .filter(|code| **code == "invalid_laravel_database_connection") + .count(), + 2, + "{diagnostics:?}" + ); + assert_eq!( + invalid + .iter() + .filter(|code| **code == "invalid_laravel_queue_connection") + .count(), + 3, + "{diagnostics:?}" + ); + assert_eq!( + invalid + .iter() + .filter(|code| **code == "invalid_laravel_broadcast_connection") + .count(), + 1, + "{diagnostics:?}" + ); + assert_eq!(invalid.len(), 6); +} + +#[tokio::test] +async fn typed_connections_ignore_homonyms_and_other_argument_shapes() { + let source = r#"connection('shared'); + $this->onConnection('shared'); + $this->connection = 'shared'; + $connection = 'shared'; + } +} + +function run(\Illuminate\Database\ConnectionResolverInterface $db) { + $db->connection(wrong: 'shared'); + $db->connection('sqlite', 'shared'); + $db->connection(['shared']); + $db->connection('sql' . 'shared'); + $db->connection(...['shared']); +} +class StaticModel extends \Illuminate\Database\Eloquent\Model { public static $connection = 'shared'; } +"#; + let (backend, _dir, uri) = workspace(source).await; + for (offset, _) in source.match_indices("shared") { + let position = crate::common::offset_position(source, offset + 2); + let labels = response_labels( + completion_response(&backend, &uri, position.line, position.character, None).await, + ); + assert!( + !labels.iter().any(|label| label == "shared"), + "{offset}: {labels:?}" + ); + let hover = backend.handle_hover(uri.as_str(), source, position); + assert!( + hover + .as_ref() + .is_none_or(|h| !hover_text(h).contains("connection**")), + "{offset}: {hover:?}" + ); + } +} + +#[tokio::test] +async fn typed_connections_references_include_config_facades_and_other_files() { + let (backend, dir, uri) = workspace(SOURCE).await; + let other = r#"connection('shared::read'); + DB::connection('shared'); + config('database.connections.shared'); +} +"#; + let path = dir.path().join("app/Other.php"); + std::fs::write(&path, other).unwrap(); + let other_uri = Uri::from_file_path(path).unwrap(); + open_php(&backend, &other_uri, other).await; + for (file_uri, content, needle) in [ + (&uri, SOURCE, "$this->db->connection('sha"), + (&other_uri, other, "$manager->connection('sha"), + (&other_uri, other, "config('database.connections.sha"), + ] { + let references = backend + .find_references( + file_uri.as_str(), + content, + position_after(content, needle), + false, + ) + .unwrap(); + assert_eq!(references.len(), 5, "{needle}: {references:?}"); + assert_eq!( + references + .iter() + .filter(|location| location.uri == other_uri) + .count(), + 3 + ); + } + let config_uri = Uri::from_file_path(dir.path().join("config/database.php")).unwrap(); + let references = backend + .find_references( + config_uri.as_str(), + DATABASE, + position_after(DATABASE, "'sha"), + true, + ) + .unwrap(); + assert_eq!(references.len(), 6, "{references:?}"); +} + +#[tokio::test] +async fn typed_connections_follow_nullable_and_narrowed_receivers() { + let source = r#"connection('shared'); + $ambiguous->connection('shared'); + if ($ambiguous instanceof Queue) { + $ambiguous->connection('sync'); + } + $nullable = new \Illuminate\Database\DatabaseManager(); + $nullable->connection('sqlite'); +} +"#; + let (backend, _dir, uri) = workspace(source).await; + for (needle, expected) in [ + ("$nullable?->connection('", vec!["shared", "sqlite"]), + ("$ambiguous->connection('sy", vec!["sync"]), + ("$nullable->connection('", vec!["shared", "sqlite"]), + ] { + assert_eq!( + labels_at(&backend, &uri, source, needle).await, + expected, + "{needle}" + ); + assert!( + backend + .handle_hover(uri.as_str(), source, position_after(source, needle)) + .is_some() + ); + } + let ambiguous = position_after(source, "$ambiguous->connection('sha"); + assert!( + !labels_at(&backend, &uri, source, "$ambiguous->connection('sha") + .await + .contains(&"shared".to_string()) + ); + assert!( + backend + .handle_hover(uri.as_str(), source, ambiguous) + .is_none() + ); +} + +#[tokio::test] +async fn typed_connections_complete_unfinished_literals_and_refresh_receiver_types() { + for (source, expected) in [ + ( + "connection(name: '", + vec!["null", "shared", "sync"], + ), + ( + "connection('shared'); }"; + let (backend, _dir, uri) = workspace(source).await; + let position = position_after(source, "connection('sha"); + assert!( + hover_text( + &backend + .handle_hover(uri.as_str(), source, position) + .unwrap() + ) + .contains("Queue connection") + ); + let changed = source.replace( + "Contracts\\Queue\\Factory", + "Contracts\\Broadcasting\\Factory", + ); + open_php(&backend, &uri, &changed).await; + let position = position_after(&changed, "connection('sha"); + assert!( + hover_text( + &backend + .handle_hover(uri.as_str(), &changed, position) + .unwrap() + ) + .contains("Broadcast connection") + ); +} + +#[tokio::test] +async fn typed_connections_cover_queue_dispatch_forms_and_hooked_properties() { + let source = r#" $this->connection; } +} +function queued(\Illuminate\Bus\PendingBatch $batch, \Illuminate\Events\QueuedClosure $listener): void { + $batch->onConnection('shared'); + $listener->onConnection('sync'); + $listener->connection = 'sync'; +} +"#; + let (backend, dir, uri) = workspace(source).await; + for (relative, stub) in [ + ( + "framework/Bus/PendingBatch.php", + "onConnection('", + "$listener->onConnection('", + "$listener->connection = '", + ] { + assert_eq!( + labels_at(&backend, &uri, source, needle).await, + ["null", "shared", "sync"], + "{needle}" + ); + let hover = backend + .handle_hover(uri.as_str(), source, position_after(source, needle)) + .unwrap(); + assert!(hover_text(&hover).contains("Queue connection")); + } +} + +#[tokio::test] +async fn typed_connections_do_not_diagnose_unreadable_config_families() { + let source = "connection('dynamic'); }"; + let (backend, dir, uri) = workspace(source).await; + let path = dir.path().join("config/queue.php"); + std::fs::remove_file(&path).unwrap(); + backend + .did_change_watched_files(DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Uri::from_file_path(&path).unwrap(), + kind: FileChangeType::Deleted, + }], + }) + .await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + assert!( + diagnostics + .iter() + .all(|d| d.code != Some(Code::String("invalid_laravel_queue_connection".into()))), + "{diagnostics:?}" + ); + std::fs::write(&path, " ['dynamic' => []]];").unwrap(); + backend + .did_change_watched_files(DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Uri::from_file_path(path).unwrap(), + kind: FileChangeType::Created, + }], + }) + .await; + assert_eq!( + labels_at(&backend, &uri, source, "connection('").await, + ["dynamic", "null"] + ); +} + +#[tokio::test] +async fn typed_connections_are_gated_on_laravel_projects() { + let source = "connection('sync'); }"; + let (backend, dir) = create_psr4_workspace( + r#"{"autoload":{"psr-4":{"App\\":"app/"}}}"#, + &[("config/queue.php", QUEUE), ("app/Plain.php", source)], + ); + backend.initialized(InitializedParams {}).await; + let uri = Uri::from_file_path(dir.path().join("app/Plain.php")).unwrap(); + open_php(&backend, &uri, source).await; + assert!( + !labels_at(&backend, &uri, source, "connection('") + .await + .contains(&"sync".to_string()) + ); + assert!( + backend + .handle_hover( + uri.as_str(), + source, + position_after(source, "connection('sy") + ) + .is_none() + ); + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + assert!( + diagnostics + .iter() + .all(|d| d.code != Some(Code::String("invalid_laravel_queue_connection".into()))) + ); +} + +#[tokio::test] +async fn typed_connections_recover_nested_anonymous_model_defaults() { + let source = "