Skip to content

Commit 2e3c394

Browse files
AbyssWaIkerAJenbo
authored andcommitted
Add find references and rename for PHPDoc @property/@method
1 parent 479a666 commit 2e3c394

5 files changed

Lines changed: 672 additions & 3 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +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)
4344

4445
### Changed
4546

src/references/mod.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -774,8 +774,19 @@ impl Backend {
774774
// Check if the enclosing class is in the hierarchy.
775775
if let Some(hier) = hierarchy {
776776
let ctx = file_ctx_cell.get_or_init(|| self.file_context(file_uri));
777-
if let Some(enclosing) = find_class_at_offset(&ctx.classes, span.start)
778-
{
777+
let enclosing =
778+
find_class_at_offset(&ctx.classes, span.start).or_else(|| {
779+
// Docblock MemberDeclaration spans are before the
780+
// opening brace; fall back to the nearest class.
781+
ctx.classes
782+
.iter()
783+
.map(|c| c.as_ref())
784+
.filter(|c| {
785+
c.keyword_offset > 0 && span.start < c.start_offset
786+
})
787+
.min_by_key(|c| c.start_offset)
788+
});
789+
if let Some(enclosing) = enclosing {
779790
let fqn = enclosing.fqn().to_string();
780791
if !hier.contains(&fqn) {
781792
continue;
@@ -1045,7 +1056,16 @@ impl Backend {
10451056
.get(uri)
10461057
.cloned()
10471058
.unwrap_or_default();
1048-
let current_class = find_class_at_offset(&classes, offset)?;
1059+
let current_class = find_class_at_offset(&classes, offset).or_else(|| {
1060+
// Fallback: offset may be in a class docblock (before the opening
1061+
// brace). Find the nearest class whose body starts past the
1062+
// offset, meaning its docblock region likely contains the offset.
1063+
classes
1064+
.iter()
1065+
.map(|c| c.as_ref())
1066+
.filter(|c| c.keyword_offset > 0 && offset < c.start_offset)
1067+
.min_by_key(|c| c.start_offset)
1068+
})?;
10491069
let fqn = current_class.fqn().to_string();
10501070
Some(self.collect_hierarchy_for_fqns(&[fqn]))
10511071
}

src/references/tests.rs

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,3 +1391,301 @@ async fn test_this_method_references_excludes_unrelated() {
13911391
lines
13921392
);
13931393
}
1394+
1395+
// ─── PHPDoc @property and @method References ────────────────────────────────
1396+
1397+
#[tokio::test]
1398+
async fn test_phpdoc_property_references_from_usage() {
1399+
let backend = Backend::new_test();
1400+
let uri = Url::parse("file:///test.php").unwrap();
1401+
let text = concat!(
1402+
"<?php\n", // L0
1403+
"/**\n", // L1
1404+
" * @property string $email\n", // L2
1405+
" */\n", // L3
1406+
"class User {\n", // L4
1407+
" public function demo(): void {\n", // L5
1408+
" echo $this->email;\n", // L6
1409+
" }\n", // L7
1410+
"}\n", // L8
1411+
"$u = new User();\n", // L9
1412+
"echo $u->email;\n", // L10
1413+
);
1414+
1415+
open_file(&backend, &uri, text).await;
1416+
1417+
// Click on "email" at line 10 ($u->email).
1418+
let locs = find_references(&backend, &uri, 10, 13, true).await;
1419+
assert!(
1420+
locs.len() >= 3,
1421+
"Expected at least 3 references to email (declaration + 2 usages), got {}",
1422+
locs.len()
1423+
);
1424+
1425+
// Should include the @property declaration (line 2).
1426+
let has_declaration = locs.iter().any(|l| l.range.start.line == 2);
1427+
assert!(
1428+
has_declaration,
1429+
"Should include the @property declaration on line 2"
1430+
);
1431+
1432+
// Should include the $this->email usage (line 6).
1433+
let has_this_usage = locs.iter().any(|l| l.range.start.line == 6);
1434+
assert!(
1435+
has_this_usage,
1436+
"Should include the $this->email usage on line 6"
1437+
);
1438+
1439+
// Should include the $u->email usage (line 10).
1440+
let has_external_usage = locs.iter().any(|l| l.range.start.line == 10);
1441+
assert!(
1442+
has_external_usage,
1443+
"Should include the $u->email usage on line 10"
1444+
);
1445+
}
1446+
1447+
#[tokio::test]
1448+
async fn test_phpdoc_property_references_from_declaration() {
1449+
let backend = Backend::new_test();
1450+
let uri = Url::parse("file:///test.php").unwrap();
1451+
let text = concat!(
1452+
"<?php\n", // L0
1453+
"/**\n", // L1
1454+
" * @property string $email\n", // L2
1455+
" */\n", // L3
1456+
"class User {\n", // L4
1457+
" public function demo(): void {\n", // L5
1458+
" echo $this->email;\n", // L6
1459+
" }\n", // L7
1460+
"}\n", // L8
1461+
"$u = new User();\n", // L9
1462+
"echo $u->email;\n", // L10
1463+
);
1464+
1465+
open_file(&backend, &uri, text).await;
1466+
1467+
// Click on "email" in the @property tag (line 2).
1468+
// Line: " * @property string $email"
1469+
// The MemberDeclaration span covers "email" (without $) starting at char 22.
1470+
let locs = find_references(&backend, &uri, 2, 22, true).await;
1471+
assert!(
1472+
locs.len() >= 3,
1473+
"Expected at least 3 references from @property declaration, got {}",
1474+
locs.len()
1475+
);
1476+
}
1477+
1478+
#[tokio::test]
1479+
async fn test_phpdoc_method_references_from_usage() {
1480+
let backend = Backend::new_test();
1481+
let uri = Url::parse("file:///test.php").unwrap();
1482+
let text = concat!(
1483+
"<?php\n", // L0
1484+
"/**\n", // L1
1485+
" * @method string getEmail()\n", // L2
1486+
" */\n", // L3
1487+
"class User {\n", // L4
1488+
"}\n", // L5
1489+
"$u = new User();\n", // L6
1490+
"echo $u->getEmail();\n", // L7
1491+
);
1492+
1493+
open_file(&backend, &uri, text).await;
1494+
1495+
// Click on "getEmail" at line 7 ($u->getEmail()).
1496+
let locs = find_references(&backend, &uri, 7, 10, true).await;
1497+
assert!(
1498+
locs.len() >= 2,
1499+
"Expected at least 2 references to getEmail (declaration + usage), got {}",
1500+
locs.len()
1501+
);
1502+
1503+
// Should include the @method declaration (line 2).
1504+
let has_declaration = locs.iter().any(|l| l.range.start.line == 2);
1505+
assert!(
1506+
has_declaration,
1507+
"Should include the @method declaration on line 2"
1508+
);
1509+
}
1510+
1511+
#[tokio::test]
1512+
async fn test_phpdoc_property_references_exclude_unrelated_class() {
1513+
let backend = Backend::new_test();
1514+
let uri = Url::parse("file:///test.php").unwrap();
1515+
let text = concat!(
1516+
"<?php\n", // L0
1517+
"/**\n", // L1
1518+
" * @property string $email\n", // L2
1519+
" */\n", // L3
1520+
"class User {}\n", // L4
1521+
"/**\n", // L5
1522+
" * @property int $email\n", // L6
1523+
" */\n", // L7
1524+
"class Order {}\n", // L8
1525+
"$u = new User();\n", // L9
1526+
"echo $u->email;\n", // L10
1527+
"$o = new Order();\n", // L11
1528+
"echo $o->email;\n", // L12
1529+
);
1530+
1531+
open_file(&backend, &uri, text).await;
1532+
1533+
// Click on "email" at line 10 ($u->email).
1534+
let locs = find_references(&backend, &uri, 10, 13, true).await;
1535+
1536+
// Should include User's @property and $u->email, but NOT Order's @property or $o->email.
1537+
let has_user_declaration = locs.iter().any(|l| l.range.start.line == 2);
1538+
let has_user_usage = locs.iter().any(|l| l.range.start.line == 10);
1539+
let has_order_declaration = locs.iter().any(|l| l.range.start.line == 6);
1540+
let has_order_usage = locs.iter().any(|l| l.range.start.line == 12);
1541+
1542+
assert!(
1543+
has_user_declaration,
1544+
"Should include User's @property declaration"
1545+
);
1546+
assert!(has_user_usage, "Should include $u->email usage");
1547+
assert!(
1548+
!has_order_declaration,
1549+
"Should NOT include Order's @property declaration"
1550+
);
1551+
assert!(!has_order_usage, "Should NOT include $o->email usage");
1552+
}
1553+
1554+
#[tokio::test]
1555+
async fn test_phpdoc_property_multiple_properties() {
1556+
let backend = Backend::new_test();
1557+
let uri = Url::parse("file:///test.php").unwrap();
1558+
let text = concat!(
1559+
"<?php\n", // L0
1560+
"/**\n", // L1
1561+
" * @property int $id\n", // L2
1562+
" * @property string $email\n", // L3
1563+
" * @property string $name\n", // L4
1564+
" */\n", // L5
1565+
"class User {}\n", // L6
1566+
"$u = new User();\n", // L7
1567+
"echo $u->email;\n", // L8
1568+
);
1569+
1570+
open_file(&backend, &uri, text).await;
1571+
1572+
// Click on "email" at line 8 ($u->email).
1573+
let locs = find_references(&backend, &uri, 8, 13, true).await;
1574+
assert!(
1575+
locs.len() >= 2,
1576+
"Expected at least 2 references to email, got {}",
1577+
locs.len()
1578+
);
1579+
1580+
// Should include only the @property string $email declaration (line 3), not id or name.
1581+
let has_email_decl = locs.iter().any(|l| l.range.start.line == 3);
1582+
let has_id_decl = locs.iter().any(|l| l.range.start.line == 2);
1583+
let has_name_decl = locs.iter().any(|l| l.range.start.line == 4);
1584+
assert!(
1585+
has_email_decl,
1586+
"Should include @property string $email declaration"
1587+
);
1588+
assert!(
1589+
!has_id_decl,
1590+
"Should NOT include @property int $id declaration"
1591+
);
1592+
assert!(
1593+
!has_name_decl,
1594+
"Should NOT include @property string $name declaration"
1595+
);
1596+
}
1597+
1598+
#[tokio::test]
1599+
async fn test_phpdoc_property_read_write_variants() {
1600+
let backend = Backend::new_test();
1601+
let uri = Url::parse("file:///test.php").unwrap();
1602+
let text = concat!(
1603+
"<?php\n", // L0
1604+
"/**\n", // L1
1605+
" * @property-read string $name\n", // L2
1606+
" * @property-write int $age\n", // L3
1607+
" */\n", // L4
1608+
"class User {}\n", // L5
1609+
"$u = new User();\n", // L6
1610+
"echo $u->name;\n", // L7
1611+
);
1612+
1613+
open_file(&backend, &uri, text).await;
1614+
1615+
// Click on "name" at line 7 ($u->name).
1616+
let locs = find_references(&backend, &uri, 7, 13, true).await;
1617+
assert!(
1618+
locs.len() >= 2,
1619+
"Expected at least 2 references to name (property-read declaration + usage), got {}",
1620+
locs.len()
1621+
);
1622+
1623+
// Should include the @property-read declaration (line 2).
1624+
let has_read_decl = locs.iter().any(|l| l.range.start.line == 2);
1625+
assert!(
1626+
has_read_decl,
1627+
"Should include the @property-read declaration"
1628+
);
1629+
}
1630+
1631+
#[tokio::test]
1632+
async fn test_phpdoc_method_references_from_declaration() {
1633+
let backend = Backend::new_test();
1634+
let uri = Url::parse("file:///test.php").unwrap();
1635+
let text = concat!(
1636+
"<?php\n", // L0
1637+
"/**\n", // L1
1638+
" * @method string getEmail()\n", // L2
1639+
" */\n", // L3
1640+
"class User {}\n", // L4
1641+
"$u = new User();\n", // L5
1642+
"echo $u->getEmail();\n", // L6
1643+
);
1644+
1645+
open_file(&backend, &uri, text).await;
1646+
1647+
// Click on "getEmail" in the @method tag (line 2).
1648+
// Line: " * @method string getEmail()"
1649+
// The MemberDeclaration span covers "getEmail" starting at char 19.
1650+
let locs = find_references(&backend, &uri, 2, 19, true).await;
1651+
assert!(
1652+
locs.len() >= 2,
1653+
"Expected at least 2 references from @method declaration, got {}",
1654+
locs.len()
1655+
);
1656+
1657+
let has_usage = locs.iter().any(|l| l.range.start.line == 6);
1658+
assert!(has_usage, "Should include $u->getEmail() usage on line 6");
1659+
}
1660+
1661+
#[tokio::test]
1662+
async fn test_phpdoc_method_no_return_type_references() {
1663+
let backend = Backend::new_test();
1664+
let uri = Url::parse("file:///test.php").unwrap();
1665+
let text = concat!(
1666+
"<?php\n", // L0
1667+
"/**\n", // L1
1668+
" * @method getEmail()\n", // L2
1669+
" */\n", // L3
1670+
"class User {}\n", // L4
1671+
"$u = new User();\n", // L5
1672+
"echo $u->getEmail();\n", // L6
1673+
);
1674+
1675+
open_file(&backend, &uri, text).await;
1676+
1677+
// Click on "getEmail" at line 6 ($u->getEmail()).
1678+
let locs = find_references(&backend, &uri, 6, 10, true).await;
1679+
assert!(
1680+
locs.len() >= 2,
1681+
"Expected at least 2 references to getEmail (declaration + usage), got {}",
1682+
locs.len()
1683+
);
1684+
1685+
// Should include the @method declaration (line 2).
1686+
let has_declaration = locs.iter().any(|l| l.range.start.line == 2);
1687+
assert!(
1688+
has_declaration,
1689+
"Should include the @method declaration on line 2"
1690+
);
1691+
}

0 commit comments

Comments
 (0)