Skip to content

Commit 15e8fc8

Browse files
committed
Fix false-positive unused $loop in Blade and update Laravel demos
1 parent a35f780 commit 15e8fc8

13 files changed

Lines changed: 383 additions & 100 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
- **`array_reduce`, `array_sum`, and `array_product` return type inference.** `array_reduce()` resolves to the type of its initial value argument. `array_sum()` and `array_product()` resolve to `int|float`.
4040
- **Machine-readable CLI output.** Both `analyze` and `fix` accept a `--format` flag with `table`, `github`, and `json` options. When `GITHUB_ACTIONS` is set, table output automatically includes GitHub annotations.
4141
- **Magic property diagnostics.** New `report-magic-properties` option under `[diagnostics]` in `.phpantom.toml`. When enabled, classes with `__get` that also have virtual properties (from `@property` docblock tags, Laravel Eloquent column inference, or other providers) will flag unknown property access instead of silently allowing it.
42+
- **Inline diagnostic suppression.** `// @phpantom-ignore code` on the same line or the line above suppresses the specified diagnostic. Multiple codes can be comma-separated. A bare `// @phpantom-ignore` suppresses all diagnostics on the target line.
4243

4344
### Changed
4445

46+
- **Replace FQCN with import.** Now replaces all occurrences of the same FQCN throughout the file in one action, not just the one under the cursor. A new "Replace all FQCNs with imports" action appears when the file contains multiple distinct FQCNs, replacing all of them at once (skipping those with import conflicts).
4547
- **LSP responsiveness.** Hover, go-to-definition, signature help, code actions, rename, and other handlers now run on background threads. Slow requests no longer block other requests or cancellations.
4648
- **Faster analysis.** Analysis time cut significantly on large projects.
4749
- **Editing responsiveness.** Classes evicted from the cache after a file edit are now eagerly re-populated in dependency order.
@@ -55,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5557

5658
- **Blade diagnostics in `analyze` command.** Blade template files now receive full diagnostics (unknown members, unknown classes, unused variables, etc.) when running `phpantom_lsp analyze`.
5759
- **Blade unknown-member diagnostic positions.** Unknown-member diagnostics in Blade files now point to the correct line in the original template instead of the virtual PHP line number.
60+
- **False-positive unused `$loop` in Blade templates.** The `$loop` variable injected by `@foreach`/`@forelse` is no longer flagged as unused when the template body doesn't reference it.
5861
- **Spurious function auto-imports.** Import statements like `use function is_array;` were misidentified as function declarations, polluting the completion list with phantom entries that inserted incorrect imports.
5962
- **Duplicate `use function` insertion.** Accepting a function completion no longer inserts a `use function` statement when the exact import already exists in the file.
6063
- **Function import conflict handling.** When a different function with the same short name is already imported, completing a namespaced function now inserts the fully-qualified name instead of the ambiguous short name.

docs/todo/bugs.md

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,3 @@ handle this automatically.
2121
**Tests:** Assertion lines were removed from
2222
`tests/psalm_assertions/method_call.php` (out of scope until
2323
upstream stubs land).
24-
25-
26-
## B17. Blade `$loop` variable triggers unused-variable false positive
27-
28-
The Blade preprocessor injects `$loop = new \stdClass();` inside
29-
`@foreach` / `@forelse` blocks to provide completion for the magic
30-
`$loop` variable. When the template does not actually reference
31-
`$loop`, the unused-variable diagnostic flags it. The injected
32-
assignment should either be suppressed from unused-variable checks
33-
(e.g. by marking it as a synthetic definition) or the preprocessor
34-
should only inject it when `$loop` is actually used in the block.

examples/laravel/app/Models/AuthorCollection.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
namespace App\Models;
44

