Skip to content

Commit 46172a9

Browse files
committed
Fix relationship property and BelongsTo diagnostics for Laravel models
1 parent eafb028 commit 46172a9

6 files changed

Lines changed: 915 additions & 47 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5757
### Fixed
5858

5959
- **Variable reassignment inside `try` blocks now tracked by the analyzer.** When a variable was reassigned to a different type inside a `try` block, subsequent accesses within the same block still resolved against the original type, producing false-positive `unknown_member` diagnostics. The same applied to `catch` and `finally` blocks. Assignments preceding the cursor within the same block are now treated as sequential.
60+
- **Types with `covariant` or `contravariant` variance annotations in generic args now parse correctly.** PHPStan/Larastan annotations like `BelongsTo<Category, covariant $this>` previously failed to parse, causing the entire type to become unresolvable. This broke Eloquent relationship property synthesis (virtual properties were not generated when the `@return` annotation used variance modifiers) and member lookup on relationship method return types (e.g. `$model->category()->associate()` reported `associate()` as not found). The variance keywords are now stripped from generic parameter positions before parsing.
6061
- **Diagnostics now work for vendor files open in the editor.** Projects using `--prefer-source` or monorepo setups where libraries are edited from the `vendor/` directory no longer have diagnostics suppressed. Previously all vendor files were unconditionally skipped, hiding syntax errors, deprecation warnings, and all other diagnostics. The `analyze` command also accepts vendor paths as explicit overrides (e.g. `phpantom_lsp analyze vendor/some-package/src/`).
6162
- **Full-line diagnostic suppression no longer requires a code mapping.** Full-line diagnostics (from tools like PHPStan that only report line numbers) are now suppressed whenever any precise diagnostic exists on the same line, regardless of error codes. Previously suppression relied on a hand-maintained mapping between error codes from different tools, which was incomplete and allowed redundant full-line underlines to obscure the precise location of the issue.
6263

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ------ |
26-
| B4 | [Relationship property access and BelongsTo return type not resolved](todo/bugs.md#b4-relationship-property-access-and-belongsto-return-type-not-resolved-by-analyzer) | Medium | Medium |
2726
| B5 | [`$this->items` on custom Collection subclass not typed](todo/bugs.md#b5-thisitems-on-custom-collection-subclass-not-typed) | Low | Medium |
2827
| B6 | [Scope methods not found on Builder in analyzer chains](todo/bugs.md#b6-scope-methods-not-found-on-builder-in-analyzer-chains) | High | Medium |
2928
| B7 | [PHPDoc `@param` generic array type not merged with native `array` hint](todo/bugs.md#b7-phpdoc-param-generic-array-type-not-merged-with-native-array-hint) | Low | Medium |

docs/todo/bugs.md

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,5 @@
11
# PHPantom — Bug Fixes
22

3-
## B4: Relationship property access and BelongsTo return type not resolved by analyzer
4-
5-
Eloquent relationship methods accessed as properties (without `()`) are
6-
resolved correctly in the completion engine via `LaravelModelProvider`,
7-
which synthesizes virtual properties (e.g. `translations()` returning
8-
`HasMany<T>` produces `$translations` typed as `Collection<T>`). However,
9-
the analyzer's member-access checker does not find these synthesized
10-
properties in some cross-file scenarios, reporting
11-
`unresolved_member_access`.
12-
13-
Additionally, calling a relationship method WITH `()` (e.g.
14-
`$translation->category()`) returns a `BelongsTo` type that the LSP can
15-
resolve, but then member lookup on that `BelongsTo` fails to find
16-
methods like `associate()`. The `covariant $this` syntax in generic args
17-
(e.g. `BelongsTo<NotificationCategory, covariant $this>`) may interfere
18-
with type parsing.
19-
20-
A separate sub-issue: `FlowService:477` accesses `$order->orderProducts`
21-
(camelCase) while the model declares the property and relationship method
22-
as `orderproducts` (all lowercase). Laravel normalises via `Str::snake()`
23-
at runtime, but the LSP does a case-sensitive property lookup.
24-
25-
Affected diagnostics:
26-
27-
- `NotificationCategory:52``$this->translations` property not resolved
28-
(HasMany relationship, no `@property` annotation)
29-
- `NotificationObject:114``$this->imageFile` property not resolved
30-
(HasOne relationship, no `@property` annotation)
31-
- `NotificationCategoryService:37``$translation->category()->associate()`
32-
`BelongsTo` return type resolves but `associate()` not found on it
33-
34-
`FlowService:477` (`$order->orderProducts`) is a case-sensitivity issue
35-
filed separately as B9.
36-
37-
`FlowService:517` is a compound failure: `$reorder->order->orderproducts`
38-
is a relationship property (this bug), then `->reduce()` returns `mixed`
39-
instead of `Decimal` (generic return type inference gap), then `->add()`
40-
fails on the unresolved type.
41-
42-
**Impact:** 4 diagnostics in the shared project
43-
(`NotificationCategory:52`, `NotificationObject:114`,
44-
`NotificationCategoryService:37`, `FlowService:517`).
45-
463
## B5: `$this->items` on custom Collection subclass not typed
474

485
When a class extends `Collection<int, T>` via `@extends`, accessing

example.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,11 @@ public function demo(): void
16551655
$bakery->vendor_count; // relationship count → int
16561656
$bakery->warmth; // $appends (no cast/attr) → mixed
16571657
// MUST NOT appear: secret_ingredient (private $attributes field)
1658+
1659+
// BelongsTo relationship property + method call with covariant $this
1660+
$post = new BlogPost();
1661+
$post->author; // relationship BelongsTo → BlogAuthor
1662+
$post->author()->associate($post->author); // associate() on BelongsTo
16581663
}
16591664
}
16601665

@@ -4801,6 +4806,9 @@ class BlogPost extends \Illuminate\Database\Eloquent\Model
48014806
{
48024807
public function getTitle(): string { return ''; }
48034808
public function getSlug(): string { return ''; }
4809+
4810+
/** @return \Illuminate\Database\Eloquent\Relations\BelongsTo<BlogAuthor, covariant $this> */
4811+
public function author(): mixed { return $this->belongsTo(BlogAuthor::class); }
48044812
}
48054813

48064814
class AuthorProfile extends \Illuminate\Database\Eloquent\Model
@@ -5740,7 +5748,10 @@ public function orderBy(string $column, string $direction = 'asc'): static { ret
57405748
}
57415749
class HasMany extends Relation {}
57425750
class HasOne extends Relation {}
5743-
class BelongsTo extends Relation {}
5751+
class BelongsTo extends Relation {
5752+
public function associate(mixed $model): static { return $this; }
5753+
public function dissociate(): static { return $this; }
5754+
}
57445755
class BelongsToMany extends Relation {}
57455756
class MorphOne extends Relation {}
57465757
class MorphMany extends Relation {}

src/php_type.rs

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,18 +137,28 @@ impl PhpType {
137137
///
138138
/// This never fails. If the input cannot be parsed by `mago_type_syntax`,
139139
/// returns `PhpType::Raw(input)`.
140+
///
141+
/// PHPStan/Larastan variance annotations (`covariant`, `contravariant`)
142+
/// inside generic parameter positions are stripped before parsing so
143+
/// that types like `BelongsTo<Category, covariant $this>` parse as
144+
/// `Generic("BelongsTo", [Named("Category"), Named("$this")])` instead
145+
/// of falling back to `Raw(…)`.
140146
pub fn parse(input: &str) -> PhpType {
141147
if input.is_empty() {
142148
return PhpType::Raw(String::new());
143149
}
144150

151+
// Strip variance annotations that mago_type_syntax cannot parse.
152+
let cleaned = strip_variance_annotations_from_type(input);
153+
let effective: &str = &cleaned;
154+
145155
let span = Span::new(
146156
FileId::zero(),
147157
Position::new(0),
148-
Position::new(input.len() as u32),
158+
Position::new(effective.len() as u32),
149159
);
150160

151-
match mago_type_syntax::parse_str(span, input) {
161+
match mago_type_syntax::parse_str(span, effective) {
152162
Ok(ty) => convert(&ty),
153163
Err(_) => PhpType::Raw(input.to_owned()),
154164
}
@@ -1496,6 +1506,56 @@ impl PhpType {
14961506
///
14971507
/// This is a superset of [`is_scalar_name`] that also includes PHPDoc-only
14981508
/// pseudo-types and special names that `resolve_type_string` skips.
1509+
/// Strip `covariant ` and `contravariant ` prefixes from generic type
1510+
/// arguments so that `mago_type_syntax` can parse the type.
1511+
///
1512+
/// Only strips the keywords when they appear immediately after `<` or `,`
1513+
/// (with optional whitespace), i.e. inside generic parameter positions.
1514+
/// Returns the input unchanged (no allocation) when no annotations are
1515+
/// found.
1516+
fn strip_variance_annotations_from_type(s: &str) -> std::borrow::Cow<'_, str> {
1517+
// Fast path: no variance annotations at all.
1518+
if !s.contains("covariant ") && !s.contains("contravariant ") {
1519+
return std::borrow::Cow::Borrowed(s);
1520+
}
1521+
1522+
let mut cleaned = String::with_capacity(s.len());
1523+
let bytes = s.as_bytes();
1524+
let mut i = 0usize;
1525+
1526+
while i < bytes.len() {
1527+
// Check whether the preceding non-whitespace is `<` or `,`,
1528+
// meaning we are inside a generic parameter position.
1529+
let preceded_by_generic_delimiter = || -> bool {
1530+
let mut j = i;
1531+
while j > 0 {
1532+
j -= 1;
1533+
if !bytes[j].is_ascii_whitespace() {
1534+
return bytes[j] == b'<' || bytes[j] == b',';
1535+
}
1536+
}
1537+
false
1538+
};
1539+
1540+
if i + "covariant ".len() <= bytes.len()
1541+
&& &bytes[i..i + "covariant ".len()] == b"covariant "
1542+
&& preceded_by_generic_delimiter()
1543+
{
1544+
i += "covariant ".len();
1545+
} else if i + "contravariant ".len() <= bytes.len()
1546+
&& &bytes[i..i + "contravariant ".len()] == b"contravariant "
1547+
&& preceded_by_generic_delimiter()
1548+
{
1549+
i += "contravariant ".len();
1550+
} else {
1551+
cleaned.push(bytes[i] as char);
1552+
i += 1;
1553+
}
1554+
}
1555+
1556+
std::borrow::Cow::Owned(cleaned)
1557+
}
1558+
14991559
fn is_keyword_type(name: &str) -> bool {
15001560
if is_scalar_name(name) {
15011561
return true;
@@ -2215,6 +2275,127 @@ mod tests {
22152275
assert_round_trip("non-empty-array<string>");
22162276
}
22172277

2278+
#[test]
2279+
fn parse_generic_with_covariant_this() {
2280+
// Laravel/Larastan uses `covariant $this` in generic args, e.g.
2281+
// `BelongsTo<Category, covariant $this>`. The parser should
2282+
// still extract the base class name (`BelongsTo`) so that
2283+
// member lookup works on the relationship class.
2284+
let ty = PhpType::parse("BelongsTo<Category, covariant $this>");
2285+
let base = ty.base_name();
2286+
assert_eq!(
2287+
base,
2288+
Some("BelongsTo"),
2289+
"base_name should be 'BelongsTo' even with 'covariant $this' arg, got: {:?} from {:?}",
2290+
base,
2291+
ty,
2292+
);
2293+
}
2294+
2295+
#[test]
2296+
fn parse_generic_with_covariant_preserves_structure() {
2297+
// The full Generic structure should be preserved after stripping.
2298+
let ty = PhpType::parse("HasMany<Post, covariant $this>");
2299+
match &ty {
2300+
PhpType::Generic(name, args) => {
2301+
assert_eq!(name, "HasMany");
2302+
assert_eq!(args.len(), 2);
2303+
assert_eq!(args[0].to_string(), "Post");
2304+
assert_eq!(args[1].to_string(), "$this");
2305+
}
2306+
other => panic!("expected Generic, got: {:?}", other),
2307+
}
2308+
}
2309+
2310+
#[test]
2311+
fn parse_generic_with_contravariant() {
2312+
let ty = PhpType::parse("Comparator<contravariant T>");
2313+
assert_eq!(
2314+
ty.base_name(),
2315+
Some("Comparator"),
2316+
"base_name should work with contravariant annotation",
2317+
);
2318+
match &ty {
2319+
PhpType::Generic(_, args) => {
2320+
assert_eq!(args.len(), 1);
2321+
assert_eq!(args[0].to_string(), "T");
2322+
}
2323+
other => panic!("expected Generic, got: {:?}", other),
2324+
}
2325+
}
2326+
2327+
#[test]
2328+
fn parse_generic_with_covariant_fqn() {
2329+
// Fully-qualified relationship type with covariant $this.
2330+
let ty = PhpType::parse(
2331+
"Illuminate\\Database\\Eloquent\\Relations\\BelongsTo<Category, covariant $this>",
2332+
);
2333+
assert_eq!(
2334+
ty.base_name(),
2335+
Some("Illuminate\\Database\\Eloquent\\Relations\\BelongsTo"),
2336+
);
2337+
}
2338+
2339+
#[test]
2340+
fn parse_generic_with_multiple_covariant_args() {
2341+
let ty = PhpType::parse("Map<covariant TKey, covariant TValue>");
2342+
match &ty {
2343+
PhpType::Generic(name, args) => {
2344+
assert_eq!(name, "Map");
2345+
assert_eq!(args.len(), 2);
2346+
assert_eq!(args[0].to_string(), "TKey");
2347+
assert_eq!(args[1].to_string(), "TValue");
2348+
}
2349+
other => panic!("expected Generic, got: {:?}", other),
2350+
}
2351+
}
2352+
2353+
#[test]
2354+
fn parse_no_false_strip_of_covariant_class_name() {
2355+
// A class named `covariant` (unlikely but possible) should not
2356+
// be stripped when it is NOT inside a generic parameter position.
2357+
// It appears at the top level, not after `<` or `,`.
2358+
let ty = PhpType::parse("covariant");
2359+
// mago may or may not parse this as a Named type; the key is
2360+
// that stripping should NOT remove it since it's not after < or ,.
2361+
assert_ne!(ty.to_string(), "", "should not produce empty string");
2362+
}
2363+
2364+
#[test]
2365+
fn parse_generic_without_covariant_unchanged() {
2366+
// Normal generics without variance annotations should be unaffected.
2367+
let ty = PhpType::parse("Collection<int, User>");
2368+
match &ty {
2369+
PhpType::Generic(name, args) => {
2370+
assert_eq!(name, "Collection");
2371+
assert_eq!(args.len(), 2);
2372+
}
2373+
other => panic!("expected Generic, got: {:?}", other),
2374+
}
2375+
}
2376+
2377+
#[test]
2378+
fn parse_covariant_array_shape_in_generic() {
2379+
// `covariant array{...}` inside a generic — the array shape
2380+
// should still parse after stripping the variance keyword.
2381+
let ty = PhpType::parse(
2382+
"Collection<int, covariant array{customer: Customer, contact: Contact|null}>",
2383+
);
2384+
assert_eq!(ty.base_name(), Some("Collection"));
2385+
match &ty {
2386+
PhpType::Generic(_, args) => {
2387+
assert_eq!(args.len(), 2);
2388+
// The second arg should be an array shape, not Raw.
2389+
assert!(
2390+
matches!(&args[1], PhpType::ArrayShape(_)),
2391+
"second arg should be ArrayShape after stripping covariant, got: {:?}",
2392+
args[1],
2393+
);
2394+
}
2395+
other => panic!("expected Generic, got: {:?}", other),
2396+
}
2397+
}
2398+
22182399
#[test]
22192400
fn round_trip_class_references() {
22202401
assert_round_trip("Foo\\Bar");

0 commit comments

Comments
 (0)