diff --git a/benches/laravel_completion.rs b/benches/laravel_completion.rs index adc469ef7..61bcfd800 100644 --- a/benches/laravel_completion.rs +++ b/benches/laravel_completion.rs @@ -1,4 +1,4 @@ -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; use phpantom_lsp::Backend; use std::collections::HashMap; use tower_lsp::LanguageServer; @@ -13,8 +13,10 @@ fn rt() -> tokio::runtime::Runtime { async fn setup_laravel_backend() -> Backend { let mut stubs = HashMap::new(); - stubs.insert("Illuminate\\Database\\Eloquent\\Model", " (String, Position, Range) { + let mut source = String::from( + r#"morphTo(); } +} + +Relation::morphMap(['post' => Post::class]); +Comment::whereIn('comments.subject_type', ["#, + ); + for _ in 1..literal_count { + source.push_str("'post', "); + } + let alias_start = source.len() + 1; + source.push_str("'po']);\n"); + + let before_alias = &source[..alias_start]; + let line = before_alias.bytes().filter(|byte| *byte == b'\n').count() as u32; + let character = before_alias.rsplit('\n').next().unwrap().len() as u32; + let start = Position::new(line, character); + let end = Position::new(line, character + 2); + (source, end, Range::new(start, end)) +} + +fn assert_morph_alias_completion(response: Option, expected_range: Range) { + let items = match response.expect("morph column completion must return a response") { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert_eq!(items.len(), 1, "expected only the registered morph alias"); + assert_eq!(items[0].label, "post"); + assert_eq!(items[0].kind, Some(CompletionItemKind::ENUM_MEMBER)); + let Some(CompletionTextEdit::Edit(edit)) = &items[0].text_edit else { + panic!("morph alias completion must replace the literal contents"); + }; + assert_eq!(edit.range, expected_range); + assert_eq!(edit.new_text, "post"); +} + +fn bench_morph_column_completion(c: &mut Criterion) { + let runtime = rt(); + let mut group = c.benchmark_group("laravel_morph_column_completion"); + + for literal_count in [1, 128] { + let backend = runtime.block_on(setup_laravel_backend()); + let (source, position, range) = generate_morph_column_source(literal_count); + let uri = runtime.block_on(open_file( + &backend, + &format!("file:///bench/morph_columns_{literal_count}.php"), + &source, + )); + let params = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + context: Some(CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + let request = || { + runtime + .block_on(backend.completion(params.clone())) + .expect("morph column completion request failed") + }; + + backend.update_ast(uri.as_str(), &source); + backend.clear_completion_cache(); + assert_morph_alias_completion(request(), range); + assert_morph_alias_completion(request(), range); + + group.bench_function(BenchmarkId::new("cold_confirmation", literal_count), |b| { + // Setups mutate shared cache state, so each must precede exactly + // one timed request instead of being grouped into larger batches. + b.iter_batched( + || { + backend.update_ast(uri.as_str(), &source); + backend.clear_completion_cache(); + }, + |()| black_box(request()), + BatchSize::PerIteration, + ); + }); + + group.bench_function(BenchmarkId::new("cached_request", literal_count), |b| { + assert_morph_alias_completion(request(), range); + b.iter(|| black_box(request())); + }); + } + + group.finish(); +} + +criterion_group!( + benches, + bench_laravel_model_completion, + bench_morph_column_completion +); criterion_main!(benches); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d464b0b07..c421788d0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -238,6 +238,7 @@ The symbol map also stores: - **Scope boundaries** (`scopes`): function, method, closure, and arrow function body ranges. Used by `find_enclosing_scope` to determine which scope the cursor is in. - **Template parameter definitions** (`template_defs`): `@template` tag locations so that template parameter names (e.g. `TKey`, `TModel`) that appear in docblock types can be resolved to their declaration site. - **Candidate render sites** (`view_receiver_sites`): the view names a method call spells when only the receiver's *type* decides whether it renders — a constructor-injected `Factory $views` behind `$this->views->make('page')`, a mailable held in a local. Extraction runs before the file's classes are resolved and cannot type the receiver, so it records the candidates and `blade/typed_receiver.rs` confirms them lazily through the shared type engine, once per file. Consumers of view keys (the call-site diagnostics, call-site inference, `lookup_symbol_map`, find-references) read the confirmed spans alongside the map's own `LaravelStringKey` spans. The reference candidate index takes the *unconfirmed* candidates, since a file has to be findable before it can be asked. +- **Candidate morph-column aliases** (`morph_column_sites`): literals compared with properties or query columns are confirmed lazily by `virtual_members/laravel/typed_morph_columns.rs`. The shared type engine identifies the receiver model, and its parsed `morphTo()` metadata identifies the column. Array values in one query share a single model and column check. Confirmed literals become ordinary `MorphAlias` spans for completion, navigation, references, and diagnostics. Model metadata changes invalidate dependent confirmations, including when a relation's column changes without changing its PHP signature. ### Tier 2: Stored Byte Offsets (cross-file jumps) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 39c63dcb6..71cb2cec7 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 +- **Morph aliases resolve in column comparisons.** Comparing a model's polymorphic type column with a registered alias now offers completion, hover, go-to-definition, and find-references, and reports unknown aliases when the morph map is enforced. Query filters and property comparisons recognize the columns declared by the model's relationships, including custom names and inherited relations. Contributed by @shuvroroy. - **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit. - **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy. - **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run --format json` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. Contributed by @calebdw. diff --git a/docs/todo.md b/docs/todo.md index dfb549e3b..3a73fcad0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -169,7 +169,6 @@ unlikely to move the needle for most users. | L12 | [`HasUuids` / `HasUlids` trait — `$id` typed as `string`](todo/laravel.md#l12-hasuuids-hasulids-trait-id-typed-as-string) | Low-Medium | Medium | | L44 | [Sibling resource registrations and degenerate resource names](todo/laravel.md#l44-sibling-resource-registrations-and-degenerate-resource-names) | Low-Medium | Medium | | L50 | ["Create route" quick-fix for an unresolved route name](todo/laravel.md#l50-create-route-quick-fix-for-an-unresolved-route-name) | Low-Medium | Medium | -| L47 | [Morph aliases in `*_type` column comparisons](todo/laravel.md#l47-morph-aliases-in-_type-column-comparisons) | Low-Medium | Medium-High | | L8 | `withSum`/`withAvg`/`withMin`/`withMax` aggregate properties | Low-Medium | High | | L45 | [`*_count` properties are offered on every relationship](todo/laravel.md#l45-_count-properties-are-offered-on-every-relationship) | Low-Medium | High | | L29 | [Livewire and Volt component names](todo/laravel.md#l29-livewire-and-volt-component-names) (Livewire projects only) | Low | Low | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 1bb754d25..42f9b022b 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -28,7 +28,7 @@ within the same impact tier. | Facade → concrete resolution via booting | Requires booting (`getFacadeRoot()`). When `getFacadeAccessor()` returns a `::class` reference, static resolution is possible without booting. See "Facade completion" section below. | | Contract → concrete resolution | Fully out of scope, including core framework contracts. Calling a concrete-only method on a contract-typed value is unsound per the declared types — the diagnostic is intended, exactly as `fn (A $a) => $a->bMethod()` is not a false positive just because `B extends A` at every call site. Where the *framework's own* docblock is needlessly wide, fix the docblock upstream or via stub patches. | | Manager → driver resolution | Requires instantiating the manager at runtime. | -| Narrowing a `MorphTo` relation to concrete models | `$comment->commentable` resolves to the generic `Illuminate\Database\Eloquent\Model`, which is what the relation declares. The morph map (now indexed, see L47/L42) is global rather than per-relation, so the only type it could supply is a union of *every* mapped model — a sound upper bound that is far wider than the truth and would report a concrete method as "not found on any of the N possible types". Annotate the relation with `@return MorphTo` where the target set is actually known. | +| Narrowing a `MorphTo` relation to concrete models | `$comment->commentable` resolves to the generic `Illuminate\Database\Eloquent\Model`, which is what the relation declares. The morph map is global rather than per-relation, so the only type it could supply is a union of *every* mapped model — a sound upper bound that is far wider than the truth and would report a concrete method as "not found on any of the N possible types". Annotate the relation with `@return MorphTo` where the target set is actually known. | --- @@ -379,34 +379,6 @@ Alternatively, if the stubs for these traits include `@property` tags or a typed `$id` override, the PHPDoc provider may handle it automatically once the traits are loaded. -#### L47. Morph aliases in `*_type` column comparisons - -**Impact: Low-Medium · Complexity: Medium-High** - -The morph-map index (`virtual_members/laravel/morph_map.rs`) recognizes -alias strings in the positions Eloquent resolves through the map by -name: the `morphMap()` keys themselves, `Relation::getMorphedModel()`, -`Model::getActualClassNameForMorph()`, and the `$types` argument of the -`whereHasMorph()` family. It does **not** recognize an alias compared -against a morph type *column*, which is how much real code reads it: - -```php -$query->where('commentable_type', 'post'); -if ($comment->commentable_type === 'post') { … } -``` - -Both are alias literals, but recognizing them means knowing that the -column named on the other side is a polymorphic type column. The -information is available: a `morphTo()` relation declares its type -column (defaulting to `_type`), and the relation methods of -the model being queried are already parsed. The work is to collect the -morph type columns of a model, then match a string literal that appears -opposite one in a `where()` / comparison against the alias index. - -Once a literal is recognized it inherits hover, go-to-definition, -find-references, and the enforced-map diagnostic for free, since those -dispatch on the `LaravelStringKind::MorphAlias` span kind. - #### L42. Morph alias completion in array positions **Impact: Low-Medium · Complexity: Medium** diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 59ed52562..ca2971602 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -884,6 +884,17 @@ public function morphAliases(): void // The same alias resolves in Relation::getMorphedModel(), where // completion also offers the registered aliases. Relation::getMorphedModel('blog_post'); // → App\Models\BlogPost + + // The relation declares reviewable_type as its morph type column. + // Aliases in query values and property comparisons also complete, + // hover as BlogPost, and navigate to the model and registration. + Review::where('reviewable_type', 'blog_post')->get(); + Review::query()->where('reviewable_type', '=', 'blog_post')->get(); + $review = new Review(); + $review->reviewable_type = 'blog_post'; + if ($review->reviewable_type === 'blog_post') { + Relation::getMorphedModel('blog_post'); + } } diff --git a/examples/laravel/app/Models/Review.php b/examples/laravel/app/Models/Review.php index 0a474c9c5..17dbbd660 100644 --- a/examples/laravel/app/Models/Review.php +++ b/examples/laravel/app/Models/Review.php @@ -13,6 +13,9 @@ #[UsePolicy(ReviewModerationPolicy::class)] class Review extends Model { + /** @var array */ + protected $casts = ['reviewable_type' => 'string']; + public function getTitle(): string { return ''; } public function getRating(): int { return 0; } diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index 9e5178ff1..21433c866 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -1468,6 +1468,26 @@ public function toArray(): array \Illuminate\Container\Container::setInstance($previousContainer); +// ─── Morph aliases in column comparisons ──────────────────────────────────── + +$previousMorphMap = \Illuminate\Database\Eloquent\Relations\Relation::morphMap(); +\Illuminate\Database\Eloquent\Relations\Relation::morphMap([ + 'blog_post' => \App\Models\BlogPost::class, +]); +$review = new \App\Models\Review(); +$review->reviewable()->associate(new \App\Models\BlogPost()); +check('Review declares its morph type column', $review->reviewable()->getMorphType() === 'reviewable_type'); +check('An associated review stores the mapped alias', $review->reviewable_type === 'blog_post'); +check( + 'The column alias resolves to BlogPost', + \Illuminate\Database\Eloquent\Relations\Relation::getMorphedModel($review->reviewable_type) === \App\Models\BlogPost::class +); +check( + 'A morph column query binds its literal alias', + \App\Models\Review::where('reviewable_type', 'blog_post')->getBindings() === ['blog_post'] +); +\Illuminate\Database\Eloquent\Relations\Relation::morphMap($previousMorphMap, false); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; diff --git a/src/backend/file_access.rs b/src/backend/file_access.rs index dcfaaaf96..60dd48974 100644 --- a/src/backend/file_access.rs +++ b/src/backend/file_access.rs @@ -324,6 +324,7 @@ impl Backend { self.symbols.uri_classes_index.write().remove(uri); self.symbol_maps.write().remove(uri); self.evict_typed_receiver_view_spans(uri); + self.morph_column_spans_cache.write().clear(); self.evict_reference_index_uri(uri); self.file_imports.write().remove(uri); self.resolved_names.write().remove(uri); diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index 853fb6489..f29a5bff3 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -367,6 +367,13 @@ impl Backend { // `try_laravel_string_key_completion`, which may trigger // `ensure_workspace_indexed` → `update_ast` → write lock. let is_laravel = self.resolved_class_cache.read().is_laravel(); + if is_laravel + && matches!(string_ctx, StringContext::InStringLiteral) + && let Some(response) = + self.try_morph_column_completion(&uri, &content, position) + { + return Ok(Some(response)); + } if is_laravel && matches!( string_ctx, diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 3591878be..9932fe955 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -1035,6 +1035,50 @@ fn string_key_item_kind(kind: &LaravelStringKind) -> CompletionItemKind { } impl Backend { + /// Complete aliases in confirmed morph-column comparisons using the same + /// model and column evidence as hover, navigation, and diagnostics. + pub(crate) fn try_morph_column_completion( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option { + let offset = position_to_offset(content, position); + let map = self.symbol_map_for(uri)?; + if !map.matches_source(content) + || !map + .morph_column_sites + .iter() + .any(|site| site.start <= offset && offset <= site.end) + { + return None; + } + let spans = self.morph_column_spans_for(uri, &map); + let span = spans + .iter() + .find(|span| span.start <= offset && offset <= span.end)?; + let prefix = content.get(span.start as usize..offset as usize)?; + let range = Range::new( + crate::text_position::offset_to_position(content, span.start as usize), + crate::text_position::offset_to_position(content, span.end as usize), + ); + let items = self + .string_key_candidates(&LaravelStringKind::MorphAlias) + .into_iter() + .filter(|alias| alias.starts_with(prefix)) + .map(|alias| CompletionItem { + label: alias.clone(), + kind: Some(CompletionItemKind::ENUM_MEMBER), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range, + new_text: alias, + })), + ..Default::default() + }) + .collect(); + Some(CompletionResponse::Array(items)) + } + /// Every name a string key of `kind` could be, unfiltered. /// /// Three kinds have no list to offer. A Blade section or stack name is diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs index 2ade50c39..b8e92a769 100644 --- a/src/definition/resolve.rs +++ b/src/definition/resolve.rs @@ -94,6 +94,17 @@ impl Backend { if let Some(span) = map.lookup(offset) { return Some(span.clone()); } + if map + .morph_column_sites + .iter() + .any(|site| offset >= site.start && offset < site.end) + && let Some(span) = self + .morph_column_spans_for(uri, &map) + .iter() + .find(|span| offset >= span.start && offset < span.end) + { + return Some(span.clone()); + } // A view name behind a typed receiver is a gap in the map — the // indexer could not tell it was one — so the cursor lands in what // reads as a plain string literal until the receiver is typed. diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index a0ae42133..56ecd5584 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -692,10 +692,12 @@ impl Backend { return; }; let extra = self.typed_receiver_view_spans_for(uri, &symbol_map); + let morph_columns = self.morph_column_spans_for(uri, &symbol_map); symbol_map .spans .iter() .chain(extra.iter()) + .chain(morph_columns.iter()) .filter_map(|span| { if let SymbolKind::LaravelStringKey { kind, diff --git a/src/lib.rs b/src/lib.rs index d7857dc05..242ac7de9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -976,6 +976,11 @@ pub struct Backend { /// symbol map recorded a candidate site ever get an entry. pub(crate) typed_receiver_view_spans_cache: Arc>>, + /// Lazily confirmed morph-column aliases, invalidated when source or + /// the model metadata used to recognize their columns changes. + pub(crate) morph_column_spans_cache: Arc< + RwLock>, + >, /// Whether the workspace directory has been fully scanned for PHP and /// resource files. /// @@ -1219,6 +1224,7 @@ impl Backend { blade_uris: Arc::new(RwLock::new(std::collections::HashSet::new())), blade_injected_vars: Arc::new(RwLock::new(HashMap::new())), typed_receiver_view_spans_cache: Arc::new(RwLock::new(HashMap::new())), + morph_column_spans_cache: Arc::new(RwLock::new(HashMap::new())), workspace_indexed: Arc::new(std::sync::atomic::AtomicBool::new(false)), workspace_index_lock: Arc::new(Mutex::new(())), full_index_in_progress: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1336,6 +1342,7 @@ impl Backend { blade_uris: Arc::new(RwLock::new(std::collections::HashSet::new())), blade_injected_vars: Arc::new(RwLock::new(HashMap::new())), typed_receiver_view_spans_cache: Arc::new(RwLock::new(HashMap::new())), + morph_column_spans_cache: Arc::new(RwLock::new(HashMap::new())), workspace_indexed: Arc::new(std::sync::atomic::AtomicBool::new(false)), workspace_index_lock: Arc::new(Mutex::new(())), full_index_in_progress: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1993,6 +2000,7 @@ impl Backend { blade_uris: Arc::clone(&self.blade_uris), blade_injected_vars: Arc::clone(&self.blade_injected_vars), typed_receiver_view_spans_cache: Arc::clone(&self.typed_receiver_view_spans_cache), + morph_column_spans_cache: Arc::clone(&self.morph_column_spans_cache), workspace_indexed: Arc::clone(&self.workspace_indexed), workspace_index_lock: Arc::clone(&self.workspace_index_lock), full_index_in_progress: Arc::clone(&self.full_index_in_progress), diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 31df6b99a..818d78b6f 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -1382,9 +1382,11 @@ impl Backend { // workspace, so a signature change anywhere can turn a call that // was not a render into one, or the other way round. self.typed_receiver_view_spans_cache.write().clear(); + self.morph_column_spans_cache.write().clear(); } else { for update in &prepared { self.evict_typed_receiver_view_spans(&update.uri); + self.morph_column_spans_cache.write().remove(&update.uri); } } diff --git a/src/parser/classes.rs b/src/parser/classes.rs index ef25969db..d6930ef53 100644 --- a/src/parser/classes.rs +++ b/src/parser/classes.rs @@ -475,6 +475,11 @@ impl Backend { &trait_def.attribute_lists, doc_ctx, ); + let laravel = crate::virtual_members::laravel::extract_laravel_trait_metadata( + trait_def, + &methods, + doc_ctx.map_or("", |ctx| ctx.content), + ); classes.push(ClassInfo { kind: ClassLikeKind::Trait, name: trait_name, @@ -523,7 +528,7 @@ impl Backend { attribute_targets: 0, method_index: Default::default(), indexed_method_count: 0, - laravel: None, + laravel, fqn: None, }); diff --git a/src/reference_index.rs b/src/reference_index.rs index 38e21213b..4307dae50 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -544,6 +544,15 @@ impl Backend { false, )); } + for site in &symbol_map.morph_column_sites { + entries.push(( + ReferenceIndexKey::LaravelString { + kind: crate::symbol_map::LaravelStringKind::MorphAlias, + key: site.key.clone(), + }, + false, + )); + } if let Some(classes) = self.symbols.uri_classes_index.read().get(uri).cloned() { for class in classes { diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index f5ec0dbef..7d708b66c 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -268,6 +268,7 @@ fn extract_call<'a>( scope_start: u32, visit_receiver: bool, ) { + record_morph_column_call(call, ctx.content, &mut ctx.morph_column_sites); match call { Call::Function(func_call) => { match func_call.function { diff --git a/src/symbol_map/extraction/expressions/mod.rs b/src/symbol_map/extraction/expressions/mod.rs index 11cf7b5d6..62ef24058 100644 --- a/src/symbol_map/extraction/expressions/mod.rs +++ b/src/symbol_map/extraction/expressions/mod.rs @@ -206,6 +206,7 @@ pub(super) fn extract_from_expression<'a>( // ── Binary operations ── Expression::Binary(bin) => { + record_morph_column_comparison(bin, ctx.content, &mut ctx.morph_column_sites); extract_from_expression(bin.lhs, ctx, scope_start); // Tag the RHS of `instanceof` with the Instanceof context. if bin.operator.is_instanceof() { diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 7f7de5586..62434a66f 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -11,10 +11,11 @@ use super::docblock::{ class_ref_span, class_ref_span_ctx, extract_docblock_symbols, extract_docblock_symbols_covering, get_docblock_text_with_offset, is_navigable_type, }; +use super::morph_columns::{record_morph_column_call, record_morph_column_comparison}; use super::{ - CallSite, ClassRefContext, DocblockMemberRef, SelfStaticParentKind, SubjectText, SymbolKind, - SymbolMap, SymbolSpan, TemplateParamDef, UntypedClosureSite, VarDefKind, VarDefSite, - ViewReceiverClass, ViewReceiverSite, + CallSite, ClassRefContext, DocblockMemberRef, MorphColumnSite, SelfStaticParentKind, + SubjectText, SymbolKind, SymbolMap, SymbolSpan, TemplateParamDef, UntypedClosureSite, + VarDefKind, VarDefSite, ViewReceiverClass, ViewReceiverSite, }; use crate::atom::{bytes_to_str, literal_bytes_to_str}; use crate::util::strip_fqn_prefix; @@ -76,6 +77,8 @@ struct ExtractionCtx<'a> { /// Render sites whose receiver only a type settles, left for /// `Backend::typed_receiver_view_spans` to confirm. view_receiver_sites: Vec, + /// Possible morph aliases whose model and type column are resolved lazily. + morph_column_sites: Vec, /// The model argument of each authorization check that named one. gate_subjects: Vec, /// Current conditional nesting depth (if/else, switch, while, for, etc.). @@ -174,6 +177,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol content, untyped_closure_sites: Vec::new(), view_receiver_sites: Vec::new(), + morph_column_sites: Vec::new(), gate_subjects: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), @@ -272,6 +276,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol ctx.switch_scopes.sort_by_key(|s| s.0); ctx.static_method_scopes.sort_by_key(|s| s.0); ctx.view_receiver_sites.sort_by_key(|s| s.start); + ctx.morph_column_sites.sort_by_key(|s| s.start); let mut member_access_indices: crate::atom::AtomMap> = crate::atom::AtomMap::default(); @@ -302,6 +307,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol instance_method_scopes: ctx.instance_method_scopes, untyped_closure_sites: ctx.untyped_closure_sites, view_receiver_sites: ctx.view_receiver_sites, + morph_column_sites: ctx.morph_column_sites, gate_subjects: ctx.gate_subjects, source_len: u32::try_from(content.len()).unwrap_or(u32::MAX), } diff --git a/src/symbol_map/mod.rs b/src/symbol_map/mod.rs index 0b557dc0a..7cb509fb3 100644 --- a/src/symbol_map/mod.rs +++ b/src/symbol_map/mod.rs @@ -28,11 +28,13 @@ pub(crate) mod docblock; mod extraction; +mod morph_columns; use crate::atom::Atom; use crate::php_type::PhpType; pub(crate) use extraction::extract_symbol_map; +pub(crate) use morph_columns::{MorphColumnReceiver, MorphColumnSite}; // ─── Data structures ──────────────────────────────────────────────────────── @@ -875,6 +877,9 @@ pub(crate) struct SymbolMap { /// a mailable through something other than the spellings /// [`SymbolKind::LaravelStringKey`] is emitted for. pub view_receiver_sites: Vec, + /// Literal comparisons whose receivers and columns must be confirmed + /// as Eloquent morph type columns, sorted by `start`. + pub morph_column_sites: Vec, /// The model argument of each gate check that named one, keyed back to /// its ability span by [`GateSubject::ability_start`]. Empty for every /// file that performs no authorization checks. diff --git a/src/symbol_map/morph_columns.rs b/src/symbol_map/morph_columns.rs new file mode 100644 index 000000000..b695ef418 --- /dev/null +++ b/src/symbol_map/morph_columns.rs @@ -0,0 +1,311 @@ +//! Syntactic candidates for aliases compared with a model's morph type column. +//! +//! Extraction only retains source ranges and literal names. The shared type +//! engine later confirms the receiver and the column before exposing a symbol. + +use mago_span::HasSpan; +use mago_syntax::cst::*; + +use crate::atom::{Atom, atom, bytes_to_str}; + +/// The receiver whose model determines whether a column stores morph aliases. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MorphColumnReceiver { + /// An instance property read in a PHP comparison. + Model, + /// An instance query-method receiver. + Query, + /// The class expression of a static query-method call. + StaticQuery, +} + +/// An unresolved literal that may name an alias in a morph-column comparison. +#[derive(Debug, Clone)] +pub(crate) struct MorphColumnSite { + /// First byte of the alias, excluding its opening quote. + pub start: u32, + /// Byte following the alias, excluding its closing quote. + pub end: u32, + /// Literal alias, including an empty value while completing a string. + pub key: String, + /// Literal property or column name, preserving any table qualification. + pub column: Atom, + /// First byte of the receiver expression. + pub receiver_start: u32, + /// Byte following the receiver expression. + pub receiver_end: u32, + /// Whether the receiver is a model instance, query, or static class. + pub receiver_kind: MorphColumnReceiver, +} + +impl MorphColumnSite { + /// The alias span this site contributes once its model and column are confirmed. + pub(crate) fn to_span(&self) -> super::SymbolSpan { + super::SymbolSpan { + start: self.start, + end: self.end, + kind: super::SymbolKind::LaravelStringKey { + key: self.key.clone(), + kind: super::LaravelStringKind::MorphAlias, + is_write: false, + is_optional: false, + }, + } + } +} + +/// Record literal values of supported scalar and set-membership query clauses. +pub(crate) fn record_morph_column_call( + call: &Call<'_>, + content: &str, + out: &mut Vec, +) { + let (receiver, method, kind) = match call { + Call::Method(call) => (call.object, &call.method, MorphColumnReceiver::Query), + Call::NullSafeMethod(call) => (call.object, &call.method, MorphColumnReceiver::Query), + Call::StaticMethod(call) => (call.class, &call.method, MorphColumnReceiver::StaticQuery), + Call::Function(_) => return, + }; + let ClassLikeMemberSelector::Identifier(method) = method else { + return; + }; + let method = bytes_to_str(method.value); + let (parameters, is_array): (&[&str], bool) = if method.eq_ignore_ascii_case("where") + || method.eq_ignore_ascii_case("whereNot") + { + (&["column", "operator", "value", "boolean"], false) + } else if method.eq_ignore_ascii_case("orWhere") || method.eq_ignore_ascii_case("orWhereNot") { + (&["column", "operator", "value"], false) + } else if method.eq_ignore_ascii_case("whereIn") { + (&["column", "values", "boolean", "not"], true) + } else if method.eq_ignore_ascii_case("whereNotIn") { + (&["column", "values", "boolean"], true) + } else if method.eq_ignore_ascii_case("orWhereIn") + || method.eq_ignore_ascii_case("orWhereNotIn") + { + (&["column", "values"], true) + } else { + return; + }; + let arguments = call.get_argument_list(); + let Some(bound) = bind_arguments(arguments, parameters) else { + return; + }; + let Some(column) = bound[0].and_then(|column| literal_text(column, content)) else { + return; + }; + if column.is_empty() { + return; + } + let Some(second) = bound[1] else { + return; + }; + if is_array { + let elements = match unparenthesized(second) { + Expression::Array(array) => &array.elements, + Expression::LegacyArray(array) => &array.elements, + _ => return, + }; + for element in elements.iter() { + let value = match element { + ArrayElement::Value(value) => value.value, + ArrayElement::KeyValue(value) => value.value, + _ => continue, + }; + push_site(value, column, receiver, kind, content, out); + } + } else if let Some(value) = bound[2] { + if matches!(literal_text(second, content), Some("=" | "!=" | "<>")) { + push_site(value, column, receiver, kind, content, out); + } + } else if arguments.arguments.len() == 2 { + // The negated wrappers forward four arguments to `where()`, so a + // recognized operator with no value compares NULL or throws instead + // of becoming the value as it does in two-argument `where()`. + if (method.eq_ignore_ascii_case("whereNot") || method.eq_ignore_ascii_case("orWhereNot")) + && literal_text(second, content).is_some_and(is_laravel_query_operator) + { + return; + } + push_site(second, column, receiver, kind, content, out); + } +} + +/// Operators recognized by Laravel's query builder independently of the +/// database grammar. Matching is case-insensitive, like `invalidOperator()`. +fn is_laravel_query_operator(value: &str) -> bool { + [ + "=", + "<", + ">", + "<=", + ">=", + "<>", + "!=", + "<=>", + "like", + "like binary", + "not like", + "ilike", + "&", + "|", + "^", + "<<", + ">>", + "&~", + "is", + "is not", + "rlike", + "not rlike", + "regexp", + "not regexp", + "~", + "~*", + "!~", + "!~*", + "similar to", + "not similar to", + "not ilike", + "~~*", + "!~~*", + ] + .iter() + .any(|operator| value.eq_ignore_ascii_case(operator)) +} + +/// Record an alias on either side of a direct or nullsafe property comparison. +pub(crate) fn record_morph_column_comparison( + binary: &Binary<'_>, + content: &str, + out: &mut Vec, +) { + if !matches!( + binary.operator, + BinaryOperator::Equal(_) + | BinaryOperator::NotEqual(_) + | BinaryOperator::Identical(_) + | BinaryOperator::NotIdentical(_) + | BinaryOperator::AngledNotEqual(_) + ) { + return; + } + let (property, value) = match (unparenthesized(binary.lhs), unparenthesized(binary.rhs)) { + (Expression::Access(property), value) | (value, Expression::Access(property)) => { + (property, value) + } + _ => return, + }; + let (receiver, property) = match property { + Access::Property(property) => (property.object, &property.property), + Access::NullSafeProperty(property) => (property.object, &property.property), + _ => return, + }; + let ClassLikeMemberSelector::Identifier(property) = property else { + return; + }; + push_site( + value, + bytes_to_str(property.value), + receiver, + MorphColumnReceiver::Model, + content, + out, + ); +} + +/// Bind the small, fixed query signatures without allocating argument maps. +fn bind_arguments<'ast, 'arena>( + arguments: &'ast ArgumentList<'arena>, + parameters: &[&str], +) -> Option<[Option<&'ast Expression<'arena>>; 4]> { + if arguments.arguments.len() > parameters.len() { + return None; + } + let mut bound = [None; 4]; + let mut has_named = false; + for (position, argument) in arguments.arguments.iter().enumerate() { + let index = match argument { + Argument::Positional(argument) => { + if has_named || argument.ellipsis.is_some() { + return None; + } + position + } + Argument::Named(argument) => { + has_named = true; + let name = bytes_to_str(argument.name.value); + parameters.iter().position(|parameter| *parameter == name)? + } + }; + if bound[index].replace(argument.value()).is_some() { + return None; + } + } + Some(bound) +} + +fn unparenthesized<'ast, 'arena>( + mut expression: &'ast Expression<'arena>, +) -> &'ast Expression<'arena> { + while let Expression::Parenthesized(parenthesized) = expression { + expression = parenthesized.expression; + } + expression +} + +fn literal_text<'content>( + expression: &Expression<'_>, + content: &'content str, +) -> Option<&'content str> { + let (start, end) = literal_range(expression)?; + content.get(start as usize..end as usize) +} + +fn literal_range(expression: &Expression<'_>) -> Option<(u32, u32)> { + let Expression::Literal(literal::Literal::String(string)) = unparenthesized(expression) else { + return None; + }; + let quote_offset = if matches!(string.raw.first(), Some(b'b' | b'B')) { + 2 + } else { + 1 + }; + Some(( + string.span.start.offset + quote_offset, + string.span.end.offset - 1, + )) +} + +fn push_site( + value: &Expression<'_>, + column: &str, + receiver: &Expression<'_>, + receiver_kind: MorphColumnReceiver, + content: &str, + out: &mut Vec, +) { + let Some((start, end)) = literal_range(value) else { + return; + }; + let Some(key) = content.get(start as usize..end as usize) else { + return; + }; + // Class-name strings retain the existing morph APIs' semantics and do + // not name aliases. Escaped strings are also opaque. + if key.contains('\\') { + return; + } + out.push(MorphColumnSite { + start, + end, + key: key.to_owned(), + column: atom(column), + receiver_start: receiver.span().start.offset, + receiver_end: receiver.span().end.offset, + receiver_kind, + }); +} + +#[cfg(test)] +#[path = "morph_columns_tests.rs"] +mod tests; diff --git a/src/symbol_map/morph_columns_tests.rs b/src/symbol_map/morph_columns_tests.rs new file mode 100644 index 000000000..542865b6c --- /dev/null +++ b/src/symbol_map/morph_columns_tests.rs @@ -0,0 +1,479 @@ +use super::*; + +fn candidates(content: &str) -> Vec { + fn collect(node: Node<'_, '_>, content: &str, out: &mut Vec) { + match node { + Node::Call(call) => record_morph_column_call(call, content, out), + Node::Binary(binary) => record_morph_column_comparison(binary, content, out), + _ => {} + } + node.visit_children(|child| collect(child, content, out)); + } + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"morph_columns.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + let mut out = Vec::new(); + collect(Node::Program(program), content, &mut out); + out +} + +#[test] +fn symbol_map_retains_sorted_candidates_once_per_chain_link() { + let content = "where('first_type', 'post')->where('second_type', 'video'); if ('image' === $comment->third_type) {}"; + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"morph_columns.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + let map = super::super::extract_symbol_map(program, content); + + assert_eq!( + map.morph_column_sites + .iter() + .map(|site| site.key.as_str()) + .collect::>(), + ["post", "video", "image"] + ); + assert!( + map.morph_column_sites + .windows(2) + .all(|pair| pair[0].start < pair[1].start) + ); +} + +#[test] +fn query_candidates_keep_exact_receiver_column_and_literal_ranges() { + for (call, receiver, kind) in [ + ("$query->where", "$query", MorphColumnReceiver::Query), + ("$query?->orWhere", "$query", MorphColumnReceiver::Query), + ( + "Comment::whereNot", + "Comment", + MorphColumnReceiver::StaticQuery, + ), + ( + "static::orWhereNot", + "static", + MorphColumnReceiver::StaticQuery, + ), + ("$query->WhErE", "$query", MorphColumnReceiver::Query), + ( + "$query->active()->where", + "$query->active()", + MorphColumnReceiver::Query, + ), + ("($query)->where", "($query)", MorphColumnReceiver::Query), + ( + "Comment::query()->where", + "Comment::query()", + MorphColumnReceiver::Query, + ), + ("self::where", "self", MorphColumnReceiver::StaticQuery), + ("parent::where", "parent", MorphColumnReceiver::StaticQuery), + ( + "$modelClass::where", + "$modelClass", + MorphColumnReceiver::StaticQuery, + ), + ] { + let content = format!("', 'post'", + "column: 'subject_type', operator: 'post'", + "operator: 'post', column: 'subject_type'", + "value: 'post', column: 'subject_type', operator: '='", + "'subject_type', value: 'post', operator: '!='", + "operator: '=', boolean: 'or', value: 'post', column: 'subject_type'", + "('subject_type'), ('='), ('post')", + ] { + let content = format!("where({arguments});"); + let sites = candidates(&content); + assert_eq!(sites.len(), 1, "{content}"); + assert_eq!(sites[0].key, "post"); + } +} + +#[test] +fn negated_query_wrappers_do_not_treat_operators_as_two_argument_aliases() { + for operator in [ + "=", + "!=", + "<>", + "<=>", + ">", + "<=", + "like", + "LIKE", + "not like", + "is", + "is not", + "&", + "|", + "^", + "<<", + ">>", + "&~", + "~", + "~*", + "!~", + "!~*", + "similar to", + "not similar to", + "not ilike", + "~~*", + "!~~*", + "like binary", + "rlike", + "not rlike", + "regexp", + "not regexp", + "ilike", + "<", + ">=", + ] { + for method in ["whereNot", "orWhereNot", "WhErEnOt"] { + for arguments in [ + format!("'subject_type', '{operator}'"), + format!("operator: '{operator}', column: 'subject_type'"), + ] { + let content = format!("{method}({arguments});"); + assert!(candidates(&content).is_empty(), "{content}"); + } + } + } +} + +#[test] +fn ordinary_queries_treat_two_argument_operator_strings_as_alias_values() { + for method in ["where", "orWhere"] { + for alias in ["=", "!=", "LIKE", "is not", "&", "not similar to"] { + let content = format!("{method}('subject_type', '{alias}');"); + let sites = candidates(&content); + assert_eq!(sites.len(), 1, "{content}"); + assert_eq!(sites[0].key, alias); + } + } +} + +#[test] +fn negated_queries_keep_operator_words_when_an_explicit_operator_precedes_them() { + for method in ["whereNot", "orWhereNot"] { + for (operator, alias) in [("=", "LIKE"), ("!=", "is not"), ("<>", "=")] { + let content = + format!("{method}('subject_type', '{operator}', '{alias}');"); + let sites = candidates(&content); + assert_eq!(sites.len(), 1, "{content}"); + assert_eq!(sites[0].key, alias); + } + } +} + +#[test] +fn query_method_variants_preserve_static_and_nullsafe_receivers() { + for (receiver, call_operator, kind) in [ + ("$query", "->", MorphColumnReceiver::Query), + ("$query", "?->", MorphColumnReceiver::Query), + ("Comment", "::", MorphColumnReceiver::StaticQuery), + ] { + for method in ["where", "whereNot", "orWhere", "orWhereNot"] { + for arguments in [ + "column: 'subject_type', operator: 'post'", + "value: 'post', operator: '=', column: 'subject_type'", + ] { + let content = format!(" 'video', ...$types, $dynamic, 7, ['nested']]", + ), + ( + "whereIn", + "'subject_type', ['post', 'App\\Models\\Post', 'video']", + ), + ( + "whereIn", + "'subject_type', [42 => 'post', $key => 'video', 'dynamic' => $alias, 'nested' => ['hidden']]", + ), + ( + "orWhereNotIn", + "'subject_type', array('first' => 'post', 'second' => 'video')", + ), + ] { + let content = format!("{method}({arguments});"); + let sites = candidates(&content); + assert_eq!( + sites + .iter() + .map(|site| site.key.as_str()) + .collect::>(), + ["post", "video"], + "{content}" + ); + } +} + +#[test] +fn property_comparisons_accept_equality_and_either_operand_order() { + for operator in ["==", "!=", "===", "!==", "<>"] { + for comparison in [ + format!("$comment->subject_type {operator} 'post'"), + format!("'post' {operator} $comment?->subject_type"), + format!("(($comment->subject_type)) {operator} (('post'))"), + ] { + let content = format!("fresh()", + "$comments[0]", + "$this->comment", + ] { + let content = format!("subject_type;"); + let sites = candidates(&content); + assert_eq!(sites.len(), 1, "{content}"); + assert_eq!( + &content[sites[0].receiver_start as usize..sites[0].receiver_end as usize], + receiver + ); + } +} + +#[test] +fn asterisk_is_a_literal_alias_in_column_comparisons() { + for expression in [ + "$query->where('subject_type', '*')", + "$query->where('subject_type', '!=', '*')", + "$query->whereIn('subject_type', ['*'])", + "$query->whereNotIn('subject_type', ['alias' => '*'])", + "$comment->subject_type === '*'", + "'*' !== $comment?->subject_type", + ] { + let content = format!("where('subject_type', ''); $comment->subject_type === ''; $query->whereIn('subject_type', [b'', B\"\"]);"; + let sites = candidates(content); + assert_eq!(sites.len(), 4); + for site in sites { + assert_eq!(site.key, ""); + assert_eq!(site.start, site.end); + } +} + +#[test] +fn binary_string_prefixes_do_not_enter_aliases_or_replacement_ranges() { + for expression in [ + "$query->where(b'subject_type', b'post')", + "$query->where(B\"subject_type\", B\"post\")", + "$comment->subject_type === (b'post')", + "$query->where((B'subject_type'), (b'='), (B'post'))", + "$query->whereIn(B'subject_type', [B'post'])", + ] { + let content = format!("where('subject_type', B\"póst\");"; + let sites = candidates(content); + assert_eq!(sites.len(), 1); + let span = sites[0].to_span(); + assert_eq!(&content[span.start as usize..span.end as usize], "póst"); + let super::super::SymbolKind::LaravelStringKey { + key, + kind, + is_write, + is_optional, + } = span.kind + else { + panic!("expected a Laravel alias reference"); + }; + assert_eq!(key, "póst"); + assert_eq!(kind, super::super::LaravelStringKind::MorphAlias); + assert!(!is_write); + assert!(!is_optional); +} + +#[test] +fn unsupported_or_dynamic_query_forms_are_not_candidates() { + for expression in [ + "where('subject_type', 'post')", + "$query->having('subject_type', 'post')", + "$query->whereColumn('subject_type', 'post')", + "$query->whereRaw('subject_type = post')", + "$query->$method('subject_type', 'post')", + "$query->{'where'}('subject_type', 'post')", + "$query?->$method('subject_type', 'post')", + "Comment::$method('subject_type', 'post')", + "$query->where()", + "$query->where('subject_type')", + "$query->where('', 'post')", + "$query->where($column, 'post')", + "$query->where('subject_' . 'type', 'post')", + "$query->where('subject_type', $alias)", + "$query->whereNot('subject_type', $alias)", + "$query->orWhereNot('subject_type', null)", + "$query->where('subject_type', \"$alias\")", + "$query->where('subject_type', 'po' . 'st')", + "$query->where('subject_type', 'App\\Models\\Post')", + "$query->where('subject_type', 'like', 'post')", + "$query->where('subject_type', '>', 'post')", + "$query->where('subject_type', '==', 'post')", + "$query->where('subject_type', '===', 'post')", + "$query->where('subject_type', '!==', 'post')", + "$query->where('subject_type', '<', 'post')", + "$query->where('subject_type', '>=', 'post')", + "$query->where('subject_type', '<=', 'post')", + "$query->where('subject_type', 'not like', 'post')", + "$query->where('subject_type', 'ilike', 'post')", + "$query->where('subject_type', $operator, 'post')", + "$query->where('subject_type', operator: '=', boolean: 'or')", + "$query->where(column: 'subject_type', value: 'post')", + "$query->where(Column: 'subject_type', operator: 'post')", + "$query->where('subject_type', column: 'post')", + "$query->where(column: 'subject_type', column: 'post')", + "$query->where('subject_type', operator: '=', operator: 'post')", + "$query->where(operator: 'post')", + "$query->where('subject_type', alias: 'post')", + "$query->where('subject_type', ...$arguments)", + "$query->where(column: 'subject_type', 'post')", + "$query->where(...$arguments)", + "$query->where('subject_type', '=', 'post', 'and', false)", + "$query->orWhere('subject_type', '=', 'post', 'or')", + "$query->whereIn('subject_type', 'post')", + "$query->whereIn('subject_type', $types)", + "$query->whereIn('subject_type', [])", + "$query->whereIn('subject_type', array())", + "$query->whereIn('subject_type', values: ['post'], values: ['video'])", + "$query->whereIn('subject_type', value: ['post'])", + "$query->orWhereIn('subject_type', ['post'], boolean: 'or')", + "$query->whereNotIn('subject_type', ['post'], not: false)", + ] { + let content = format!("subject_type > 'post'", + "$comment->subject_type < 'post'", + "$comment->subject_type >= 'post'", + "$comment->subject_type <= 'post'", + "$comment->subject_type <=> 'post'", + "$comment->subject_type . 'post'", + "$comment->subject_type === $alias", + "$comment->subject_type === 'po' . 'st'", + "$comment->subject_type === \"$alias\"", + "$comment->subject_type === 'App\\Models\\Post'", + "$comment->$column === 'post'", + "$comment->{'subject_type'} === 'post'", + "$comment['subject_type'] === 'post'", + "Comment::$subject_type === 'post'", + "Comment::SUBJECT_TYPE === 'post'", + "'post' === 'video'", + "$comment->subject_type === $other->subject_type", + ] { + let content = format!(" Some(statement), + _ => None, + }) + .expect("expected string expression"); + assert!(literal_text(statement.expression, "").is_none()); + let mut sites = Vec::new(); + push_site( + statement.expression, + "subject_type", + statement.expression, + MorphColumnReceiver::Model, + "", + &mut sites, + ); + assert!(sites.is_empty()); +} diff --git a/src/type_engine/subject_resolution.rs b/src/type_engine/subject_resolution.rs index e1303ceae..c837cf65d 100644 --- a/src/type_engine/subject_resolution.rs +++ b/src/type_engine/subject_resolution.rs @@ -65,8 +65,7 @@ pub(crate) fn resolve_subject_type( "parent" => { let cls = find_class_at_offset(ctx.local_classes, access_offset)?; let parent = cls.parent_class.as_ref()?; - let fqn = resolve_to_fqn(parent, ctx.use_map, ctx.namespace); - Some(PhpType::named(atom(&fqn))) + Some(PhpType::named(*parent)) } _ if is_static && !trimmed.starts_with('$') => { let fqn = resolve_to_fqn(trimmed, ctx.use_map, ctx.namespace); diff --git a/src/type_engine/variable/rhs_resolution/instantiation.rs b/src/type_engine/variable/rhs_resolution/instantiation.rs index 57732d3aa..79a275c10 100644 --- a/src/type_engine/variable/rhs_resolution/instantiation.rs +++ b/src/type_engine/variable/rhs_resolution/instantiation.rs @@ -384,24 +384,25 @@ pub(crate) fn extract_generic_arg_from_ancestor( // If the arg type itself is already generic with the wrapper name, // extract directly. E.g. argument type is `Container`. - if let TypeKind::Generic(g) = arg_type.kind() { - let n_short = crate::util::short_name(&g.name); - let wrapper_short = crate::util::short_name(wrapper_name); - if n_short.eq_ignore_ascii_case(wrapper_short) { - return g.args.get(tpl_position).cloned(); - } + if let TypeKind::Generic(g) = arg_type.kind() + && generic_wrapper_matches(&g.name, wrapper_name) + { + return g.args.get(tpl_position).cloned(); } let class_loader = rctx.class_loader; let cls = class_loader(class_name)?; + let subs = match arg_type.kind() { + TypeKind::Generic(generic) => crate::inheritance::build_generic_subs(&cls, &generic.args), + _ => HashMap::new(), + }; - let wrapper_short = crate::util::short_name(wrapper_name); let mut visited = Vec::new(); ancestor_generic_arg( &cls, - wrapper_short, + wrapper_name, tpl_position, - &HashMap::new(), + &subs, &mut visited, class_loader, ) @@ -483,13 +484,22 @@ pub(super) fn find_extends_generic_arg( .iter() .chain(cls.implements_generics.iter()) { - if crate::util::short_name(name) == target_short { + if generic_wrapper_matches(name, target_short) { return args.get(position).cloned(); } } None } +fn generic_wrapper_matches(name: &str, target: &str) -> bool { + if target.contains('\\') { + name.trim_start_matches('\\') + .eq_ignore_ascii_case(target.trim_start_matches('\\')) + } else { + crate::util::short_name(name).eq_ignore_ascii_case(target) + } +} + /// Remap constructor template substitutions from ancestor param names to child /// param names when a constructor is inherited. /// @@ -1237,6 +1247,17 @@ pub(super) fn extract_generic_arg_at_position(ty: &PhpType, position: usize) -> mod tests { use super::*; + #[test] + fn morph_builder_wrappers_match_qualified_identity_and_explicit_short_names() { + assert!(generic_wrapper_matches("App\\Builder", "Builder")); + assert!(generic_wrapper_matches("App\\Builder", "\\app\\builder")); + assert!(!generic_wrapper_matches( + "App\\Builder", + "Illuminate\\Database\\Eloquent\\Builder" + )); + assert!(!generic_wrapper_matches("App\\Other", "Builder")); + } + #[test] fn classify_direct_param() { let ty = PhpType::parse("T"); diff --git a/src/types/mod.rs b/src/types/mod.rs index 1801128fd..05a63385e 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1680,6 +1680,11 @@ pub struct LaravelMetadata { /// resolved to an FQN in the name-resolution pass. Used to surface the /// custom pivot class and extra pivot columns in hover. pub belongs_to_many_pivots: Vec, + /// Relation method names and discriminator columns recovered from + /// returned `$this->morphTo()` calls. A missing column derives its name + /// from the effective method name, including trait aliases. Kept on the + /// declaring class or trait so lookup respects method overrides. + pub morph_type_columns: Vec<(Atom, Option)>, /// What this class's `getFacadeAccessor()` returns, when it declares /// one. /// diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 48a7eeef3..3cb2f0a19 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -121,6 +121,7 @@ mod helpers; mod higher_order_proxy; mod macros; mod model_extraction; +mod morph_columns; pub(crate) mod morph_map; pub(crate) mod patches; mod path_helpers; @@ -134,6 +135,7 @@ mod scopes; mod storage; mod string_keys; mod trans_keys; +pub(crate) mod typed_morph_columns; pub(crate) mod validated_shape; pub(crate) mod validation_rules; mod view_data; @@ -165,8 +167,10 @@ pub(crate) use macros::{ synthesize_mixin_macros, }; pub(crate) use model_extraction::{ - extract_laravel_metadata, has_scope_attribute, infer_relationship_from_method, + extract_laravel_metadata, extract_laravel_trait_metadata, has_scope_attribute, + infer_relationship_from_method, }; +pub(crate) use morph_columns::is_morph_type_column; pub(crate) use morph_map::{LaravelMorphMapIndex, MorphMapEntry, MorphMapScan, scan_morph_map}; pub(crate) use patches::STORAGE_FACADE_FQN; pub(crate) use path_helpers::{ diff --git a/src/virtual_members/laravel/model_extraction.rs b/src/virtual_members/laravel/model_extraction.rs index 7c896f686..b97579460 100644 --- a/src/virtual_members/laravel/model_extraction.rs +++ b/src/virtual_members/laravel/model_extraction.rs @@ -988,6 +988,164 @@ fn extract_pivot_relations<'a>( relations } +/// Extract trait metadata needed to resolve a model's morph columns and table. +pub(crate) fn extract_laravel_trait_metadata<'a>( + trait_def: &class_like::Trait<'a>, + methods: &[MethodInfo], + content: &str, +) -> Option> { + let morph_type_columns = extract_morph_type_columns(trait_def.members.iter(), content); + let table_name = extract_string_property(trait_def.members.iter(), content, "table"); + let has_get_table_method = methods.iter().any(|method| { + !method.is_abstract && !method.is_virtual && method.name.eq_ignore_ascii_case("getTable") + }); + (!morph_type_columns.is_empty() || table_name.is_some() || has_get_table_method).then(|| { + Box::new(LaravelMetadata { + morph_type_columns, + table_name, + has_get_table_method, + ..Default::default() + }) + }) +} + +/// Extract the type columns declared by returned `$this->morphTo()` calls. +/// +/// Only the returned receiver chain counts: a call inside an argument, +/// closure, or unrelated statement does not declare a relationship. +pub(crate) fn extract_morph_type_columns<'a>( + members: impl Iterator>, + content: &str, +) -> Vec<(Atom, Option)> { + let mut columns = Vec::new(); + let mut returns = Vec::new(); + for member in members { + let class_like::member::ClassLikeMember::Method(method) = member else { + continue; + }; + let class_like::method::MethodBody::Concrete(block) = &method.body else { + continue; + }; + let span = block.span(); + let Some(body) = content.get(span.start.offset as usize..span.end.offset as usize) else { + continue; + }; + if !memchr::memchr2_iter(b'm', b'M', body.as_bytes()).any(|offset| { + body.as_bytes() + .get(offset..offset + 7) + .is_some_and(|name| name.eq_ignore_ascii_case(b"morphTo")) + }) { + continue; + } + + returns.clear(); + crate::return_collection::collect_returns(block.statements.iter(), &mut returns); + let name = bytes_to_str(method.name.value); + let mut column = None; + let mut consistent = !returns.is_empty(); + for (value, _, _, _) in &returns { + let current = value.and_then(morph_to_column); + if current.is_none() || column.is_some_and(|column| Some(column) != current) { + consistent = false; + break; + } + column = current; + } + if consistent && let Some(column) = column { + columns.push((atom(name), column)); + } + } + columns +} + +fn morph_to_column(mut value: &Expression<'_>) -> Option> { + loop { + match value { + Expression::Parenthesized(parenthesized) => value = parenthesized.expression, + Expression::Call(Call::Method(call)) => { + if let ClassLikeMemberSelector::Identifier(name) = &call.method + && name.value.eq_ignore_ascii_case(b"morphTo") + && matches!( + call.object, + Expression::Variable(Variable::Direct(variable)) + if variable.name == b"$this" + ) + { + return morph_to_arguments_column(&call.argument_list); + } + value = call.object; + } + _ => return None, + } + } +} + +fn morph_to_arguments_column(arguments: &ArgumentList<'_>) -> Option> { + let mut name = None; + let mut column = None; + for (position, argument) in arguments.arguments.iter().enumerate() { + if argument.is_unpacked() { + return None; + } + match argument { + Argument::Named(argument) => match argument.name.value { + b"name" => name = Some(argument.value), + b"type" => column = Some(argument.value), + _ => {} + }, + Argument::Positional(argument) => match position { + 0 => name = Some(argument.value), + 1 => column = Some(argument.value), + _ => {} + }, + } + } + + if let Some(column) = morph_argument_string(column)? { + return Some(Some(atom(column))); + } + let Some(name) = morph_argument_string(name)? else { + return Some(None); + }; + let mut column = String::with_capacity(name.len() + 5); + let mut word_start = true; + for character in name.chars() { + if character.is_whitespace() { + word_start = word_start || character.is_ascii_whitespace(); + continue; + } + let character = if word_start { + character.to_ascii_uppercase() + } else { + character + }; + // Laravel's Str::snake separates every ASCII capital, including + // acronyms (URLTarget becomes u_r_l_target). + if character.is_ascii_uppercase() && !column.is_empty() { + column.push('_'); + } + column.extend(character.to_lowercase()); + word_start = false; + } + column.push_str("_type"); + Some(Some(atom(&column))) +} + +// The outer None means dynamic; the inner None requests Laravel's default. +fn morph_argument_string<'a>(value: Option<&Expression<'a>>) -> Option> { + match value { + None | Some(Expression::Literal(Literal::Null(_))) => Some(None), + Some(Expression::Parenthesized(parenthesized)) => { + morph_argument_string(Some(parenthesized.expression)) + } + Some(Expression::Literal(Literal::String(string))) => { + let value = literal_bytes_to_str(string.value?)?; + Some((!value.is_empty() && value != "0").then_some(value)) + } + _ => None, + } +} + /// Build the [`LaravelMetadata`] for a class from its AST node. /// /// `methods` must already be extracted (via @@ -1016,6 +1174,7 @@ pub(crate) fn extract_laravel_metadata<'a>( let casts_definitions = extract_casts_definitions(class.members.iter(), content); let belongs_to_many_pivots = extract_pivot_relations(class.members.iter(), content); + let morph_type_columns = extract_morph_type_columns(class.members.iter(), content); let attributes_definitions = extract_attributes_definitions(class.members.iter(), content); let attribute_defaults = extract_attribute_defaults(class.members.iter(), content); @@ -1077,6 +1236,7 @@ pub(crate) fn extract_laravel_metadata<'a>( custom_builder, policy_class, belongs_to_many_pivots, + morph_type_columns, facade_accessor, } } diff --git a/src/virtual_members/laravel/morph_columns.rs b/src/virtual_members/laravel/morph_columns.rs new file mode 100644 index 000000000..12d7d81e3 --- /dev/null +++ b/src/virtual_members/laravel/morph_columns.rs @@ -0,0 +1,161 @@ +//! Lookup of morph discriminator columns through model and trait inheritance. + +use std::sync::Arc; + +use crate::atom::Atom; +use crate::class_lookup::load_ancestor; +use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH}; + +#[derive(Clone, Copy)] +enum MorphColumn { + Fixed(Atom), + MethodName(Atom), +} + +impl MorphColumn { + fn matches(self, column: &str) -> bool { + match self { + Self::Fixed(name) => name.as_str() == column, + Self::MethodName(method) => column.strip_suffix("_type").is_some_and(|column| { + method + .chars() + .enumerate() + .flat_map(|(position, character)| { + (position > 0 && character.is_ascii_uppercase()) + .then_some('_') + .into_iter() + .chain(character.to_lowercase()) + }) + .eq(column.chars()) + }), + } + } +} + +/// Whether a model declares a `morphTo()` relationship using `column`. +/// +/// The loader must return unmerged classes so a non-morph override can +/// shadow a relationship inherited from a parent or trait. Callers verify +/// that the receiver is an Eloquent model before using this metadata. +pub(crate) fn is_morph_type_column( + class: &ClassInfo, + column: &str, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + let raw = class_loader(&class.fqn()); + let class = raw.as_deref().unwrap_or(class); + has_column(class, class, column, class_loader, 0) +} + +fn has_column( + class: &ClassInfo, + receiver: &ClassInfo, + column: &str, + class_loader: &dyn Fn(&str) -> Option>, + depth: u32, +) -> bool { + if depth > MAX_INHERITANCE_DEPTH { + return false; + } + if let Some(metadata) = class.laravel() + && metadata + .morph_type_columns + .iter() + .any(|(method, declared)| { + declared + .map_or(MorphColumn::MethodName(*method), MorphColumn::Fixed) + .matches(column) + && method_column(receiver, method, class_loader, 0) + .flatten() + .is_some_and(|effective| effective.matches(column)) + }) + { + return true; + } + // An excluded trait method may remain available through an alias. + if class.trait_aliases.iter().any(|alias| { + alias.alias.is_some_and(|name| { + method_column(receiver, &name, class_loader, 0) + .flatten() + .is_some_and(|effective| effective.matches(column)) + }) + }) { + return true; + } + for name in &class.used_traits { + if let Some(trait_info) = class_loader(name) + && has_column(&trait_info, receiver, column, class_loader, depth + 1) + { + return true; + } + } + class.parent_class.as_ref().is_some_and(|name| { + load_ancestor(&class.fqn(), name, class_loader) + .is_some_and(|parent| has_column(&parent, receiver, column, class_loader, depth + 1)) + }) +} + +// Some(None) is a declared method without known morph metadata and must +// stop lookup; None means this branch does not supply the method. +fn method_column( + class: &ClassInfo, + method: &str, + class_loader: &dyn Fn(&str) -> Option>, + depth: u32, +) -> Option> { + if depth > MAX_INHERITANCE_DEPTH { + return None; + } + if class + .get_method_ci(method) + .is_some_and(|method| !method.is_virtual && !method.is_abstract) + { + return Some(class.laravel().and_then(|metadata| { + metadata + .morph_type_columns + .iter() + .find_map(|(name, column)| { + name.eq_ignore_ascii_case(method) + .then(|| column.map_or(MorphColumn::MethodName(*name), MorphColumn::Fixed)) + }) + })); + } + for alias in &class.trait_aliases { + let Some(alias_name) = alias.alias.filter(|name| name.eq_ignore_ascii_case(method)) else { + continue; + }; + for name in &class.used_traits { + if alias.trait_name.is_some_and(|source| source != *name) { + continue; + } + if let Some(trait_info) = class_loader(name) + && let Some(column) = + method_column(&trait_info, &alias.method_name, class_loader, depth + 1) + { + return Some(column.map(|column| match column { + MorphColumn::Fixed(_) => column, + MorphColumn::MethodName(_) => MorphColumn::MethodName(alias_name), + })); + } + } + } + for name in &class.used_traits { + if class.trait_precedences.iter().any(|precedence| { + precedence.method_name.eq_ignore_ascii_case(method) + && precedence.insteadof.contains(name) + }) { + continue; + } + if let Some(trait_info) = class_loader(name) + && let Some(column) = method_column(&trait_info, method, class_loader, depth + 1) + { + return Some(column); + } + } + let parent = load_ancestor(&class.fqn(), class.parent_class.as_ref()?, class_loader)?; + method_column(&parent, method, class_loader, depth + 1) +} + +#[cfg(test)] +#[path = "morph_columns_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/morph_columns_tests.rs b/src/virtual_members/laravel/morph_columns_tests.rs new file mode 100644 index 000000000..bcfb7738c --- /dev/null +++ b/src/virtual_members/laravel/morph_columns_tests.rs @@ -0,0 +1,350 @@ +use std::collections::HashMap; + +use crate::Backend; +use crate::atom::atom; + +use super::*; + +fn classes(source: &str) -> HashMap> { + Backend::parse_php_versioned_with_namespaces(source, None) + .into_iter() + .map(|(class, _)| (class.name, Arc::new(class))) + .collect() +} + +#[test] +fn morph_type_columns_follow_laravel_names_and_explicit_arguments() { + let classes = classes( + r#"morphTo(); } + public function relatedItem() { return $this->morphTo()->withDefault(); } + public function URLTarget() { return $this->morphTo(); } + public function explicitName() { return $this->morphTo('imageOwner'); } + public function explicitType() { return $this->morphTo(null, 'target_kind'); } + public function namedType() { return $this->morphTo(type: 'named_kind'); } + public function namedBoth() { return $this->morphTo(type: null, name: 'contentOwner'); } + public function nulls() { return $this->morphTo(null, null); } + public function falsey() { return $this->morphTo('', '0'); } + public function caseInsensitive() { return $this->MORPHTO(); } + public function parentheses() { return ($this->morphTo(name: ('linkedItem'), type: (null))); } + public function ignoredArguments() { return $this->morphTo('link', null, 'link_id', 'key'); } + public function namedOwnerKey() { return $this->morphTo(ownerKey: 'id'); } + public function spacedName() { return $this->morphTo('Related Item'); } + public function nestedBlocks() { + if ($this->active) { return $this->morphTo('subject'); } + return $this->morphTo('subject')->withDefault(); + } +} +"#, + ); + let model = &classes[&atom("Comment")]; + let metadata = model.laravel().unwrap(); + let expected = [ + ("commentable", "commentable_type"), + ("relatedItem", "related_item_type"), + ("URLTarget", "u_r_l_target_type"), + ("explicitName", "image_owner_type"), + ("explicitType", "target_kind"), + ("namedType", "named_kind"), + ("namedBoth", "content_owner_type"), + ("nulls", "nulls_type"), + ("falsey", "falsey_type"), + ("caseInsensitive", "case_insensitive_type"), + ("parentheses", "linked_item_type"), + ("ignoredArguments", "link_type"), + ("namedOwnerKey", "named_owner_key_type"), + ("spacedName", "related_item_type"), + ("nestedBlocks", "subject_type"), + ]; + assert_eq!(metadata.morph_type_columns.len(), expected.len()); + for (method, column) in expected { + let explicit = matches!( + method, + "explicitName" + | "explicitType" + | "namedType" + | "namedBoth" + | "nestedBlocks" + | "parentheses" + | "ignoredArguments" + | "spacedName" + ); + assert!( + metadata + .morph_type_columns + .contains(&(atom(method), explicit.then(|| atom(column)))) + ); + assert!(is_morph_type_column(model, column, &|name| { + classes.get(&atom(name)).cloned() + })); + } +} + +#[test] +fn morph_type_columns_reject_dynamic_and_unrelated_calls() { + let classes = classes( + r#"morphTo($this->name); } + public function dynamicType() { return $this->morphTo(null, $this->column); } + public function interpolated() { return $this->morphTo(type: "{$this->column}"); } + public function spread() { return $this->morphTo(...$this->arguments); } + public function otherReceiver() { return $this->other->morphTo(); } + public function argumentCall() { return wrap($this->morphTo()); } + public function ignoredCall() { $this->morphTo(); return 1; } + public function closureOnly() { return fn () => $this->morphTo(); } + public function nestedFunction() { + function nested() { return $this->morphTo(); } + return 1; + } + public function conflictingReturns() { + if ($this->active) { return $this->morphTo('subject'); } + return $this->morphTo('owner'); + } + public function dynamicReturn() { + if ($this->active) { return $this->morphTo(); } + return $this->other(); + } + public function bareReturn() { + if ($this->active) { return $this->morphTo(); } + return; + } + public function invalidUtf8() { return $this->morphTo(type: "\x8b"); } + public function noCalls() { return 1; } + public function onlyIgnoredCall() { $this->morphTo(); } + public function dynamicMethod() { return $this->{'morphTo'}(); } +} +"#, + ); + assert!( + classes[&atom("Comment")] + .laravel() + .unwrap() + .morph_type_columns + .is_empty() + ); +} + +#[test] +fn morph_type_columns_respect_class_trait_and_parent_overrides() { + let classes = classes( + r#"morphTo(null, 'parent_trait_type'); } +} +class ParentModel { + use ParentRelation; + public function subject() { return $this->morphTo(null, 'parent_type'); } + public function inherited() { return $this->morphTo(); } +} +trait Relation { + public function subject() { return $this->morphTo(null, 'trait_type'); } +} +trait NestedRelation { use Relation; } +class TraitModel extends ParentModel { use NestedRelation; } +class OwnModel extends ParentModel { + use NestedRelation; + public function subject() { return $this->morphTo(null, 'own_type'); } +} +class NoRelation extends ParentModel { + use NestedRelation; + public function SUBJECT() { return 1; } + public function owner() { return 1; } +} +trait OtherRelation { + public function subject() { return $this->morphTo(null, 'other_type'); } +} +class AdaptedModel { + use Relation, OtherRelation { + OtherRelation::subject insteadof Relation; + } +} +class AliasedModel { + use Relation, OtherRelation { + OtherRelation::subject insteadof Relation; + Relation::subject as originalSubject; + } +} +trait DefaultRelation { + public function subject() { return $this->morphTo(); } +} +trait ExplicitNameRelation { + public function subject() { return $this->morphTo('subject'); } +} +class DefaultAliasedModel { + use DefaultRelation { subject as alternateSubject; } +} +class DefaultOverriddenModel { + use DefaultRelation { subject as alternateSubject; } + public function subject() { return 1; } +} +class ExplicitNameAliasedModel { + use ExplicitNameRelation { subject as alternateSubject; } +} +trait NestedAlias { + use DefaultRelation { subject as alternateSubject; } +} +class NestedAliasedModel { + use NestedAlias { alternateSubject as finalSubject; } +} +trait Unrelated { public function ordinary() { return 1; } } +trait RequiresSubject { abstract public function subject(); } +class AbstractRequirementModel extends ParentModel { use RequiresSubject; } +class UnqualifiedAliasModel { + use Unrelated, DefaultRelation { subject as anotherSubject; subject as protected; } +} +class SecondTraitAliasModel { + use Unrelated, DefaultRelation { DefaultRelation::subject as secondSubject; } +} +"#, + ); + let loader = |name: &str| classes.get(&atom(name)).cloned(); + for (model, column, expected) in [ + ("TraitModel", "trait_type", true), + ("TraitModel", "parent_type", false), + ("TraitModel", "parent_trait_type", true), + ("TraitModel", "inherited_type", true), + ("OwnModel", "own_type", true), + ("OwnModel", "trait_type", false), + ("OwnModel", "parent_type", false), + ("NoRelation", "parent_type", false), + ("NoRelation", "trait_type", false), + ("NoRelation", "parent_trait_type", false), + ("NoRelation", "inherited_type", true), + ("AdaptedModel", "trait_type", false), + ("AdaptedModel", "other_type", true), + ("AliasedModel", "trait_type", true), + ("AliasedModel", "other_type", true), + ("DefaultAliasedModel", "subject_type", true), + ("DefaultAliasedModel", "alternate_subject_type", true), + ("DefaultOverriddenModel", "subject_type", false), + ("DefaultOverriddenModel", "alternate_subject_type", true), + ("ExplicitNameAliasedModel", "subject_type", true), + ("ExplicitNameAliasedModel", "alternate_subject_type", false), + ("NestedAliasedModel", "subject_type", true), + ("NestedAliasedModel", "alternate_subject_type", true), + ("NestedAliasedModel", "final_subject_type", true), + ("UnqualifiedAliasModel", "another_subject_type", true), + ("UnqualifiedAliasModel", "ordinary_type", false), + ("UnqualifiedAliasModel", "subject", false), + ("AbstractRequirementModel", "parent_type", true), + ("SecondTraitAliasModel", "second_subject_type", true), + ] { + assert_eq!( + is_morph_type_column(&classes[&atom(model)], column, &loader), + expected, + "{model} discriminator {column}" + ); + } +} + +#[test] +fn morph_type_column_lookup_bounds_cyclic_inheritance() { + let classes = classes( + r#"morphTo(); } +} +"#, + ); + assert!(is_morph_type_column( + &classes[&atom("Comment")], + "subject_type", + &|_| None, + )); + assert!(!is_morph_type_column( + &classes[&atom("Comment")], + "another_type", + &|_| None, + )); +} + +#[test] +fn morph_type_column_lookup_reloads_raw_members_before_applying_overrides() { + let classes = classes( + r#"morphTo(); } +} +class Comment extends BaseComment {} +"#, + ); + let mut merged = classes[&atom("Comment")].as_ref().clone(); + merged.methods = classes[&atom("BaseComment")].methods.clone(); + assert!(is_morph_type_column(&merged, "subject_type", &|name| { + classes.get(&atom(name)).cloned() + })); +} + +#[test] +fn morph_column_traits_preserve_literal_tables_and_dynamic_table_methods() { + let classes = classes( + r#"morphTo(); } +} +"#, + ); + assert_eq!( + classes[&atom("TableSource")] + .laravel() + .unwrap() + .table_name + .as_deref(), + Some("entries"), + ); + assert!( + classes[&atom("DynamicTable")] + .laravel() + .unwrap() + .has_get_table_method + ); + assert!(classes[&atom("AbstractTable")].laravel().is_none()); + assert!(classes[&atom("Unrelated")].laravel().is_none()); + let metadata = classes[&atom("MorphTable")].laravel().unwrap(); + assert_eq!(metadata.table_name.as_deref(), Some("relationships")); + assert_eq!(metadata.morph_type_columns, [(atom("subject"), None)]); +} + +#[test] +fn morph_column_extraction_skips_bodies_outside_the_supplied_source() { + let source = "morphTo(); } }"; + crate::parser::with_parsed_program(source, "morph_missing_source", |program, _| { + let mago_syntax::cst::Statement::Trait(trait_def) = program.statements.last().unwrap() + else { + panic!("expected trait fixture"); + }; + assert!( + super::super::model_extraction::extract_morph_type_columns( + trait_def.members.iter(), + "" + ) + .is_empty() + ); + }); +} diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 974b31422..6cba9b1ba 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -283,10 +283,23 @@ fn find_string_key_usages( } else { std::sync::Arc::new(Vec::new()) }; + let morph_columns = (*kind == crate::symbol_map::LaravelStringKind::MorphAlias + && symbol_map + .morph_column_sites + .iter() + .any(|site| site.key == key)) + .then(|| backend.morph_column_spans_for(file_uri, symbol_map)); // First pass: check if this file even has ANY LaravelStringKey matches. // This avoids reading file content from disk for thousands of unrelated files. - let has_match = symbol_map.spans.iter().chain(extra.iter()).any(|span| { + let spans = || { + symbol_map + .spans + .iter() + .chain(extra.iter()) + .chain(morph_columns.iter().flat_map(|spans| spans.iter())) + }; + let has_match = spans().any(|span| { if let SymbolKind::LaravelStringKey { kind: span_kind, key: span_key, @@ -309,7 +322,7 @@ fn find_string_key_usages( let Some(content) = backend.get_file_content_arc(file_uri) else { continue; }; - for span in symbol_map.spans.iter().chain(extra.iter()) { + for span in spans() { if let SymbolKind::LaravelStringKey { kind: span_kind, key: span_key, diff --git a/src/virtual_members/laravel/typed_morph_columns.rs b/src/virtual_members/laravel/typed_morph_columns.rs new file mode 100644 index 000000000..bed144c9a --- /dev/null +++ b/src/virtual_members/laravel/typed_morph_columns.rs @@ -0,0 +1,284 @@ +//! Confirm morph-column alias candidates through the shared type engine. + +use std::collections::HashMap; +use std::sync::Arc; + +use mago_span::HasSpan; +use mago_syntax::cst::{Expression, Node}; + +use crate::Backend; +use crate::php_type::{PhpType, TypeKind}; +use crate::symbol_map::{MorphColumnReceiver, MorphColumnSite, SymbolMap, SymbolSpan}; +use crate::type_engine::resolver::{Loaders, ResolutionCtx, VarResolutionCtx}; +use crate::types::ClassInfo; + +/// Confirmed spans with their class-index generation and immutable source map. +pub(crate) type MorphColumnSpans = (u64, Arc, Arc>); + +impl Backend { + /// Resolve a file's candidate morph aliases once, retaining only comparisons + /// against columns declared by the receiver model's `morphTo()` relations. + pub(crate) fn morph_column_spans_for( + &self, + uri: &str, + map: &Arc, + ) -> Arc> { + if map.morph_column_sites.is_empty() { + return empty_spans(); + } + let content = self + .blade_virtual_content + .read() + .get(uri) + .map(|content| Arc::new(content.clone())) + .or_else(|| self.get_file_content_arc(uri)); + let Some(content) = content.filter(|content| map.matches_source(content)) else { + return empty_spans(); + }; + // A same-length alias edit can arrive before the background parse. + if map.morph_column_sites.iter().any(|site| { + content.get(site.start as usize..site.end as usize) != Some(site.key.as_str()) + }) { + return empty_spans(); + } + // Capture before resolving: a concurrent model edit must also retire + // an answer that finishes computing after that edit invalidated caches. + let generation = self.symbols.class_lookup_generation(); + if let Some((cached_generation, source, spans)) = + self.morph_column_spans_cache.read().get(uri) + && *cached_generation == generation + && Arc::ptr_eq(source, map) + { + return Arc::clone(spans); + } + let spans = Arc::new(self.confirm_morph_columns(uri, &content, &map.morph_column_sites)); + self.morph_column_spans_cache.write().insert( + uri.to_string(), + (generation, Arc::clone(map), Arc::clone(&spans)), + ); + spans + } + + fn confirm_morph_columns( + &self, + uri: &str, + content: &str, + sites: &[MorphColumnSite], + ) -> Vec { + let mut by_receiver: HashMap<(u32, u32), Vec<&MorphColumnSite>> = HashMap::new(); + for site in sites { + by_receiver + .entry((site.receiver_start, site.receiver_end)) + .or_default() + .push(site); + } + let file_ctx = self.file_context(uri); + let class_loader = self.class_loader(&file_ctx); + let function_loader = self.function_loader(&file_ctx); + let function_loader_cl = |name: &str, offset: u32| function_loader(name, offset); + let default_class = ClassInfo::default(); + let mut confirmed = Vec::new(); + crate::parser::with_parsed_program(content, "morph_columns", |program, content| { + visit_expressions(Node::Program(program), &mut |expr| { + let span = expr.span(); + let Some(candidates) = by_receiver.get(&(span.start.offset, span.end.offset)) + else { + return; + }; + let site = candidates[0]; + let offset = span.start.offset; + let current_class = + crate::class_lookup::find_class_at_offset(&file_ctx.classes, offset) + .unwrap_or(&default_class); + let var_ctx = VarResolutionCtx { + var_name: "", + top_level_scope: None, + current_class, + all_classes: &file_ctx.classes, + content, + cursor_offset: offset, + class_loader: &class_loader, + backend: Some(self), + loaders: Loaders::with_function(Some(&function_loader_cl)), + resolved_class_cache: Some(&self.resolved_class_cache), + enclosing_return_type: None, + branch_aware: false, + match_arm_narrowing: HashMap::new(), + scope_var_resolver: None, + scope_proofs: None, + }; + let ty = if matches!( + expr, + Expression::Identifier(_) + | Expression::Self_(_) + | Expression::Static(_) + | Expression::Parent(_) + ) && site.receiver_kind == MorphColumnReceiver::StaticQuery + { + crate::type_engine::subject_resolution::resolve_subject_type( + &content[offset as usize..span.end.offset as usize], + true, + offset, + &crate::type_engine::subject_resolution::SubjectResolutionCtx { + local_classes: &file_ctx.classes, + use_map: &file_ctx.use_map, + namespace: file_ctx.namespace_at(offset), + content, + class_loader: &class_loader, + backend: Some(self), + function_loader: &function_loader_cl, + }, + ) + } else { + crate::type_engine::variable::foreach_resolution::resolve_expression_type( + expr, &var_ctx, + ) + }; + let Some(ty) = ty else { + return; + }; + // Each receiver's literals share its column and call kind. + // A whereIn array needs only one column check. + if has_morph_column(&ty, site, &var_ctx.as_resolution_ctx()) { + confirmed.extend(candidates.iter().map(|site| site.to_span())); + } + }); + }); + confirmed.sort_by_key(|span| span.start); + confirmed + } +} + +fn has_morph_column(ty: &PhpType, site: &MorphColumnSite, ctx: &ResolutionCtx<'_>) -> bool { + match ty.kind() { + TypeKind::Nullable(inner) => return has_morph_column(inner, site, ctx), + TypeKind::Union(members) => { + let mut non_null = members.iter().filter(|ty| !ty.is_null()); + return non_null + .next() + .is_some_and(|ty| has_morph_column(ty, site, ctx)) + && non_null.all(|ty| has_morph_column(ty, site, ctx)); + } + TypeKind::Intersection(members) => { + return members.iter().any(|ty| has_morph_column(ty, site, ctx)); + } + _ => {} + } + let Some(name) = ty.base_name() else { + return false; + }; + if crate::class_lookup::is_subtype_of_named(ty, super::ELOQUENT_MODEL_FQN, ctx.class_loader) { + let Some(class) = (ctx.class_loader)(name) else { + return false; + }; + // Qualified columns only refer to this model when their table matches. + let column = if let Some((table, column)) = site.column.rsplit_once('.') { + if site.receiver_kind == MorphColumnReceiver::Model + || !model_table_matches(&class, table, ctx.class_loader) + { + return false; + } + column + } else { + &site.column + }; + return super::is_morph_type_column(&class, column, ctx.class_loader); + } + if site.receiver_kind != MorphColumnReceiver::Model + && crate::class_lookup::is_subtype_of_named( + ty, + super::ELOQUENT_BUILDER_FQN, + ctx.class_loader, + ) + && let Some(model) = + crate::type_engine::variable::rhs_resolution::extract_generic_arg_from_ancestor( + ty, + super::ELOQUENT_BUILDER_FQN, + 0, + ctx, + ) + && crate::class_lookup::is_subtype_of_named( + &model, + super::ELOQUENT_MODEL_FQN, + ctx.class_loader, + ) + { + return has_morph_column(&model, site, ctx); + } + false +} + +fn model_table_matches( + class: &ClassInfo, + table: &str, + loader: &dyn Fn(&str) -> Option>, +) -> bool { + let mut declared_match = None; + if !collect_table_metadata(class, table, loader, &mut declared_match, 0) { + return false; + } + declared_match.unwrap_or_else(|| super::model_table_name(class).as_deref() == Some(table)) +} + +// A custom getTable() can compute anything, even when a nearer subclass +// declares $table. Search the whole chain before trusting a literal table. +fn collect_table_metadata( + class: &ClassInfo, + requested: &str, + loader: &dyn Fn(&str) -> Option>, + declared_match: &mut Option, + depth: u32, +) -> bool { + if depth > crate::types::MAX_INHERITANCE_DEPTH { + return false; + } + if class.fqn().as_str() == super::ELOQUENT_MODEL_FQN { + return true; + } + if class + .get_method_ci("getTable") + .is_some_and(|method| !method.is_abstract && !method.is_virtual) + { + return false; + } + if let Some(metadata) = class.laravel() { + if metadata.has_get_table_method { + return false; + } + if declared_match.is_none() { + *declared_match = metadata + .table_name + .as_deref() + .map(|table| table == requested); + } + } + for name in &class.used_traits { + if let Some(trait_info) = loader(name) + && !collect_table_metadata(&trait_info, requested, loader, declared_match, depth + 1) + { + return false; + } + } + class.parent_class.as_ref().is_none_or(|name| { + name.as_str() == super::ELOQUENT_MODEL_FQN + || loader(name).is_some_and(|parent| { + collect_table_metadata(&parent, requested, loader, declared_match, depth + 1) + }) + }) +} + +fn visit_expressions(node: Node<'_, '_>, visitor: &mut impl FnMut(&Expression<'_>)) { + if let Node::Expression(expr) = node { + visitor(expr); + } + node.visit_children(|child| visit_expressions(child, visitor)); +} + +fn empty_spans() -> Arc> { + static EMPTY: std::sync::OnceLock>> = std::sync::OnceLock::new(); + Arc::clone(EMPTY.get_or_init(|| Arc::new(Vec::new()))) +} + +#[cfg(test)] +#[path = "typed_morph_columns_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/typed_morph_columns_tests.rs b/src/virtual_members/laravel/typed_morph_columns_tests.rs new file mode 100644 index 000000000..b5a44d2f8 --- /dev/null +++ b/src/virtual_members/laravel/typed_morph_columns_tests.rs @@ -0,0 +1,207 @@ +use super::*; +use crate::atom::atom; +use crate::test_fixtures::make_class; + +fn symbol_map(content: &str) -> Arc { + crate::parser::with_parsed_program(content, "morph_test", |program, content| { + Arc::new(crate::symbol_map::extract_symbol_map(program, content)) + }) +} + +#[test] +fn morph_column_cache_rejects_missing_changed_and_outdated_sources() { + let backend = Backend::new_headless(); + let uri = "file:///phpantom-morph-cache.php"; + let source = "morphTo(); } } Comment::where('subject_type', 'post');"; + let map = symbol_map(source); + assert!(backend.morph_column_spans_for(uri, &map).is_empty()); + backend + .open_files + .write() + .insert(uri.to_string(), Arc::new(source.to_string())); + backend.update_ast(uri, source); + let map = backend.symbol_map_for(uri).unwrap(); + let original = backend.morph_column_spans_for(uri, &map); + assert_eq!(original.len(), 1); + assert!(Arc::ptr_eq( + &original, + &backend.morph_column_spans_for(uri, &map) + )); + + backend.symbols.note_class_lookup_change(); + let refreshed = backend.morph_column_spans_for(uri, &map); + assert_eq!(refreshed.len(), 1); + assert!(!Arc::ptr_eq(&original, &refreshed)); + let replacement = symbol_map(source); + assert!(!Arc::ptr_eq( + &refreshed, + &backend.morph_column_spans_for(uri, &replacement) + )); + + backend.open_files.write().insert( + uri.to_string(), + Arc::new(source.replace("'post'", "'user'")), + ); + assert!(backend.morph_column_spans_for(uri, &map).is_empty()); + backend + .open_files + .write() + .insert(uri.to_string(), Arc::new(format!("{source}\n"))); + assert!(backend.morph_column_spans_for(uri, &map).is_empty()); + backend + .blade_virtual_content + .write() + .insert(uri.to_string(), source.to_string()); + assert_eq!(backend.morph_column_spans_for(uri, &map).len(), 1); + assert!( + backend + .morph_column_spans_for(uri, &symbol_map("morphTo(); } +} +Comment::where('entries.subject_type', 'post'); +Comment::where('comments.subject_type', 'post'); +"#; + backend + .open_files + .write() + .insert(uri.to_string(), Arc::new(content.to_string())); + backend.update_ast(uri, content); + let map = backend.symbol_map_for(uri).unwrap(); + let spans = backend.morph_column_spans_for(uri, &map); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].start as usize, content.find("'post'").unwrap() + 1); +} + +#[test] +fn morph_column_confirmation_preserves_array_values_across_nested_calls() { + let backend = Backend::new_test(); + let uri = "file:///phpantom-morph-array.php"; + let content = r#"morphTo(); } +} +class Other extends \Illuminate\Database\Eloquent\Model {} +Comment::whereIn('subject_type', [ + 'post', + Other::whereIn('subject_type', ['unrelated']), + Comment::whereIn('subject_type', ['nested']), + Comment::whereIn('other_type', ['invalid']), + 'post', + 'video', +]); +"#; + backend + .open_files + .write() + .insert(uri.to_string(), Arc::new(content.to_string())); + backend.update_ast(uri, content); + let map = backend.symbol_map_for(uri).unwrap(); + let spans = backend.morph_column_spans_for(uri, &map); + let aliases: Vec<_> = spans + .iter() + .map(|span| &content[span.start as usize..span.end as usize]) + .collect(); + assert_eq!(aliases, ["post", "nested", "post", "video"]); +} diff --git a/tests/integration/laravel_morph_columns.rs b/tests/integration/laravel_morph_columns.rs new file mode 100644 index 000000000..2fa3f2edf --- /dev/null +++ b/tests/integration/laravel_morph_columns.rs @@ -0,0 +1,558 @@ +//! Morph-map aliases used as values of a model's actual polymorphic type columns. + +use crate::common::{create_psr4_workspace, open_php}; +use phpantom_lsp::Backend; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER: &str = r#"{ + "require": { "laravel/framework": "^12.0" }, + "autoload": { "psr-4": { "App\\": "app/", "Illuminate\\Database\\Eloquent\\": "framework/" } } +}"#; + +const COMMENT: &str = r#"morphTo(); } +} +"#; + +const ATTACHMENT: &str = r#"morphTo(); } + public function owner(): MorphTo { + return $this->morphTo(type: 'owner_kind', name: 'asset'); + } + public function renamed(): MorphTo { return $this->morphTo('media'); } +} +"#; + +const SUBJECT: &str = r#"morphTo('subject', 'subject_kind'); + } +} +"#; + +const OTHER: &str = r#" String { + let method = if enforced { + "enforceMorphMap" + } else { + "morphMap" + }; + format!( + r#" Post::class, 'video' => Video::class]); + }} +}} +"# + ) +} + +fn consumer(body: &str) -> String { + format!( + r#" $query */ + public function run(Comment $comment, Attachment $attachment, Other $other, Builder $query, OtherQuery $ordinary, \Illuminate\Database\Query\Builder $sql): void {{ + {body} + }} +}} +"# + ) +} + +async fn workspace(source: &str, enforced: bool) -> (Backend, tempfile::TempDir, Url) { + let registration = provider(enforced); + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("framework/Model.php", super::laravel_morph_map::MODEL_PHP), + ( + "framework/Builder.php", + super::laravel_morph_map::BUILDER_PHP, + ), + ( + "bootstrap/providers.php", + "commentable_type === 'post') {} + Comment::whereHasMorph('commentable', ['post']); + } +} +"#, + ), + ("app/Consumer.php", source), + ], + ); + backend.initialized(InitializedParams {}).await; + let uri = Url::from_file_path(dir.path().join("app/Consumer.php")).unwrap(); + open_php(&backend, &uri, source).await; + (backend, dir, uri) +} + +fn position_after(source: &str, prefix: &str) -> Position { + let end = source + .find(prefix) + .unwrap_or_else(|| panic!("missing {prefix}")) + + prefix.len(); + let before = &source[..end]; + Position::new( + before.bytes().filter(|byte| *byte == b'\n').count() as u32, + before.rsplit('\n').next().unwrap().encode_utf16().count() as u32, + ) +} + +async fn labels(backend: &Backend, uri: &Url, position: Position) -> Vec { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap(); + match response { + Some(CompletionResponse::Array(items)) => { + items.into_iter().map(|item| item.label).collect() + } + Some(CompletionResponse::List(list)) => { + list.items.into_iter().map(|item| item.label).collect() + } + None => Vec::new(), + } +} + +async fn hover(backend: &Backend, uri: &Url, position: Position) -> Option { + let result = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap()?; + match result.contents { + HoverContents::Markup(markup) => Some(markup.value), + contents => Some(format!("{contents:?}")), + } +} + +fn morph_diagnostics(backend: &Backend, uri: &Url, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + diagnostics + .into_iter() + .filter(|diagnostic| { + matches!(&diagnostic.code, Some(NumberOrString::String(code)) if code == "invalid_laravel_morph_alias") + }) + .collect() +} + +#[tokio::test] +async fn model_and_typed_builder_where_values_complete_and_hover() { + let statements = [ + "Comment::where('commentable_type', 'post');", + "Comment::query()->where('commentable_type', '=', 'post');", + "$query->where('commentable_type', '!=', 'post');", + "$comment->orWhere('commentable_type', '<>', 'post');", + "$query->orWhere(column: 'commentable_type', operator: 'post');", + "Comment::where(value: 'post', operator: '=', column: 'commentable_type');", + "$query->whereIn('commentable_type', ['post', 'video']);", + "$query->orWhereIn('commentable_type', array('post'));", + "Comment::whereNotIn('commentable_type', ['post']);", + "$query->orWhereNotIn(values: ['post'], column: 'commentable_type');", + ]; + let source = consumer(&statements.join("\n")); + let (backend, _dir, uri) = workspace(&source, false).await; + for statement in statements { + let prefix = &statement[..statement.find("'post").unwrap() + 3]; + let position = position_after(&source, prefix); + assert_eq!( + labels(&backend, &uri, position).await, + ["post"], + "{statement}" + ); + let text = hover(&backend, &uri, position).await.expect(statement); + assert!(text.contains("App\\Models\\Post"), "{statement}: {text}"); + } +} + +#[tokio::test] +async fn property_equality_aliases_work_in_both_directions_and_with_nullsafe_access() { + let statements = [ + "if ($comment->commentable_type === 'post') {}", + "if ($comment->commentable_type == 'post') {}", + "if ($comment->commentable_type !== 'post') {}", + "if ($comment->commentable_type != 'post') {}", + "if ('post' === $comment->commentable_type) {}", + "if ('post' == $comment->commentable_type) {}", + "if ('post' !== $comment->commentable_type) {}", + "if ('post' != $comment->commentable_type) {}", + "if ($comment?->commentable_type === 'post') {}", + "if ('post' !== $comment?->commentable_type) {}", + ]; + let source = consumer(&statements.join("\n")); + let (backend, _dir, uri) = workspace(&source, false).await; + for statement in statements { + let start = source.find(statement).unwrap(); + let prefix = &source[..start + statement.find("'post").unwrap() + 3]; + let position = position_after(&source, prefix); + assert_eq!( + labels(&backend, &uri, position).await, + ["post"], + "{statement}" + ); + assert!( + hover(&backend, &uri, position) + .await + .unwrap() + .contains("App\\Models\\Post"), + "{statement}" + ); + } +} + +#[tokio::test] +async fn empty_literals_and_relation_declared_custom_columns_complete() { + let statements = [ + "Comment::where('commentable_type', '');", + "if ($comment->commentable_type === '') {}", + "if ('' === $comment->commentable_type) {}", + "Attachment::where('commentable_type', '');", + "Attachment::where('image_owner_type', '');", + "Attachment::where('owner_kind', '');", + "Attachment::where('media_type', '');", + "Attachment::where('subject_kind', '');", + "if ($attachment->owner_kind === '') {}", + "$query->whereIn('commentable_type', ['']);", + ]; + let source = consumer(&statements.join("\n")); + let (backend, _dir, uri) = workspace(&source, false).await; + for statement in statements { + let prefix = &statement[..statement.find("''").unwrap() + 1]; + let mut values = labels(&backend, &uri, position_after(&source, prefix)).await; + values.sort_unstable(); + assert_eq!(values, ["post", "video"], "{statement}"); + } +} + +#[tokio::test] +async fn lookalike_columns_receivers_and_non_literal_expressions_are_not_aliases() { + let statements = [ + "Other::where('commentable_type', 'post');", + "Comment::where('owner_kind', 'post');", + "Attachment::where('owner_type', 'post');", + "Attachment::where('imageOwner_type', 'post');", + "Comment::where('status_type', 'post');", + "$query->where('commentable_type', 'like', 'post');", + "$query->where('commentable_type', '>', 'post');", + "$query->where('commentable_type', '<=', 'post');", + "$query->where('commentable_type', $operator, 'post');", + "$query->where($column, 'post');", + "$query->where('commentable_' . 'type', 'post');", + "$query->where('commentable_type', 'post' . $suffix);", + "$query->whereRaw('commentable_type', 'post');", + "$query->whereIn('commentable_type', [['post']]);", + "\\Illuminate\\Support\\Facades\\DB::table('comments')->where('commentable_type', 'post');", + "$unknown->where('commentable_type', 'post');", + "$ordinary->where('commentable_type', 'post');", + "$sql->where('commentable_type', 'post');", + "if ($other->commentable_type === 'post') {}", + "if ($ordinary->commentable_type === 'post') {}", + "if ($comment->owner_kind === 'post') {}", + "if ($comment->commentable_type > 'post') {}", + "if ($comment->commentable_type === 'post' . $suffix) {}", + "if ($comment->{$column} === 'post') {}", + "// $query->where('commentable_type', 'post');", + "$example = \"$query->where('commentable_type', 'post')\";", + ]; + let source = consumer(&statements.join("\n")); + let (backend, _dir, uri) = workspace(&source, true).await; + for statement in statements { + let start = source.find(statement).unwrap(); + let prefix = &source[..start + statement.find("'post").unwrap() + 3]; + let position = position_after(&source, prefix); + let values = labels(&backend, &uri, position).await; + assert!( + !values + .iter() + .any(|value| value == "post" || value == "video"), + "{statement}: {values:?}" + ); + assert!( + hover(&backend, &uri, position) + .await + .is_none_or(|text| !text.contains("Morph type")), + "{statement}" + ); + } + let unknowns = source.replace("'post'", "'missing'"); + open_php(&backend, &uri, &unknowns).await; + assert!(morph_diagnostics(&backend, &uri, &unknowns).is_empty()); +} + +#[tokio::test] +async fn comparisons_diagnose_only_unknown_aliases_under_an_enforced_map() { + let source = consumer( + r#" +Comment::where('commentable_type', 'post'); +Comment::where('commentable_type', 'missing-query'); +$query->whereIn('commentable_type', ['video', 'missing-array']); +if ($comment->commentable_type !== 'missing-property') {} +if ('missing-reversed' === $comment->commentable_type) {} +Comment::where('commentable_type', 'App\\Models\\Post'); +Other::where('commentable_type', 'ordinary'); +"#, + ); + for enforced in [false, true] { + let (backend, _dir, uri) = workspace(&source, enforced).await; + let diagnostics = morph_diagnostics(&backend, &uri, &source); + assert_eq!( + diagnostics.len(), + if enforced { 4 } else { 0 }, + "{diagnostics:?}" + ); + if enforced { + for missing in [ + "missing-query", + "missing-array", + "missing-property", + "missing-reversed", + ] { + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains(missing)), + "{diagnostics:?}" + ); + } + } + } +} + +#[tokio::test] +async fn alias_navigation_links_model_registration_and_cold_cross_file_usages() { + let source = consumer("Comment::where('commentable_type', 'post');"); + let (backend, dir, uri) = workspace(&source, false).await; + let position = position_after(&source, "'commentable_type', 'po"); + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("alias definition"); + let destinations: Vec = match response { + GotoDefinitionResponse::Scalar(location) => vec![location.uri], + GotoDefinitionResponse::Array(locations) => { + locations.into_iter().map(|location| location.uri).collect() + } + GotoDefinitionResponse::Link(links) => { + links.into_iter().map(|link| link.target_uri).collect() + } + }; + for suffix in ["/Providers/AppServiceProvider.php", "/Models/Post.php"] { + assert!( + destinations + .iter() + .any(|target| target.path().ends_with(suffix)), + "{destinations:?}" + ); + } + let registration_uri = + Url::from_file_path(dir.path().join("app/Providers/AppServiceProvider.php")).unwrap(); + let cold_uri = Url::from_file_path(dir.path().join("app/ColdConsumer.php")).unwrap(); + let references = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }) + .await + .unwrap() + .unwrap_or_default(); + assert_eq!( + references + .iter() + .filter(|location| location.uri == cold_uri) + .count(), + 3, + "{references:?}" + ); + assert_eq!( + references + .iter() + .filter(|location| location.uri == uri) + .count(), + 1, + "{references:?}" + ); + assert!( + references + .iter() + .any(|location| location.uri == registration_uri), + "{references:?}" + ); +} + +#[tokio::test] +async fn same_length_model_edits_refresh_cross_file_column_aliases() { + let source = consumer( + "Comment::where('commentable_type', 'post');\nComment::where('mentionable_type', 'post');", + ); + let (backend, dir, uri) = workspace(&source, false).await; + let old_position = position_after(&source, "'commentable_type', 'po"); + let new_position = position_after(&source, "'mentionable_type', 'po"); + assert_eq!(labels(&backend, &uri, old_position).await, ["post"]); + assert!( + !labels(&backend, &uri, new_position) + .await + .iter() + .any(|label| label == "post") + ); + + let model_uri = Url::from_file_path(dir.path().join("app/Models/Comment.php")).unwrap(); + open_php(&backend, &model_uri, COMMENT).await; + let edited = COMMENT.replace("commentable", "mentionable"); + assert_eq!(edited.len(), COMMENT.len()); + backend + .did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { + uri: model_uri, + version: 2, + }, + content_changes: vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: edited, + }], + }) + .await; + assert!( + !labels(&backend, &uri, old_position) + .await + .iter() + .any(|label| label == "post") + ); + assert_eq!(labels(&backend, &uri, new_position).await, ["post"]); + assert!(hover(&backend, &uri, old_position).await.is_none()); + assert!( + hover(&backend, &uri, new_position) + .await + .unwrap() + .contains("App\\Models\\Post") + ); +} + +#[tokio::test] +async fn morph_aliases_resolve_a_builder_subclass_with_the_same_short_name() { + let source = r#" + */ +class Builder extends EloquentBuilder {} +/** @extends EloquentBuilder<\App\Models\Comment> */ +class FixedBuilder extends EloquentBuilder {} +class Consumer { + /** @param Builder $query */ + public function run(Builder $query, FixedBuilder $fixed): void { + $query->where('commentable_type', 'post'); + $fixed->where('commentable_type', 'post'); + } +} +"#; + let (backend, _dir, uri) = workspace(source, false).await; + let position = position_after(source, "'commentable_type', 'po"); + assert_eq!(labels(&backend, &uri, position).await, ["post"]); + assert!( + hover(&backend, &uri, position) + .await + .unwrap() + .contains("App\\Models\\Post") + ); + assert_eq!( + labels( + &backend, + &uri, + position_after(source, "$fixed->where('commentable_type', 'po") + ) + .await, + ["post"] + ); +} diff --git a/tests/integration/laravel_morph_map.rs b/tests/integration/laravel_morph_map.rs index 67cb5e816..17647cc37 100644 --- a/tests/integration/laravel_morph_map.rs +++ b/tests/integration/laravel_morph_map.rs @@ -12,9 +12,30 @@ use tower_lsp::lsp_types::*; const COMPOSER_JSON: &str = r#"{ "require": { "laravel/framework": "^11.0" }, - "autoload": { "psr-4": { "App\\": "src/" } } + "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\Database\\Eloquent\\": "framework/" } } }"#; +pub(super) const MODEL_PHP: &str = r#" */ + public static function query() {} + /** @return Builder */ + public static function where($column, $operator = null, $value = null) {} +} +"#; + +pub(super) const BUILDER_PHP: &str = r#" Position { async fn workspace( enforce: bool, consumer: &str, +) -> (phpantom_lsp::Backend, tempfile::TempDir, String) { + workspace_with_comment(enforce, consumer, COMMENT_PHP).await +} + +async fn workspace_with_comment( + enforce: bool, + consumer: &str, + comment: &str, ) -> (phpantom_lsp::Backend, tempfile::TempDir, String) { let provider_src = provider(enforce); let (backend, dir) = create_psr4_workspace( @@ -115,7 +144,9 @@ async fn workspace( ("src/Providers/AppServiceProvider.php", &provider_src), ("src/Models/Post.php", POST_PHP), ("src/Models/Video.php", VIDEO_PHP), - ("src/Models/Comment.php", COMMENT_PHP), + ("src/Models/Comment.php", comment), + ("framework/Model.php", MODEL_PHP), + ("framework/Builder.php", BUILDER_PHP), ("src/Consumer.php", consumer), ], ); @@ -453,3 +484,606 @@ async fn find_references_links_usages_to_the_registration() { "the registration should be found, got {uris:?}" ); } + +#[tokio::test] +async fn morph_type_where_values_hover_on_models_and_typed_builders() { + let consumer = r#" $query */ + public function go(Builder $query, Comment $comment): void { + Comment::where('commentable_type', 'post'); + Comment::where('commentable_type', '=', 'post'); + $query->where('commentable_type', '!=', 'post'); + $comment->where('commentable_type', '<>', 'post'); + Comment::query()->where('commentable_type', 'post'); + } +} +"#; + let (backend, _dir, uri) = workspace(false, consumer).await; + + for needle in [ + "Comment::where('commentable_type', 'po", + "Comment::where('commentable_type', '=', 'po", + "$query->where('commentable_type', '!=', 'po", + "$comment->where('commentable_type', '<>', 'po", + "Comment::query()->where('commentable_type', 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| panic!("morph alias should hover at {needle}")); + assert!( + hover.contains("App\\Models\\Post"), + "alias should resolve to Post at {needle}, got {hover}" + ); + } +} + +#[tokio::test] +async fn morph_type_columns_resolve_builder_subclasses_and_inherited_table_names() { + let comment = r#"morphTo(); + } +} +"#; + let consumer = r#" + */ +class CustomBuilder extends Builder {} +class SpecialComment extends Comment {} +class Consumer { + /** @param CustomBuilder $query */ + public function go(CustomBuilder $query): void { + $query->where('commentable_type', 'post'); + $query->where('comments.commentable_type', 'post'); + SpecialComment::where('comments.commentable_type', 'post'); + $query->where('posts.commentable_type', 'post'); + SpecialComment::where('special_comments.commentable_type', 'post'); + } +} +"#; + let (backend, _dir, uri) = workspace_with_comment(false, consumer, comment).await; + + for needle in [ + "$query->where('commentable_type', 'po", + "$query->where('comments.commentable_type', 'po", + "SpecialComment::where('comments.commentable_type', 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| panic!("inherited model metadata should resolve at {needle}")); + assert!(hover.contains("App\\Models\\Post"), "got {hover}"); + } + + for needle in [ + "$query->where('posts.commentable_type', 'po", + "SpecialComment::where('special_comments.commentable_type', 'po", + ] { + assert!( + hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .is_none(), + "only the inherited declared table may qualify the morph column at {needle}" + ); + } +} + +#[tokio::test] +async fn morph_type_queries_resolve_relative_class_names_inside_model_methods() { + let consumer = r#"where('commentable_type', 'post'); + $matches = $this->commentable_type === 'post'; + } +} +"#; + let (backend, _dir, uri) = workspace(false, consumer).await; + + for needle in [ + "self::where('commentable_type', 'po", + "static::where('commentable_type', 'po", + "parent::where('commentable_type', 'po", + "$this->where('commentable_type', 'po", + "$this->commentable_type === 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| panic!("relative model receiver should resolve at {needle}")); + assert!(hover.contains("App\\Models\\Post"), "got {hover}"); + } +} + +#[tokio::test] +async fn morph_type_aliases_follow_the_current_type_after_local_reassignment() { + let consumer = r#"commentable_type === 'post'; + $receiver->where('commentable_type', 'post'); + $receiver = new Plain(); + $after = $receiver->commentable_type === 'post'; + $receiver->where('commentable_type', 'video'); + } +} +"#; + let (backend, _dir, uri) = workspace(false, consumer).await; + + for needle in [ + "$before = $receiver->commentable_type === 'po", + "$receiver->where('commentable_type', 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| { + panic!("model receiver should resolve before reassignment at {needle}") + }); + assert!(hover.contains("App\\Models\\Post"), "got {hover}"); + } + + for needle in [ + "$after = $receiver->commentable_type === 'po", + "$receiver->where('commentable_type', 'vi", + ] { + assert!( + hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .is_none(), + "non-model reassignment must stop morph alias recognition at {needle}" + ); + } +} + +#[tokio::test] +async fn morph_type_aliases_require_every_non_null_union_member_to_declare_the_column() { + let consumer = r#"commentable_type === 'post'; + $nullable?->where('commentable_type', 'post'); + $union = $compatible?->commentable_type === 'post'; + $tagged = $intersection->commentable_type === 'post'; + $ambiguous = $incompatible->commentable_type === 'post'; + $incompatible->where('commentable_type', 'post'); + } +} +"#; + let (backend, _dir, uri) = workspace(false, consumer).await; + + for needle in [ + "$nullable?->commentable_type === 'po", + "$nullable?->where('commentable_type', 'po", + "$compatible?->commentable_type === 'po", + "$intersection->commentable_type === 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| panic!("known morph column should resolve at {needle}")); + assert!(hover.contains("App\\Models\\Post"), "got {hover}"); + } + + for needle in [ + "$incompatible->commentable_type === 'po", + "$incompatible->where('commentable_type', 'po", + ] { + assert!( + hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .is_none(), + "a union member without the morph relation must prevent recognition at {needle}" + ); + } +} + +#[tokio::test] +async fn morph_type_queries_resolve_function_returns_and_property_receivers() { + let consumer = r#" */ +function commentQuery(): Builder { return Comment::query(); } +class QueryHolder { + /** @var Builder */ + public Builder $query; +} +class Consumer { + public function go(QueryHolder $holder): void { + commentQuery()->where('commentable_type', 'post'); + $holder->query->where('commentable_type', 'post'); + } +} +"#; + let (backend, _dir, uri) = workspace(false, consumer).await; + + for needle in [ + "commentQuery()->where('commentable_type', 'po", + "$holder->query->where('commentable_type', 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| panic!("builder expression should retain its model at {needle}")); + assert!(hover.contains("App\\Models\\Post"), "got {hover}"); + } +} + +#[tokio::test] +async fn inherited_get_table_override_prevents_guessing_qualified_morph_columns() { + let comment = r#"morphTo(); + } +} +"#; + let consumer = r#"where('commentable_type', 'post'); + Comment::where('comments.commentable_type', 'post'); + $comment->where('comments.commentable_type', 'post'); + } +} +"#; + let (backend, _dir, uri) = workspace_with_comment(false, consumer, comment).await; + + for needle in [ + "Comment::where('commentable_type', 'po", + "$comment->where('commentable_type', 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| { + panic!("declared unqualified morph column should resolve at {needle}") + }); + assert!(hover.contains("App\\Models\\Post"), "got {hover}"); + } + + for needle in [ + "Comment::where('comments.commentable_type', 'po", + "$comment->where('comments.commentable_type', 'po", + ] { + assert!( + hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .is_none(), + "inherited getTable() overrides the child's literal table at {needle}" + ); + } +} + +#[tokio::test] +async fn morph_type_property_comparisons_recognize_both_operand_orders() { + let consumer = r#"commentable_type === 'post'; + $loose = $comment->commentable_type == 'post'; + $different = $comment->commentable_type !== 'post'; + $unequal = $comment->commentable_type != 'post'; + $alternate = $comment->commentable_type <> 'post'; + $reversed = 'post' === $comment->commentable_type; + $reversedUnequal = 'post' != $comment->commentable_type; + } +} +"#; + let (backend, _dir, uri) = workspace(false, consumer).await; + + for needle in [ + "commentable_type === 'po", + "commentable_type == 'po", + "commentable_type !== 'po", + "commentable_type != 'po", + "commentable_type <> 'po", + "$reversed = 'po", + "$reversedUnequal = 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| panic!("comparison alias should hover at {needle}")); + assert!( + hover.contains("App\\Models\\Post"), + "comparison should resolve to Post at {needle}, got {hover}" + ); + } +} + +#[tokio::test] +async fn morph_type_columns_follow_custom_names_inherited_methods_and_traits() { + let comment = r#"morphTo('owner'); + } +} +abstract class BaseComment extends Model { + public function commentable(): MorphTo { + return $this->morphTo('commentable', 'target_kind', 'target_id'); + } +} +class Comment extends BaseComment { + use HasOwner; + public function attachment(): MorphTo { + return $this->morphTo(type: 'attachment_kind', name: 'attachment'); + } +} +"#; + let consumer = r#"target_kind === 'post'; + Comment::where('owner_type', 'post'); + $trait = $comment->owner_type === 'post'; + Comment::where('attachment_kind', 'post'); + $named = $comment->attachment_kind === 'post'; + Comment::where('commentable_type', 'post'); + $oldDefault = $comment->commentable_type === 'post'; + Comment::where('owned_by_type', 'post'); + } +} +"#; + let (backend, _dir, uri) = workspace_with_comment(false, consumer, comment).await; + + for needle in [ + "where('target_kind', 'po", + "target_kind === 'po", + "where('owner_type', 'po", + "owner_type === 'po", + "where('attachment_kind', 'po", + "attachment_kind === 'po", + ] { + let hover = hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .unwrap_or_else(|| panic!("declared morph type column should resolve at {needle}")); + assert!(hover.contains("App\\Models\\Post"), "got {hover}"); + } + + for needle in [ + "where('commentable_type', 'po", + "commentable_type === 'po", + "where('owned_by_type', 'po", + ] { + assert!( + hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .is_none(), + "explicit morphTo arguments must replace the inferred column at {needle}" + ); + } +} + +#[tokio::test] +async fn morph_type_columns_require_a_relation_on_an_eloquent_receiver() { + let consumer = r#"morphTo(); } + public function morphTo() {} + public static function where($column, $operator = null, $value = null) {} +} +class Consumer { + public function go(Comment $comment, Post $post, Plain $plain): void { + Comment::where('document_type', 'post'); + $ordinary = $comment->document_type === 'post'; + Post::where('commentable_type', 'post'); + $otherModel = $post->commentable_type === 'post'; + Plain::where('commentable_type', 'post'); + $plain->where('commentable_type', 'post'); + $lookalike = $plain->commentable_type === 'post'; + Comment::where('commentable_type', 'like', 'post'); + $ordering = $comment->commentable_type > 'post'; + } +} +"#; + let (backend, _dir, uri) = workspace(true, consumer).await; + + for needle in [ + "Comment::where('document_type', 'po", + "document_type === 'po", + "Post::where('commentable_type', 'po", + "$post->commentable_type === 'po", + "Plain::where('commentable_type', 'po", + "$plain->where('commentable_type', 'po", + "$plain->commentable_type === 'po", + "where('commentable_type', 'like', 'po", + "commentable_type > 'po", + ] { + assert!( + hover_at(&backend, &uri, position_after(consumer, needle)) + .await + .is_none(), + "unrelated string must not be recognized as a morph alias at {needle}" + ); + assert!( + definition_uris(&backend, &uri, position_after(consumer, needle)) + .await + .is_empty(), + "unrelated string must not navigate to a morph registration at {needle}" + ); + } +} + +#[tokio::test] +async fn morph_type_column_diagnostics_flag_only_unregistered_comparison_values() { + let consumer = r#"commentable_type === 'unknown'; + $reverse = 'unmapped' !== $comment->commentable_type; + Comment::where('ordinary_type', 'unregistered'); + $ordinary = $comment->ordinary_type === 'unregistered'; + Comment::where('commentable_type', 'like', '%post%'); + } +} +"#; + + for enforce in [false, true] { + let (backend, _dir, uri) = workspace(enforce, consumer).await; + let mut diags = Vec::new(); + backend.collect_slow_diagnostics(&uri, consumer, &mut diags); + let flagged = morph_diagnostics(&diags); + if enforce { + let expected = ["'au", "'mi", "'un", "$reverse = 'un"]; + assert_eq!(flagged.len(), expected.len(), "got {flagged:?}"); + for needle in expected { + let position = position_after(consumer, needle); + assert!( + flagged.iter().any(|diagnostic| { + diagnostic.range.start <= position && position < diagnostic.range.end + }), + "unregistered alias at {needle} should be flagged, got {flagged:?}" + ); + } + } else { + assert!( + flagged.is_empty(), + "open morph map should accept values: {flagged:?}" + ); + } + } +} + +#[tokio::test] +async fn morph_type_column_navigation_and_references_share_the_registered_alias() { + let consumer = r#"commentable_type === 'post'; + Comment::whereHasMorph('commentable', ['post']); + Comment::where('ordinary_type', 'post'); + } +} +"#; + let (backend, dir, uri) = workspace(false, consumer).await; + let provider_src = provider(false); + let provider_uri = Url::from_file_path(dir.path().join("src/Providers/AppServiceProvider.php")) + .unwrap() + .to_string(); + open(&backend, &provider_uri, &provider_src).await; + + let usages = [ + "where('commentable_type', 'po", + "commentable_type === 'po", + "['po", + ]; + for needle in &usages[..2] { + let targets = definition_uris(&backend, &uri, position_after(consumer, needle)).await; + assert!( + targets.iter().any(|target| target == &provider_uri), + "column alias should navigate to registration at {needle}, got {targets:?}" + ); + assert!( + targets + .iter() + .any(|target| target.ends_with("/Models/Post.php")), + "column alias should navigate to mapped model at {needle}, got {targets:?}" + ); + } + + for (source_uri, position) in [ + (&provider_uri, position_after(&provider_src, "'po")), + (&uri, position_after(consumer, usages[0])), + (&uri, position_after(consumer, usages[1])), + ] { + let locations = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: Url::parse(source_uri).unwrap(), + }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }) + .await + .unwrap() + .unwrap_or_default(); + assert!( + locations + .iter() + .any(|location| location.uri.as_str() == provider_uri), + "references should include registration, got {locations:?}" + ); + let usage_lines: Vec = locations + .iter() + .filter(|location| location.uri.as_str() == uri) + .map(|location| location.range.start.line) + .collect(); + assert_eq!(usage_lines.len(), usages.len(), "got {locations:?}"); + for needle in usages { + assert!( + usage_lines.contains(&position_after(consumer, needle).line), + "references should include {needle}, got {locations:?}" + ); + } + } +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index c4bad70e6..b670cd86e 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -173,6 +173,7 @@ mod laravel_date_factory; mod laravel_gates; mod laravel_macro_facade; mod laravel_macros; +mod laravel_morph_columns; mod laravel_morph_map; mod laravel_path_helpers; mod laravel_provider_refresh;