Skip to content

Commit a35f780

Browse files
committed
Add report-magic-properties config option and diagnostic support
1 parent bafba5a commit a35f780

5 files changed

Lines changed: 85 additions & 8 deletions

File tree

config-schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
"type": "boolean",
3030
"description": "Report calls that pass more arguments than the function accepts. PHP silently ignores extra arguments to user-defined functions, and many libraries exploit this for flexible APIs.",
3131
"default": false
32+
},
33+
"report-magic-properties": {
34+
"type": "boolean",
35+
"description": "Report unknown property access on classes with __get when virtual properties are defined (via @property docblock tags, Laravel Eloquent column inference, or other providers). Matches PHPStan's reportMagicProperties behaviour.",
36+
"default": false
3237
}
3338
}
3439
},

docs/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- **`global` keyword variable resolution.** Variables imported with `global $var` now resolve to their top-level type, enabling completion, hover, and go-to-definition.
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.
41+
- **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.
4142

4243
### Changed
4344

@@ -52,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5253

5354
### Fixed
5455

55-
- **Blade diagnostics in `analyze` command.** Blade template files now receive full diagnostics (unknown members, unknown classes, unused variables, etc.) when running `phpantom_lsp analyze`. Previously, the analyzer passed raw Blade template content to diagnostic collectors instead of the preprocessed virtual PHP, so no diagnostics were produced.
56+
- **Blade diagnostics in `analyze` command.** Blade template files now receive full diagnostics (unknown members, unknown classes, unused variables, etc.) when running `phpantom_lsp analyze`.
5657
- **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.
5758
- **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.
5859
- **Duplicate `use function` insertion.** Accepting a function completion no longer inserts a `use function` statement when the exact import already exists in the file.

examples/laravel/.phpantom.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[diagnostics]
2+
unresolved-member-access = true
3+
report-magic-properties = true

src/config.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ pub struct DiagnosticsConfig {
6767
/// you want stricter checking.
6868
#[serde(rename = "extra-arguments")]
6969
pub extra_arguments: Option<bool>,
70+
71+
/// Report property access on classes with `__get` when virtual
72+
/// properties are defined.
73+
///
74+
/// Off by default. When enabled, classes that have `__get` but
75+
/// also declare virtual properties (via `@property` docblock tags,
76+
/// Laravel Eloquent column inference, or any other virtual member
77+
/// provider) will flag unknown property access instead of
78+
/// suppressing it. This matches PHPStan's `reportMagicProperties`
79+
/// behaviour.
80+
#[serde(rename = "report-magic-properties")]
81+
pub report_magic_properties: Option<bool>,
7082
}
7183

