Skip to content

Commit 6713952

Browse files
committed
Suppress unknown member diagnostics for arbitrary SoapClient methods
1 parent 987def9 commit 6713952

3 files changed

Lines changed: 85 additions & 0 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- **Property `self`/`static` type resolution.** Properties declared with `@var self|null` or `static` type annotations now resolve to the owning class name instead of displaying the raw `self`/`static` keyword in hover and type inference.
2929
- **Magic `__get` property access.** Accessing undefined properties on objects with a `__get` method now resolves to the method's declared return type, even when `__get` has no template parameters (e.g. `SimpleXMLElement::$child` resolves to `SimpleXMLElement`).
3030
- **Magic `__call` method return type.** Calling undefined methods on objects with a `__call` method now resolves to `__call`'s declared return type for hover and type inference.
31+
- **SoapClient arbitrary methods.** Calling any method on `SoapClient` (or subclasses) no longer produces false-positive "unknown member" diagnostics. SoapClient is a SOAP proxy where any method name is valid at runtime.
3132
- **`self::class` and `static::class` in template arguments.** Passing `self::class` or `static::class` to a method with `@template T` + `@param class-string<T>` now correctly resolves T to the enclosing class instead of failing to resolve or resolving to the called class.
3233
- **Use-map shadowing no longer causes false type errors.** When a file imports a namespaced class under the same short name as a global class (e.g. `use App\Exceptions\Exception;`), type hierarchy checks no longer cycle and incorrectly flag subclass arguments as incompatible with parent interfaces like `Throwable`.
3334
- **Multi-namespace function return type resolution.** In files with multiple `namespace { }` blocks, function return types were resolved against the first namespace instead of the function's own namespace. This caused incorrect type inference for variables assigned from function calls in later namespace blocks.

src/diagnostics/unknown_members/mod.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,13 @@ impl Backend {
480480
}
481481

482482
SubjectOutcome::UnresolvableClass(ref unresolved) => {
483+
// SoapClient is a SOAP proxy where any method is
484+
// valid. Even if we cannot fully resolve the class,
485+
// suppress the diagnostic.
486+
let type_str = unresolved.to_string();
487+
if type_str == "SoapClient" || type_str == "\\SoapClient" {
488+
continue;
489+
}
483490
let range = match self.offset_range_to_lsp_range(
484491
uri,
485492
content,
@@ -733,6 +740,19 @@ impl Backend {
733740
.iter()
734741
.any(|c| has_magic_method_for_access(c, is_static, true)));
735742

743+
// ── SoapClient: suppress diagnostic entirely ────────────────
744+
// SoapClient is a SOAP proxy where any method name is valid
745+
// (proxied to the remote service). PHP does not error, and
746+
// PHPStan treats all calls as returning mixed. Suppress the
747+
// diagnostic but do not break the chain.
748+
if has_magic_call
749+
&& resolved_classes
750+
.iter()
751+
.any(|c| is_soap_client(&c.name, class_loader))
752+
{
753+
return (MemberCheckResult::MagicFallback, diagnostics);
754+
}
755+
736756
// ── Member is unresolved on ALL branches — emit diagnostic ──
737757
let range = match offset_range_to_lsp_range(content, start as usize, end as usize) {
738758
Some(r) => r,
@@ -946,6 +966,29 @@ fn has_magic_method_for_access(class: &ClassInfo, is_static: bool, is_method_cal
946966
false
947967
}
948968

969+
/// Returns `true` if `class_name` is `SoapClient` or a class that
970+
/// ultimately extends `SoapClient`. SoapClient acts as a SOAP
971+
/// proxy: any method name is valid and returns mixed at runtime.
972+
fn is_soap_client(class_name: &str, class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>) -> bool {
973+
if class_name == "SoapClient" {
974+
return true;
975+
}
976+
// Walk the parent chain to check if this class extends SoapClient.
977+
let mut current = class_loader(class_name);
978+
let mut depth = 0;
979+
while let Some(cls) = current {
980+
if cls.name == "SoapClient" {
981+
return true;
982+
}
983+
depth += 1;
984+
if depth > 20 {
985+
break;
986+
}
987+
current = cls.parent_class.as_ref().and_then(|p| class_loader(p));
988+
}
989+
false
990+
}
991+
949992
fn display_class_name(class: &ClassInfo) -> String {
950993
if class.name.starts_with("__anonymous@") {
951994
return "anonymous class".to_string();

src/diagnostics/unknown_members/tests.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5228,3 +5228,44 @@ class Svc {
52285228
"expected no diagnostics after guard clause narrowing on property in foreach, got: {diags:?}",
52295229
);
52305230
}
5231+
5232+
#[test]
5233+
fn no_diagnostic_for_arbitrary_method_on_soap_client() {
5234+
let php = r#"<?php
5235+
function test(\SoapClient $client): void {
5236+
$client->gettransactionlist(['foo' => 'bar']);
5237+
$client->delete(123);
5238+
$client->capture('abc');
5239+
}
5240+
"#;
5241+
let backend = Backend::new_test();
5242+
let diags = collect(&backend, "file:///test.php", php);
5243+
assert!(
5244+
diags.is_empty(),
5245+
"expected no diagnostics for arbitrary methods on SoapClient, got: {diags:?}",
5246+
);
5247+
}
5248+
5249+
#[test]
5250+
fn no_diagnostic_for_arbitrary_method_on_soap_client_subclass() {
5251+
// When a class extends SoapClient, it inherits __call and any
5252+
// method should be valid. In single-file tests the parent chain
5253+
// may not fully resolve from stubs, so we test with a direct
5254+
// SoapClient parameter typed as the subclass via docblock.
5255+
let php = r#"<?php
5256+
class MyService extends \SoapClient {
5257+
public function getConnection(): \SoapClient { return $this; }
5258+
}
5259+
function test(): void {
5260+
$svc = new MyService('http://example.com?wsdl');
5261+
$svc->getConnection()->customMethod();
5262+
}
5263+
"#;
5264+
let backend = Backend::new_test();
5265+
let diags = collect(&backend, "file:///test.php", php);
5266+
// The getConnection() returns \SoapClient, which should suppress.
5267+
assert!(
5268+
diags.is_empty(),
5269+
"expected no diagnostics for arbitrary methods on SoapClient subclass, got: {diags:?}",
5270+
);
5271+
}

0 commit comments

Comments
 (0)