Skip to content

Commit 8faf01a

Browse files
committed
Include virtual member references for nullable/union types
1 parent 2a7e335 commit 8faf01a

3 files changed

Lines changed: 171 additions & 6 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
4242
- **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.
43-
- **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)
43+
- **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, including when the subject has a nullable or union type (e.g. `Foo|null` from `->first()`). (thanks @AbyssWaIker)
4444

4545
### Changed
4646

src/references/mod.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ impl Backend {
151151
// so we only return references on related classes.
152152
let hierarchy =
153153
self.resolve_member_access_hierarchy(uri, subject_text, *is_static, span_start);
154+
154155
self.find_member_references(
155156
member_name,
156157
*is_static,
@@ -1086,10 +1087,12 @@ impl Backend {
10861087
) -> Vec<String> {
10871088
let class_loader = self.class_loader(ctx);
10881089
let function_loader = self.function_loader(ctx);
1089-
let ctx = crate::subject_resolution::SubjectResolutionCtx {
1090+
let use_map = &ctx.use_map;
1091+
let namespace = &ctx.namespace;
1092+
let resolution_ctx = crate::subject_resolution::SubjectResolutionCtx {
10901093
local_classes: &ctx.classes,
1091-
use_map: &ctx.use_map,
1092-
namespace: &ctx.namespace,
1094+
use_map,
1095+
namespace,
10931096
content,
10941097
class_loader: &class_loader,
10951098
function_loader: &function_loader,
@@ -1099,12 +1102,25 @@ impl Backend {
10991102
subject_text,
11001103
is_static,
11011104
access_offset,
1102-
&ctx,
1105+
&resolution_ctx,
11031106
) {
11041107
Some(php_type) => php_type
11051108
.top_level_class_names()
11061109
.into_iter()
1107-
.map(|n| normalize_fqn(&n))
1110+
.map(|n| {
1111+
let normalized = normalize_fqn(&n);
1112+
// top_level_class_names() may return short names
1113+
// (e.g. "BlogAuthor" instead of
1114+
// "App\Models\BlogAuthor"). Resolve them through
1115+
// the file's use-map and namespace so they match
1116+
// the FQNs used in the hierarchy set.
1117+
if normalized.contains('\\') {
1118+
normalized.to_string()
1119+
} else {
1120+
normalize_fqn(&Self::resolve_to_fqn(&normalized, use_map, namespace))
1121+
.to_string()
1122+
}
1123+
})
11081124
.collect(),
11091125
None => Vec::new(),
11101126
}

tests/integration/references.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,3 +1579,152 @@ class Cart {
15791579

15801580
assert_no_duplicates(&results, "one_file_method_refs");
15811581
}
1582+
1583+
// ─── Nullable / union type member references ────────────────────────────────
1584+
1585+
/// Find references on a @property-read member via a nullable variable
1586+
/// should include the @property-read declaration and non-nullable usages.
1587+
#[test]
1588+
fn member_references_nullable_type_virtual_property() {
1589+
let backend = create_test_backend();
1590+
let uri = "file:///tmp/test_refs_nullable_virtual.php";
1591+
let content = r#"<?php
1592+
1593+
/**
1594+
* @property-read string $displayName
1595+
*/
1596+
class Author {
1597+
public function __get(string $name): mixed { return null; }
1598+
1599+
/** @return static|null */
1600+
public static function first(): ?static { return null; }
1601+
}
1602+
1603+
function test(): void {
1604+
$found = Author::first();
1605+
echo $found->displayName;
1606+
1607+
$author = new Author();
1608+
echo $author->displayName;
1609+
}
1610+
"#;
1611+
1612+
open_file(&backend, uri, content);
1613+
1614+
// Cursor on `displayName` in `$found->displayName` (line 14)
1615+
let results = backend
1616+
.find_references(uri, content, Position::new(14, 18), true)
1617+
.expect("should find references");
1618+
1619+
assert_no_duplicates(&results, "nullable_virtual_prop_refs");
1620+
1621+
for (i, loc) in results.iter().enumerate() {
1622+
eprintln!(
1623+
" [{}] {}:{}:{}-{}:{}",
1624+
i,
1625+
loc.uri,
1626+
loc.range.start.line,
1627+
loc.range.start.character,
1628+
loc.range.end.line,
1629+
loc.range.end.character,
1630+
);
1631+
}
1632+
1633+
// Expect: 1 @property-read declaration + 2 accesses ($found->displayName, $author->displayName)
1634+
assert_eq!(
1635+
results.len(),
1636+
3,
1637+
"Expected 3 references (1 @property-read declaration + 2 accesses), got {}: {:#?}",
1638+
results.len(),
1639+
results
1640+
);
1641+
}
1642+
1643+
/// Cross-file find references on a @property-read member via a nullable
1644+
/// variable should include the declaration and non-nullable usages from other files.
1645+
#[test]
1646+
fn cross_file_member_references_nullable_virtual_property() {
1647+
let (backend, _dir) = crate::common::create_psr4_workspace(
1648+
r#"{
1649+
"autoload": {
1650+
"psr-4": {
1651+
"App\\": "src/"
1652+
}
1653+
}
1654+
}"#,
1655+
&[
1656+
(
1657+
"src/Author.php",
1658+
r#"<?php
1659+
namespace App;
1660+
1661+
/**
1662+
* @property-read string $displayName
1663+
*/
1664+
class Author {
1665+
public function __get(string $name): mixed { return null; }
1666+
1667+
/** @return static|null */
1668+
public static function first(): ?static { return null; }
1669+
}
1670+
"#,
1671+
),
1672+
(
1673+
"src/Service.php",
1674+
r#"<?php
1675+
namespace App;
1676+
1677+
class Service {
1678+
public function test(): void {
1679+
$found = Author::first();
1680+
echo $found->displayName;
1681+
1682+
$author = new Author();
1683+
echo $author->displayName;
1684+
}
1685+
}
1686+
"#,
1687+
),
1688+
],
1689+
);
1690+
1691+
let author_path = _dir.path().join("src/Author.php");
1692+
let service_path = _dir.path().join("src/Service.php");
1693+
1694+
let author_uri = format!("file://{}", author_path.display());
1695+
let service_uri = format!("file://{}", service_path.display());
1696+
1697+
let author_content = std::fs::read_to_string(&author_path).unwrap();
1698+
let service_content = std::fs::read_to_string(&service_path).unwrap();
1699+
1700+
open_file(&backend, &author_uri, &author_content);
1701+
open_file(&backend, &service_uri, &service_content);
1702+
1703+
// Cursor on `displayName` in `$found->displayName` (line 6 in Service.php)
1704+
let results = backend
1705+
.find_references(&service_uri, &service_content, Position::new(6, 22), true)
1706+
.expect("should find references");
1707+
1708+
assert_no_duplicates(&results, "cross_file_nullable_virtual_prop_refs");
1709+
1710+
for (i, loc) in results.iter().enumerate() {
1711+
eprintln!(
1712+
" [{}] {}:{}:{}-{}:{}",
1713+
i,
1714+
loc.uri,
1715+
loc.range.start.line,
1716+
loc.range.start.character,
1717+
loc.range.end.line,
1718+
loc.range.end.character,
1719+
);
1720+
}
1721+
1722+
// Expect: 1 @property-read declaration in Author.php + 2 accesses in Service.php
1723+
assert_eq!(
1724+
results.len(),
1725+
3,
1726+
"Expected 3 references (1 @property-read declaration + 2 accesses), got {}: {:#?}",
1727+
results.len(),
1728+
results
1729+
);
1730+
}

0 commit comments

Comments
 (0)