7284
impl DiagnosticsConfig {
@@ -83,6 +95,13 @@ impl DiagnosticsConfig {
8395
pub fn extra_arguments_enabled(&self) -> bool {
8496
self.extra_arguments.unwrap_or(false)
8597
}
98+
99+
/// Whether magic property reporting is enabled.
100+
///
101+
/// Defaults to `false` (off) when not explicitly set.
102+
pub fn report_magic_properties_enabled(&self) -> bool {
103+
self.report_magic_properties.unwrap_or(false)
104+
}
86105
}
87106

88107
/// `[formatting]` section — controls the formatting strategy.
@@ -526,6 +545,7 @@ mod tests {
526545
assert!(config.php.version.is_none());
527546
assert!(!config.diagnostics.unresolved_member_access_enabled());
528547
assert!(!config.diagnostics.extra_arguments_enabled());
548+
assert!(!config.diagnostics.report_magic_properties_enabled());
529549
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
530550
assert!(config.formatting.php_cs_fixer.is_none());
531551
assert!(config.formatting.phpcbf.is_none());
@@ -554,6 +574,7 @@ mod tests {
554574
assert!(config.php.version.is_none());
555575
assert!(!config.diagnostics.unresolved_member_access_enabled());
556576
assert!(!config.diagnostics.extra_arguments_enabled());
577+
assert!(!config.diagnostics.report_magic_properties_enabled());
557578
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
558579
assert!(config.formatting.php_cs_fixer.is_none());
559580
assert!(config.formatting.phpcbf.is_none());
@@ -571,6 +592,7 @@ mod tests {
571592
assert!(config.php.version.is_none());
572593
assert!(!config.diagnostics.unresolved_member_access_enabled());
573594
assert!(!config.diagnostics.extra_arguments_enabled());
595+
assert!(!config.diagnostics.report_magic_properties_enabled());
574596
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
575597
assert!(config.formatting.php_cs_fixer.is_none());
576598
assert!(config.formatting.phpcbf.is_none());
@@ -606,6 +628,24 @@ mod tests {
606628
assert!(!config.diagnostics.unresolved_member_access_enabled());
607629
}
608630

631+
#[test]
632+
fn parses_report_magic_properties() {
633+
let dir = tempfile::tempdir().unwrap();
634+
let path = dir.path().join(CONFIG_FILE_NAME);
635+
std::fs::write(&path, "[diagnostics]\nreport-magic-properties = true\n").unwrap();
636+
let config = load_config(dir.path()).unwrap();
637+
assert!(config.diagnostics.report_magic_properties_enabled());
638+
}
639+
640+
#[test]
641+
fn report_magic_properties_defaults_to_false() {
642+
let dir = tempfile::tempdir().unwrap();
643+
let path = dir.path().join(CONFIG_FILE_NAME);
644+
std::fs::write(&path, "[diagnostics]\n").unwrap();
645+
let config = load_config(dir.path()).unwrap();
646+
assert!(!config.diagnostics.report_magic_properties_enabled());
647+
}
648+
609649
#[test]
610650
fn extra_arguments_defaults_to_false() {
611651
let dir = tempfile::tempdir().unwrap();
@@ -722,6 +762,7 @@ version = "8.2"
722762
[diagnostics]
723763
unresolved-member-access = true
724764
extra-arguments = true
765+
report-magic-properties = true
725766
726767
[indexing]
727768
strategy = "self"
@@ -752,6 +793,7 @@ analyze-timeout = 45000
752793
assert_eq!(config.php.version.as_deref(), Some("8.2"));
753794
assert!(config.diagnostics.unresolved_member_access_enabled());
754795
assert!(config.diagnostics.extra_arguments_enabled());
796+
assert!(config.diagnostics.report_magic_properties_enabled());
755797
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::SelfScan));
756798
assert_eq!(config.formatting.php_cs_fixer.as_deref(), Some(""));
757799
assert_eq!(

src/diagnostics/unknown_members/mod.rs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,8 @@ impl Backend {
656656
end: u32,
657657
) -> (MemberCheckResult, Vec<Diagnostic>) {
658658
let mut diagnostics = Vec::new();
659+
let report_magic = self.config().diagnostics.report_magic_properties_enabled();
660+
659661
// ── Quick check on pre-resolved base classes ────────────────
660662
// `resolve_target_classes` already returns fully-resolved
661663
// classes in many code paths (e.g. `type_hint_to_classes_typed`
@@ -675,7 +677,7 @@ impl Backend {
675677
if !is_method_call
676678
&& base_classes
677679
.iter()
678-
.any(|c| has_magic_method_for_access(c, is_static, false))
680+
.any(|c| has_magic_method_for_access(c, is_static, false, report_magic))
679681
{
680682
return (MemberCheckResult::Ok, diagnostics);
681683
}
@@ -709,7 +711,7 @@ impl Backend {
709711
if !is_method_call
710712
&& resolved_classes
711713
.iter()
712-
.any(|c| has_magic_method_for_access(c, is_static, false))
714+
.any(|c| has_magic_method_for_access(c, is_static, false, report_magic))
713715
{
714716
return (MemberCheckResult::Ok, diagnostics);
715717
}
@@ -737,10 +739,10 @@ impl Backend {
737739
let has_magic_call = is_method_call
738740
&& (base_classes
739741
.iter()
740-
.any(|c| has_magic_method_for_access(c, is_static, true))
742+
.any(|c| has_magic_method_for_access(c, is_static, true, report_magic))
741743
|| resolved_classes
742744
.iter()
743-
.any(|c| has_magic_method_for_access(c, is_static, true)));
745+
.any(|c| has_magic_method_for_access(c, is_static, true, report_magic)));
744746

745747
// ── SoapClient: suppress diagnostic entirely ────────────────
746748
// SoapClient is a SOAP proxy where any method name is valid
@@ -949,7 +951,18 @@ fn member_exists(
949951
/// Check whether the class has a magic method that would handle the
950952
/// member access at runtime, making the "unknown member" diagnostic
951953
/// a false positive.
952-
fn has_magic_method_for_access(class: &ClassInfo, is_static: bool, is_method_call: bool) -> bool {
954+
///
955+
/// For property access, `__get` only suppresses the diagnostic when
956+
/// the class has no `@property` annotations. When `@property` tags
957+
/// exist, they define the expected property surface and unknown
958+
/// properties should be flagged (matching PHPStan's behaviour with
959+
/// `reportMagicProperties: true`).
960+
fn has_magic_method_for_access(
961+
class: &ClassInfo,
962+
is_static: bool,
963+
is_method_call: bool,
964+
report_magic_properties: bool,
965+
) -> bool {
953966
if is_method_call {
954967
let magic = if is_static { "__callStatic" } else { "__call" };
955968
return class
@@ -959,11 +972,24 @@ fn has_magic_method_for_access(class: &ClassInfo, is_static: bool, is_method_cal
959972
}
960973

961974
if !is_static {
962-
// Instance property access — `__get` handles arbitrary property names.
963-
return class
975+
// Instance property access — `__get` handles arbitrary property
976+
// names. When `report_magic_properties` is enabled and any
977+
// virtual member provider has added properties to the class
978+
// (@property docblock tags, Laravel Eloquent column inference,
979+
// etc.), do not suppress — let normal member checking flag
980+
// unknowns. When disabled (the default), `__get` always
981+
// suppresses.
982+
let has_get = class
964983
.methods
965984
.iter()
966985
.any(|m| m.name.eq_ignore_ascii_case("__get"));
986+
if has_get {
987+
if report_magic_properties {
988+
let has_virtual_properties = class.properties.iter().any(|p| p.is_virtual);
989+
return !has_virtual_properties;
990+
}
991+
return true;
992+
}
967993
}
968994

969995
false

0 commit comments

Comments
 (0)