5+
use Illuminate\Database\Eloquent\Collection;
6+
57
/**
68
* @template TKey of array-key
79
* @template TModel of BlogAuthor
8-
* @extends \Illuminate\Database\Eloquent\Collection<TKey, TModel>
10+
* @extends Collection<TKey, TModel>
911
*/
10-
class AuthorCollection extends \Illuminate\Database\Eloquent\Collection
12+
class AuthorCollection extends Collection
1113
{
1214
/** @return static */
1315
public function active(): static { return $this->filter(fn($a) => $a->active); }

examples/laravel/app/Models/Bakery.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
namespace App\Models;
44

55
use Illuminate\Database\Eloquent\Attributes\Scope;
6+
use Illuminate\Database\Eloquent\Builder;
67
use Illuminate\Database\Eloquent\Casts\Attribute;
78
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
10+
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\Relations\HasOne;
812

913
class Bakery extends Model
1014
{
@@ -42,29 +46,29 @@ protected function casts(): array
4246
'gluten_free' => false,
4347
];
4448

45-
/** @return \Illuminate\Database\Eloquent\Relations\HasMany<Loaf, $this> */
49+
/** @return HasMany<Loaf, $this> */
4650
public function baguettes(): mixed { return $this->hasMany(Loaf::class); }
4751

48-
/** @return \Illuminate\Database\Eloquent\Relations\HasOne<Baker, $this> */
52+
/** @return HasOne<Baker, $this> */
4953
public function headBaker(): mixed { return $this->hasOne(Baker::class); }
5054

51-
/** @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<BakeryRecipe, $this> */
55+
/** @return BelongsToMany<BakeryRecipe, $this> */
5256
public function masterRecipe(): mixed { return $this->belongsToMany(BakeryRecipe::class); }
5357

5458
public function vendor() { return $this->morphTo(); }
5559

56-
public function scopeTopping(\Illuminate\Database\Eloquent\Builder $query, string $type): void
60+
public function scopeTopping(Builder $query, string $type): void
5761
{
5862
$query->where('topping', $type);
5963
}
6064

61-
public function scopeUnbaked(\Illuminate\Database\Eloquent\Builder $query): void
65+
public function scopeUnbaked(Builder $query): void
6266
{
6367
$query->where('baked', false);
6468
}
6569

6670
#[Scope]
67-
protected function freshlyBaked(\Illuminate\Database\Eloquent\Builder $query): void
71+
protected function freshlyBaked(Builder $query): void
6872
{
6973
$query->where('fresh', true);
7074
}

examples/laravel/app/Models/BlogAuthor.php

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,41 @@
33
namespace App\Models;
44

55
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
6+
use Illuminate\Database\Eloquent\Builder;
67
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\Relations\HasOne;
710

811
#[CollectedBy(AuthorCollection::class)]
912

1013
/**
11-
* @method static \Illuminate\Database\Eloquent\Builder<static> withTrashed(bool $withTrashed = true)
12-
* @method static \Illuminate\Database\Eloquent\Builder<static> onlyTrashed()
14+
* @method static Builder<static> withTrashed(bool $withTrashed = true)
15+
* @method static Builder<static> onlyTrashed()
1316
*/
1417
class BlogAuthor extends Model
1518
{
1619
protected $fillable = ['name', 'email', 'genre'];
1720

21+
protected $casts = [
22+
'active' => 'bool',
23+
];
24+
1825
protected $guarded = ['id'];
1926

2027
protected $hidden = ['password'];
2128

22-
/** @return \Illuminate\Database\Eloquent\Relations\HasMany<BlogPost, $this> */
29+
/** @return HasMany<BlogPost, $this> */
2330
public function posts(): mixed { return $this->hasMany(BlogPost::class); }
2431

25-
/** @return \Illuminate\Database\Eloquent\Relations\HasOne<AuthorProfile, $this> */
32+
/** @return HasOne<AuthorProfile, $this> */
2633
public function profile(): mixed { return $this->hasOne(AuthorProfile::class); }
2734

28-
public function scopeActive(\Illuminate\Database\Eloquent\Builder $query): void
35+
public function scopeActive(Builder $query): void
2936
{
3037
$query->where('active', true);
3138
}
3239

33-
public function scopeOfGenre(\Illuminate\Database\Eloquent\Builder $query, string $genre): void
40+
public function scopeOfGenre(Builder $query, string $genre): void
3441
{
3542
$query->where('genre', $genre);
3643
}

examples/laravel/app/Models/BlogPost.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44

55
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
66
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
78

89
#[CollectedBy(PostCollection::class)]
910
class BlogPost extends Model
1011
{
11-
public function getTitle(): string { return ''; }
12-
public function getSlug(): string { return ''; }
12+
protected $fillable = ['title', 'slug'];
1313

14-
/** @return \Illuminate\Database\Eloquent\Relations\BelongsTo<BlogAuthor, covariant $this> */
14+
protected $casts = [
15+
'published' => 'bool',
16+
];
17+
18+
public function getTitle(): string { return $this->title; }
19+
public function getSlug(): string { return $this->slug; }
20+
21+
/** @return BelongsTo<BlogAuthor, covariant $this> */
1522
public function author(): mixed { return $this->belongsTo(BlogAuthor::class); }
1623
}

examples/laravel/app/Models/PostCollection.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
namespace App\Models;
44

5+
use Illuminate\Database\Eloquent\Collection;
6+
57
/**
68
* @template TKey of array-key
79
* @template TModel of BlogPost
8-
* @extends \Illuminate\Database\Eloquent\Collection<TKey, TModel>
10+
* @extends Collection<TKey, TModel>
911
*/
10-
class PostCollection extends \Illuminate\Database\Eloquent\Collection
12+
class PostCollection extends Collection
1113
{
1214
/** @return static */
1315
public function published(): static { return $this->filter(fn($p) => $p->published); }

examples/laravel/app/Models/Review.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
66
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\HasMany;
78

89
#[CollectedBy(ReviewCollection::class)]
910
class Review extends Model
1011
{
1112
public function getTitle(): string { return ''; }
1213
public function getRating(): int { return 0; }
1314

14-
/** @return \Illuminate\Database\Eloquent\Relations\HasMany<Review, $this> */
15+
/** @return HasMany<Review, $this> */
1516
public function replies(): mixed { return $this->hasMany(Review::class); }
1617
}

examples/laravel/app/Models/ReviewCollection.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
namespace App\Models;
44

5+
use Illuminate\Database\Eloquent\Collection;
6+
57
/**
68
* @template TKey of array-key
79
* @template TModel
8-
* @extends \Illuminate\Database\Eloquent\Collection<TKey, TModel>
10+
* @extends Collection<TKey, TModel>
911
*/
10-
class ReviewCollection extends \Illuminate\Database\Eloquent\Collection
12+
class ReviewCollection extends Collection
1113
{
1214
/** @return array<TKey, TModel> */
1315
public function topRated(): array { return []; }

src/analyse.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ pub async fn run(options: AnalyseOptions) -> i32 {
306306
if i >= file_count {
307307
break;
308308
}
309-
let (uri, content) = match &file_data[i] {
309+
let (uri, original_content) = match &file_data[i] {
310310
Some(pair) => (&pair.0, &pair.1),
311311
None => continue, // file that failed to read
312312
};
@@ -321,10 +321,10 @@ pub async fn run(options: AnalyseOptions) -> i32 {
321321
blade_content = vc.clone();
322322
&blade_content
323323
} else {
324-
content
324+
original_content
325325
}
326326
} else {
327-
content
327+
original_content
328328
};
329329

330330
// Activate ONE parse cache for the entire file so
@@ -495,6 +495,12 @@ pub async fn run(options: AnalyseOptions) -> i32 {
495495
}
496496
}
497497

498+
// ── Apply @phpantom-ignore comment suppression ─────
499+
// Use original_content (not virtual PHP) because
500+
// diagnostic line numbers have already been translated
501+
// back to original file coordinates.
502+
crate::diagnostics::filter_ignored_by_comment(&mut raw, original_content);
503+
498504
// For Blade files, translate diagnostic ranges from
499505
// virtual PHP coordinates back to original Blade
500506
// coordinates so line numbers match the source file.

0 commit comments

Comments
 (0)