From db7b81b3953bcc5308a530d40a2eb4a4d6936020 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Tue, 3 Nov 2026 21:35:38 +0600 Subject: [PATCH] Resolve Laravel connection names through typed receivers Use the shared type engine to select database, queue, or broadcasting connection names for instance calls and model or job connection properties. Provide completion, hover, definition, diagnostics, and references through the same config identity used by direct facade calls. Handle nullable and narrowed receivers, Queueable traits, named arguments, and unfinished property defaults. Include top-level anonymous classes in the parser used for recovery so nested anonymous model defaults complete. Add twelve integration tests, Laravel demos and runtime assertions, update the changelog, and remove the completed L56 backlog item. Canonicalize two macOS test fixture paths and restore the documented Laravel demo diagnostic baseline found during full-suite validation. Validation: 18,899 Rust tests passed; 342/342 changed executable Rust lines covered. Clippy, formatting, semantic-export tests, doctests, PHP lint and runtime assertions, and framework diagnostic baselines passed. --- docs/CHANGELOG.md | 1 + docs/todo.md | 1 - docs/todo/laravel.md | 12 - examples/laravel/README.md | 1 + examples/laravel/app/Demo.php | 26 +- .../laravel/app/Jobs/SendBakeryReport.php | 21 + .../laravel/app/Models/ReportingCustomer.php | 11 + examples/laravel/assertions.php | 12 + src/blade/typed_receiver.rs | 127 +++- .../laravel_string_keys/connections.rs | 136 ++++ src/completion/laravel_string_keys/mod.rs | 11 + src/parser/mod.rs | 3 +- src/reference_index.rs | 8 +- src/symbol_map/extraction/class_like.rs | 2 + .../extraction/expressions/assignment.rs | 3 + .../extraction/laravel/connections.rs | 72 +++ .../extraction/laravel/method_calls.rs | 5 + src/symbol_map/extraction/laravel/mod.rs | 5 + src/symbol_map/string_keys.rs | 70 +- src/virtual_members/laravel/config_keys.rs | 18 +- tests/integration/laravel_connection_names.rs | 601 ++++++++++++++++++ tests/integration/main.rs | 1 + tests/integration/symfony_container.rs | 8 +- tests/integration/wordpress_hooks.rs | 2 +- 24 files changed, 1113 insertions(+), 44 deletions(-) create mode 100644 examples/laravel/app/Jobs/SendBakeryReport.php create mode 100644 examples/laravel/app/Models/ReportingCustomer.php create mode 100644 src/completion/laravel_string_keys/connections.rs create mode 100644 src/symbol_map/extraction/laravel/connections.rs create mode 100644 tests/integration/laravel_connection_names.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ac97e85df..ca5233c21 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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. - **Switch inlay hints off by kind.** `parameter-names = false` or `closure-types = false` under `[inlay_hints]` hides parameter name hints at call sites or inferred types on closures, without touching other extensions' hints. Editor extensions can pass the same toggles, and changes apply to open files right away. - **WordPress hook names.** A hook name in `add_action()`, `add_filter()` and the rest of the listener API completes, jumps to every `do_action()` or `apply_filters()` that fires it, lists its references, highlights, and renames. A hook fired under a name built at runtime, like `"save_post_{$post->post_type}"`, matches the names it can produce, and completes as a snippet. Core's hooks, and those of plugins and themes installed with Composer, complete and navigate as well, and a project with only the stubs still completes core's. - **`@global` tags type the globals a function imports.** A `@global wpdb $wpdb` tag on a function or method docblock types `$wpdb` after `global $wpdb;`, so WordPress code gets completion and diagnostics on `$wpdb`, `$wp_query` and the rest. A function without a tag gets the type the `@global` tags elsewhere give the global, in the stubs, in libraries, or in the project's own code, so `global $wpdb;` is typed wherever WordPress core or `php-stubs/wordpress-stubs` is indexed. diff --git a/docs/todo.md b/docs/todo.md index 05ca07caa..6b68511fa 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -173,7 +173,6 @@ ships, remove every row that names it. | L69 | [Benchmarks for Laravel string names and Blade](todo/laravel.md#l69-benchmarks-for-laravel-string-names-and-blade) | Medium | Low-Medium | | L71 | [Model-level attributes as column sources](todo/laravel.md#l71-model-level-attributes-as-column-sources) | 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 | | L53 | [Collection key types from the column for `keyBy` / `groupBy` / `pluck`](todo/laravel.md#l53-collection-key-types-from-the-column-for-keyby-groupby-pluck) | Medium | Medium | | L66 | [Related-file navigation for models, events, and listeners](todo/laravel.md#l66-related-file-navigation-for-models-events-and-listeners) | Medium | Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index c65f6ea86..6bd5e8f48 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -533,18 +533,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 fb491dea3..6cc41d002 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -8,6 +8,7 @@ features against a real Laravel installation. - **Eloquent models.** Virtual properties from `$fillable`, `$casts`, `$attributes`, relationships, scopes, accessors, custom collections, and query builder forwarding. A method a model or its builder does not answer is reported, since Eloquent throws for it (see the "Try:" in `Demo::eloquentQuery()`). - **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/`). - **Route navigation.** Go-to-definition for `route('home')` (resolves to `->name('home')` in route files). - **Controller action navigation.** Go-to-definition, hover, rename, references, and completion for route action strings in `[Controller::class, 'method']` callables and `Route::controller(...)->group(...)` routes. diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index dacf28b45..916ff990a 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; @@ -27,6 +28,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; @@ -37,6 +39,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; @@ -1214,6 +1219,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 @@ -1232,8 +1254,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 a4f2d0e8e..bbcd9368e 100644 --- a/src/blade/typed_receiver.rs +++ b/src/blade/typed_receiver.rs @@ -118,8 +118,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. fn confirm_typed_receiver_sites( &self, uri: &str, @@ -148,13 +148,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 { @@ -186,6 +192,13 @@ impl Backend { receiver: ReceiverClass, class_loader: &ResolvedClassLoader<'_>, ) -> Option { + let connection = matches!( + receiver, + ReceiverClass::LaravelConnection + | ReceiverClass::LaravelQueueConnection + | ReceiverClass::LaravelConnectionProperty + ); + let ty = if connection { ty.unwrap_nullable() } else { ty }; if let Some((_, kind)) = receiver .targets() .iter() @@ -193,6 +206,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, + )); + } + } if !receiver.follows_wrappers() { return None; } @@ -429,7 +478,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)>, @@ -502,24 +552,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 6fc50edf2..7aed5450e 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -18,6 +18,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)] @@ -238,6 +239,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( @@ -258,6 +260,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 { // The candidate lists are shared with every other consumer, so only // the names the typed prefix keeps are copied out of them. let candidates = self.string_key_candidates(&ctx.kind, ctx.prefix); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a355ac263..8d89731e5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -255,7 +255,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), @@ -266,7 +266,6 @@ impl Backend { result.push((cls, None, None)); } } - _ => {} } } diff --git a/src/reference_index.rs b/src/reference_index.rs index 82802e8c1..cfe8ebf50 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -764,13 +764,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 03a0f679e..f930782e7 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -741,6 +741,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 11b82408e..259e4b7a1 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); + } // The definition only becomes visible *after* the entire // assignment expression — the RHS still sees the previous 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 e867f3b25..72f4e4038 100644 --- a/src/symbol_map/extraction/laravel/method_calls.rs +++ b/src/symbol_map/extraction/laravel/method_calls.rs @@ -242,6 +242,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 b7a3d4c9b..b6f625d03 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -698,10 +698,16 @@ pub(crate) const SYMFONY_CONTAINER_CONFIGURATOR_NS: &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. @@ -780,11 +786,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)], @@ -906,7 +970,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 = " (Backend, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); let files = [ ("composer.json", COMPOSER), ("vendor/composer/installed.json", INSTALLED),