Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **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.
- **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.
- **Find references and rename for PHPDoc virtual members.** `@property`, `@property-read`, `@property-write`, and `@method` declarations in docblocks are now included in find-references and rename results alongside their runtime usages. (thanks @AbyssWaIker)

### Changed

Expand Down
23 changes: 21 additions & 2 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public function customCollection(): void
$top = $reviews->topRated(); // custom method from ReviewCollection
$avg = $reviews->averageRating(); // custom method from ReviewCollection
$reviews->first(); // inherited — returns Review|null
echo count($top), $avg;

// Relationship properties also use the custom collection
$review = new Review();
Expand All @@ -133,7 +134,8 @@ public function eloquentClosure(): void
{
// Eloquent chunk — $orders inferred as Collection
BlogAuthor::where('active', true)->chunk(100, function ($orders) {
$total = $orders->count(); // resolves to Eloquent Collection
$count = $orders->count(); // resolves to Eloquent Collection
echo $count;
});

// Explicit bare type hint inherits inferred generic args for foreach
Expand Down Expand Up @@ -213,7 +215,24 @@ public function laravelNavigation(): void
}


// ── Laravel Config (definition & references) ────────────────────────────
// ── PHPDoc Virtual Member References & Rename ───────────────────────────
// Try: right-click "displayName" or "bio" below and use
// • Find All References — includes the @property/@method declaration
// • Rename Symbol — renames in the docblock AND all usage sites

public function phpdocVirtualMembers(): void
{
$author = new BlogAuthor();
$author->displayName; // @property-read on BlogAuthor
$author->bio(); // @method on BlogAuthor

$found = BlogAuthor::where('active', true)->first();
$found->displayName;
$found->bio();
}


// ── Laravel Config (definition & references) ────────────────────────

public function laravelConfig(): void
{
Expand Down
5 changes: 3 additions & 2 deletions examples/laravel/app/Models/BlogAuthor.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

#[CollectedBy(AuthorCollection::class)]

/**
* @property-read string $displayName
* @method string bio()
* @method static Builder<static> withTrashed(bool $withTrashed = true)
* @method static Builder<static> onlyTrashed()
*/
#[CollectedBy(AuthorCollection::class)]
class BlogAuthor extends Model
{
protected $fillable = ['name', 'email', 'genre'];
Expand Down
26 changes: 23 additions & 3 deletions src/references/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -774,8 +774,19 @@ impl Backend {
// Check if the enclosing class is in the hierarchy.
if let Some(hier) = hierarchy {
let ctx = file_ctx_cell.get_or_init(|| self.file_context(file_uri));
if let Some(enclosing) = find_class_at_offset(&ctx.classes, span.start)
{
let enclosing =
find_class_at_offset(&ctx.classes, span.start).or_else(|| {
// Docblock MemberDeclaration spans are before the
// opening brace; fall back to the nearest class.
ctx.classes
.iter()
.map(|c| c.as_ref())
.filter(|c| {
c.keyword_offset > 0 && span.start < c.start_offset
})
.min_by_key(|c| c.start_offset)
});
if let Some(enclosing) = enclosing {
let fqn = enclosing.fqn().to_string();
if !hier.contains(&fqn) {
continue;
Expand Down Expand Up @@ -1045,7 +1056,16 @@ impl Backend {
.get(uri)
.cloned()
.unwrap_or_default();
let current_class = find_class_at_offset(&classes, offset)?;
let current_class = find_class_at_offset(&classes, offset).or_else(|| {
// Fallback: offset may be in a class docblock (before the opening
// brace). Find the nearest class whose body starts past the
// offset, meaning its docblock region likely contains the offset.
classes
.iter()
.map(|c| c.as_ref())
.filter(|c| c.keyword_offset > 0 && offset < c.start_offset)
.min_by_key(|c| c.start_offset)
})?;
let fqn = current_class.fqn().to_string();
Some(self.collect_hierarchy_for_fqns(&[fqn]))
}
Expand Down
Loading
Loading