use std::collections::HashMap;
use std::sync::Arc;
use super::unresolved_member_access::UNRESOLVED_MEMBER_ACCESS_CODE;
use crate::parser::with_parse_cache;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::completion::resolver::{ResolutionCtx, SubjectOutcome, resolve_subject_outcome};
use crate::symbol_map::SymbolKind;
use crate::types::{AccessKind, ClassInfo, ClassLikeKind};
use crate::virtual_members::resolve_class_fully_cached;
use super::helpers::{find_innermost_enclosing_class, make_diagnostic};
use super::offset_range_to_lsp_range;
pub(crate) const UNKNOWN_MEMBER_CODE: &str = "unknown_member";
pub(crate) const SCALAR_MEMBER_ACCESS_CODE: &str = "scalar_member_access";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MemberCheckResult {
Ok,
Break,
MagicFallback,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum ScopeKey {
Class {
name: String,
start_offset: u32,
fn_scope_start: u32,
},
TopLevel { fn_scope_start: u32 },
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct SubjectCacheKey {
subject_text: String,
access_kind: AccessKind,
scope: ScopeKey,
var_def_offset: u32,
narrowing_offset: u32,
assert_offset: u32,
access_offset: u32,
}
type SubjectCache = HashMap<SubjectCacheKey, SubjectOutcome>;
fn subject_text_is_rooted_in_self(subject_text: &str) -> bool {
if matches!(subject_text, "$this" | "self" | "static" | "parent") {
return true;
}
if subject_text.starts_with("$this->") || subject_text.starts_with("$this?->") {
return true;
}
if subject_text.starts_with("self::")
|| subject_text.starts_with("static::")
|| subject_text.starts_with("parent::")
{
return true;
}
false
}
fn scope_key_for(current_class: Option<&ClassInfo>, fn_scope_start: u32) -> ScopeKey {
match current_class {
Some(cc) => ScopeKey::Class {
name: cc.name.clone(),
start_offset: cc.start_offset,
fn_scope_start,
},
None => ScopeKey::TopLevel { fn_scope_start },
}
}
impl Backend {
pub fn collect_unknown_member_diagnostics(
&self,
uri: &str,
content: &str,
out: &mut Vec<Diagnostic>,
) {
let symbol_map = {
let maps = self.symbol_maps.read();
match maps.get(uri) {
Some(sm) => sm.clone(),
None => return,
}
};
let file_use_map: HashMap<String, String> = self.file_use_map(uri);
let file_namespace: Option<String> = self.namespace_map.read().get(uri).cloned().flatten();
let local_classes: Vec<Arc<ClassInfo>> =
self.ast_map.read().get(uri).cloned().unwrap_or_default();
let class_loader = self.class_loader_with(&local_classes, &file_use_map, &file_namespace);
let function_loader = self.function_loader_with(&file_use_map, &file_namespace);
let resolved_cache = &self.resolved_class_cache;
let _parse_guard = with_parse_cache(content);
let _subj_guard = crate::completion::resolver::with_diagnostic_subject_cache();
crate::completion::resolver::set_diagnostic_subject_cache_scopes(
symbol_map.scopes.clone(),
symbol_map.var_defs.clone(),
symbol_map.narrowing_blocks.clone(),
symbol_map.assert_narrowing_offsets.clone(),
);
let mut subject_cache: SubjectCache = HashMap::new();
let mut broken_chain_prefixes: Vec<String> = Vec::new();
for span in &symbol_map.spans {
let (subject_text, member_name, is_static, is_method_call, is_docblock_ref) =
match &span.kind {
SymbolKind::MemberAccess {
subject_text,
member_name,
is_static,
is_method_call,
is_docblock_reference,
} => (
subject_text,
member_name,
*is_static,
*is_method_call,
*is_docblock_reference,
),
_ => continue,
};
if member_name == "class" && is_static {
continue;
}
let access_kind = if is_static {
AccessKind::DoubleColon
} else {
AccessKind::Arrow
};
let current_class = find_innermost_enclosing_class(&local_classes, span.start);
if let Some(cc) = current_class
&& cc.kind == ClassLikeKind::Trait
&& subject_text_is_rooted_in_self(subject_text)
{
continue;
}
let fn_scope_start = symbol_map.find_enclosing_scope(span.start);
let var_def_offset =
if subject_text.starts_with('$') && !subject_text.starts_with("$this") {
let var_name = subject_text
.find("->")
.map(|i| &subject_text[..i])
.unwrap_or(subject_text);
symbol_map.active_var_def_offset(
&var_name[1..], span.start,
)
} else {
0
};
let narrowing_offset =
if subject_text.starts_with('$') && !subject_text.starts_with("$this") {
symbol_map.find_narrowing_block(span.start)
} else {
0
};
let assert_offset =
if subject_text.starts_with('$') && !subject_text.starts_with("$this") {
symbol_map.find_preceding_assert_offset(span.start)
} else {
0
};
let access_offset =
if subject_text.starts_with('$') && !subject_text.starts_with("$this") {
span.start
} else {
0
};
let cache_key = SubjectCacheKey {
subject_text: subject_text.clone(),
access_kind,
scope: scope_key_for(current_class, fn_scope_start),
var_def_offset,
narrowing_offset,
assert_offset,
access_offset,
};
let outcome = subject_cache
.entry(cache_key)
.or_insert_with(|| {
let rctx = ResolutionCtx {
current_class,
all_classes: &local_classes,
content,
cursor_offset: span.start,
class_loader: &class_loader,
resolved_class_cache: Some(resolved_cache),
function_loader: Some(&function_loader),
};
resolve_subject_outcome(subject_text, access_kind, &rctx)
})
.clone();
if is_downstream_of_broken_chain(subject_text, &broken_chain_prefixes) {
continue;
}
match outcome {
SubjectOutcome::Scalar(ref scalar) => {
let range = match offset_range_to_lsp_range(
content,
span.start as usize,
span.end as usize,
) {
Some(r) => r,
None => continue,
};
let kind_label = if is_method_call { "method" } else { "property" };
let message = format!(
"Cannot access {} '{}' on type '{}'",
kind_label, member_name, scalar,
);
out.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
SCALAR_MEMBER_ACCESS_CODE,
message,
));
broken_chain_prefixes.push(broken_chain_prefix(
subject_text,
member_name,
is_static,
is_method_call,
));
}
SubjectOutcome::UnresolvableClass(ref unresolved) => {
let range = match offset_range_to_lsp_range(
content,
span.start as usize,
span.end as usize,
) {
Some(r) => r,
None => continue,
};
let kind_label = if is_method_call { "method" } else { "property" };
let message = format!(
"Cannot verify {} '{}' — subject type '{}' could not be resolved",
kind_label, member_name, unresolved,
);
out.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
UNKNOWN_MEMBER_CODE,
message,
));
broken_chain_prefixes.push(broken_chain_prefix(
subject_text,
member_name,
is_static,
is_method_call,
));
}
SubjectOutcome::Untyped => {
if self.config().diagnostics.unresolved_member_access_enabled() {
let range = match offset_range_to_lsp_range(
content,
span.start as usize,
span.end as usize,
) {
Some(r) => r,
None => continue,
};
let subject_display = subject_text.trim();
let kind_label = if is_method_call { "method" } else { "property" };
let message = format!(
"Cannot verify {} '{}' — type of '{}' could not be resolved",
kind_label, member_name, subject_display,
);
out.push(make_diagnostic(
range,
DiagnosticSeverity::HINT,
UNRESOLVED_MEMBER_ACCESS_CODE,
message,
));
broken_chain_prefixes.push(broken_chain_prefix(
subject_text,
member_name,
is_static,
is_method_call,
));
}
}
SubjectOutcome::Resolved(ref base_classes) => {
let result = self.check_member_on_resolved_classes(
base_classes,
member_name,
is_static,
is_method_call,
is_docblock_ref,
&class_loader,
resolved_cache,
content,
span.start,
span.end,
out,
);
if result == MemberCheckResult::Break {
broken_chain_prefixes.push(broken_chain_prefix(
subject_text,
member_name,
is_static,
is_method_call,
));
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn check_member_on_resolved_classes(
&self,
base_classes: &[Arc<ClassInfo>],
member_name: &str,
is_static: bool,
is_method_call: bool,
is_docblock_ref: bool,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
cache: &crate::virtual_members::ResolvedClassCache,
content: &str,
start: u32,
end: u32,
out: &mut Vec<Diagnostic>,
) -> MemberCheckResult {
if !is_method_call
&& base_classes
.iter()
.any(|c| has_magic_method_for_access(c, is_static, false))
{
return MemberCheckResult::Ok;
}
if base_classes.iter().any(|c| c.name == "stdClass") {
return MemberCheckResult::Ok;
}
if base_classes.iter().any(|c| {
member_exists(c, member_name, is_static, is_method_call)
|| (is_docblock_ref && member_exists_relaxed(c, member_name, is_method_call))
}) {
return MemberCheckResult::Ok;
}
let resolved_classes: Vec<Arc<ClassInfo>> = base_classes
.iter()
.map(|c| {
if c.name == "__object_shape" {
Arc::clone(c)
} else {
resolve_class_fully_cached(c, class_loader, cache)
}
})
.collect();
if !is_method_call
&& resolved_classes
.iter()
.any(|c| has_magic_method_for_access(c, is_static, false))
{
return MemberCheckResult::Ok;
}
if resolved_classes.iter().any(|c| c.name == "stdClass") {
return MemberCheckResult::Ok;
}
if resolved_classes.iter().any(|c| {
member_exists(c, member_name, is_static, is_method_call)
|| (is_docblock_ref && member_exists_relaxed(c, member_name, is_method_call))
}) {
return MemberCheckResult::Ok;
}
let has_magic_call = is_method_call
&& (base_classes
.iter()
.any(|c| has_magic_method_for_access(c, is_static, true))
|| resolved_classes
.iter()
.any(|c| has_magic_method_for_access(c, is_static, true)));
let range = match offset_range_to_lsp_range(content, start as usize, end as usize) {
Some(r) => r,
None => return MemberCheckResult::Ok,
};
let kind_label = if is_method_call {
"Method"
} else if is_static {
"Member"
} else {
"Property"
};
let class_display = display_class_name(&resolved_classes[0]);
let message = if resolved_classes.len() > 1 {
format!(
"{} '{}' not found on any of the {} possible types ({})",
kind_label,
member_name,
resolved_classes.len(),
resolved_classes
.iter()
.map(|c| display_class_name(c))
.collect::<Vec<_>>()
.join(", "),
)
} else {
format!(
"{} '{}' not found on class '{}'",
kind_label, member_name, class_display,
)
};
out.push(make_diagnostic(
range,
DiagnosticSeverity::WARNING,
UNKNOWN_MEMBER_CODE,
message,
));
if has_magic_call {
MemberCheckResult::MagicFallback
} else {
MemberCheckResult::Break
}
}
}
fn broken_chain_prefix(
subject_text: &str,
member_name: &str,
is_static: bool,
is_method_call: bool,
) -> String {
let normalized = subject_text.replace("?->", "->");
let operator = if is_static { "::" } else { "->" };
if is_method_call {
format!("{}{}{}{}", normalized, operator, member_name, "(")
} else {
format!("{}{}{}", normalized, operator, member_name)
}
}
fn is_downstream_of_broken_chain(subject_text: &str, broken_prefixes: &[String]) -> bool {
if broken_prefixes.is_empty() {
return false;
}
let normalized = subject_text.replace("?->", "->");
broken_prefixes.iter().any(|prefix| {
if prefix.ends_with('(') {
normalized.starts_with(prefix.as_str())
} else {
if normalized == *prefix {
return true;
}
if !normalized.starts_with(prefix.as_str()) {
return false;
}
let rest = &normalized[prefix.len()..];
rest.starts_with("->") || rest.starts_with("::") || rest.starts_with('[')
}
})
}
fn member_exists_relaxed(class: &ClassInfo, member_name: &str, _is_method_call: bool) -> bool {
let lower = member_name.to_ascii_lowercase();
if class
.methods
.iter()
.any(|m| m.name.to_ascii_lowercase() == lower)
{
return true;
}
if class.properties.iter().any(|p| p.name == member_name) {
return true;
}
class.constants.iter().any(|c| c.name == member_name)
}
fn member_exists(
class: &ClassInfo,
member_name: &str,
is_static: bool,
is_method_call: bool,
) -> bool {
if is_method_call {
let lower = member_name.to_ascii_lowercase();
return class
.methods
.iter()
.any(|m| m.name.to_ascii_lowercase() == lower);
}
if is_static {
if class.constants.iter().any(|c| c.name == member_name) {
return true;
}
if class.properties.iter().any(|p| {
p.is_static && (p.name == member_name || format!("${}", p.name) == member_name)
}) {
return true;
}
return false;
}
class.properties.iter().any(|p| p.name == member_name)
}
fn has_magic_method_for_access(class: &ClassInfo, is_static: bool, is_method_call: bool) -> bool {
if is_method_call {
let magic = if is_static { "__callStatic" } else { "__call" };
return class
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(magic));
}
if !is_static {
return class
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case("__get"));
}
false
}
fn display_class_name(class: &ClassInfo) -> String {
if class.name.starts_with("__anonymous@") {
return "anonymous class".to_string();
}
class.fqn()
}
#[cfg(test)]
mod tests {
use super::*;
fn collect(backend: &Backend, uri: &str, content: &str) -> Vec<Diagnostic> {
backend.update_ast(uri, content);
let mut out = Vec::new();
backend.collect_unknown_member_diagnostics(uri, content, &mut out);
out
}
#[test]
fn flags_unknown_method_on_known_class() {
let php = r#"<?php
class Greeter {
public function hello(): string { return ''; }
}
function test(): void {
$g = new Greeter();
$g->nonexistent();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| {
d.message.contains("nonexistent")
&& d.message.contains("Greeter")
&& d.message.contains("Method")
}),
"expected diagnostic for nonexistent method, got: {diags:?}"
);
}
#[test]
fn flags_unknown_property_on_known_class() {
let php = r#"<?php
class User {
public string $name;
}
function test(): void {
$u = new User();
$u->missing;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| {
d.message.contains("missing")
&& d.message.contains("User")
&& d.message.contains("Property")
}),
"expected diagnostic for missing property, got: {diags:?}"
);
}
#[test]
fn flags_unknown_static_method() {
let php = r#"<?php
class MathHelper {
public static function add(): int { return 0; }
}
MathHelper::nonexistent();
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("nonexistent") && d.message.contains("MathHelper")),
"expected diagnostic for nonexistent static method, got: {diags:?}"
);
}
#[test]
fn flags_unknown_constant_on_class() {
let php = r#"<?php
class Config {
const VERSION = '1.0';
}
echo Config::MISSING;
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("MISSING") && d.message.contains("Config")),
"expected diagnostic for missing constant, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_existing_method() {
let php = r#"<?php
class Greeter {
public function hello(): string { return ''; }
public function goodbye(): string { return ''; }
}
function test(): void {
$g = new Greeter();
$g->hello();
$g->goodbye();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_existing_property() {
let php = r#"<?php
class User {
public string $name;
public int $age;
}
function test(): void {
$u = new User();
echo $u->name;
echo $u->age;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_existing_constant() {
let php = r#"<?php
class Config {
const VERSION = '1.0';
}
echo Config::VERSION;
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_class_keyword() {
let php = r#"<?php
class Foo {}
echo Foo::class;
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn diagnostic_when_class_has_magic_call_but_chain_continues() {
let php = r#"<?php
class Dynamic {
public function __call(string $name, array $args): mixed { return null; }
}
function test(): void {
$d = new Dynamic();
$d->anything();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"Should flag unknown method even when __call exists, got: {diags:?}"
);
assert!(
diags[0].message.contains("anything"),
"Diagnostic should mention 'anything', got: {}",
diags[0].message
);
}
#[test]
fn no_diagnostic_when_class_has_magic_get() {
let php = r#"<?php
class Dynamic {
public function __get(string $name): mixed { return null; }
}
function test(): void {
$d = new Dynamic();
echo $d->anything;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn diagnostic_when_class_has_magic_call_static_but_chain_continues() {
let php = r#"<?php
class Dynamic {
public static function __callStatic(string $name, array $args): mixed { return null; }
}
Dynamic::anything();
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"Should flag unknown static method even when __callStatic exists, got: {diags:?}"
);
assert!(
diags[0].message.contains("anything"),
"Diagnostic should mention 'anything', got: {}",
diags[0].message
);
}
#[test]
fn no_diagnostic_for_inherited_method() {
let php = r#"<?php
class Base {
public function baseMethod(): void {}
}
class Child extends Base {}
function test(): void {
$c = new Child();
$c->baseMethod();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_trait_method() {
let php = r#"<?php
trait Greetable {
public function greet(): string { return ''; }
}
class Person {
use Greetable;
}
function test(): void {
$p = new Person();
$p->greet();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_this_member_access_inside_trait() {
let php = r#"<?php
trait LogsErrors {
public function logError(): void {
$this->model;
$this->eventType;
}
}
class ImportJob {
use LogsErrors;
public string $model = 'Product';
public string $eventType = 'import';
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for $this-> inside trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_this_method_call_inside_trait() {
let php = r#"<?php
trait Cacheable {
public function cache(): void {
$this->getCacheKey();
}
}
class Product {
use Cacheable;
public function getCacheKey(): string { return ''; }
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for $this->method() inside trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_self_static_inside_trait() {
let php = r#"<?php
trait HasDefaults {
public static function create(): void {
self::DEFAULT_NAME;
static::factory();
}
}
class User {
use HasDefaults;
const DEFAULT_NAME = 'admin';
public static function factory(): void {}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for self::/static:: inside trait, got: {diags:?}"
);
}
#[test]
fn trait_own_members_still_resolve_on_host_class() {
let php = r#"<?php
trait Greetable {
public function greet(): string { return ''; }
}
class Person {
use Greetable;
}
function test(): void {
$p = new Person();
$p->greet();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for trait member on host class, got: {diags:?}"
);
}
#[test]
fn variable_inside_trait_still_diagnosed() {
let php = r#"<?php
class Foo {
public function bar(): void {}
}
trait MyTrait {
public function doStuff(Foo $x): void {
$x->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("nonexistent") && d.message.contains("Foo")),
"expected diagnostic for unknown method on typed variable inside trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_phpdoc_method() {
let php = r#"<?php
/**
* @method string virtualMethod()
*/
class Magic {}
function test(): void {
$m = new Magic();
$m->virtualMethod();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_phpdoc_property() {
let php = r#"<?php
/**
* @property string $virtualProp
*/
class Magic {
public function __get(string $name): mixed { return null; }
}
function test(): void {
$m = new Magic();
echo $m->virtualProp;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_method_on_this() {
let php = r#"<?php
class Foo {
public function bar(): void {
$this->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("nonexistent") && d.message.contains("Foo")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_this_in_second_class() {
let php = r#"<?php
class First {
public function a(): void {}
}
class Second {
public function b(): void {
$this->b();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_object_shape_property() {
let php = r#"<?php
class Factory {
/**
* @return object{name: string, age: int}
*/
public function create(): object {
return (object)['name' => 'test', 'age' => 1];
}
}
class Consumer {
public function test(): void {
$factory = new Factory();
$obj = $factory->create();
echo $obj->name;
echo $obj->age;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_property_on_object_shape() {
let php = r#"<?php
class Factory {
/**
* @return object{name: string, age: int}
*/
public function create(): object {
return (object)['name' => 'test', 'age' => 1];
}
}
class Consumer {
public function test(): void {
$obj = (new Factory())->create();
echo $obj->missing;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("missing")),
"expected diagnostic for missing property on object shape, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_this_in_anonymous_class() {
let php = r#"<?php
class Outer {
public function make(): void {
$anon = new class {
public function inner(): void {}
public function test(): void {
$this->inner();
}
};
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_method_on_this_in_anonymous_class() {
let php = r#"<?php
class Outer {
public function make(): void {
$anon = new class {
public function inner(): void {}
public function test(): void {
$this->missing();
}
};
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("missing")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_parent_in_anonymous_class() {
let php = r#"<?php
class Base {
public function baseMethod(): void {}
}
class Outer {
public function make(): void {
$anon = new class extends Base {
public function test(): void {
parent::baseMethod();
}
};
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_method_on_this_in_second_class() {
let php = r#"<?php
class First {
public function a(): void {}
}
class Second {
public function b(): void {
$this->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("nonexistent") && d.message.contains("Second")),
"expected diagnostic for Second, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_this_existing_method() {
let php = r#"<?php
class Foo {
public function bar(): void {
$this->bar();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_method_on_self() {
let php = r#"<?php
class Foo {
public function bar(): void {
self::nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("nonexistent") && d.message.contains("Foo")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_self_existing_method() {
let php = r#"<?php
class Foo {
public static function bar(): void {
self::bar();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_parent_existing_method() {
let php = r#"<?php
class Base {
public function base(): void {}
}
class Child extends Base {
public function test(): void {
parent::base();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn diagnostic_has_warning_severity() {
let php = r#"<?php
class Foo { }
function test(): void {
$f = new Foo();
$f->missing();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(!diags.is_empty());
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
}
#[test]
fn diagnostic_has_code_and_source() {
let php = r#"<?php
class Foo { }
function test(): void {
$f = new Foo();
$f->missing();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(!diags.is_empty());
match &diags[0].code {
Some(NumberOrString::String(code)) => {
assert_eq!(code, UNKNOWN_MEMBER_CODE);
}
other => panic!("expected string code, got: {other:?}"),
}
assert_eq!(diags[0].source, Some("phpantom".to_string()));
}
#[test]
fn method_matching_is_case_insensitive() {
let php = r#"<?php
class Foo {
public function hello(): void {}
}
function test(): void {
$f = new Foo();
$f->HELLO();
$f->Hello();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_multiple_unknown_members() {
let php = r#"<?php
class Foo {
public function real(): void {}
}
function test(): void {
$f = new Foo();
$f->missing1();
$f->real();
$f->missing2();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
2,
"expected 2 diagnostics, got {}: {diags:?}",
diags.len()
);
}
#[test]
fn no_diagnostic_when_subject_unresolvable() {
let php = r#"<?php
function test(): void {
$x->something();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for unresolvable subject, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_enum_case() {
let php = r#"<?php
enum Color {
case Red;
case Green;
case Blue;
}
echo Color::Red;
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_enum_case() {
let php = r#"<?php
enum Color {
case Red;
case Green;
case Blue;
}
echo Color::Yellow;
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("Yellow")),
"expected diagnostic for unknown enum case, got: {diags:?}"
);
}
#[test]
fn flags_unknown_method_via_parameter() {
let php = r#"<?php
class Service {
public function run(): void {}
}
function handler(Service $svc): void {
$svc->nonexistent();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("nonexistent") && d.message.contains("Service")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_method_via_parameter() {
let php = r#"<?php
class Service {
public function run(): void {}
}
function handler(Service $svc): void {
$svc->run();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn diagnostic_when_parent_has_magic_call_but_chain_continues() {
let php = r#"<?php
class Base {
public function __call(string $name, array $args): mixed { return null; }
}
class Child extends Base {}
function test(): void {
$c = new Child();
$c->anything();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"Should flag unknown method even when parent has __call, got: {diags:?}"
);
assert!(
diags[0].message.contains("anything"),
"Diagnostic should mention 'anything', got: {}",
diags[0].message
);
}
#[test]
fn no_diagnostic_for_interface_method() {
let php = r#"<?php
interface Runnable {
public function run(): void;
}
class Worker implements Runnable {
public function run(): void {}
}
function handler(Runnable $r): void {
$r->run();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_existing_static_property() {
let php = r#"<?php
class Config {
public static string $version = '1.0';
}
echo Config::$version;
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_member_on_any_union_branch() {
let php = r#"<?php
class Cat {
public function purr(): void {}
public function eat(): void {}
}
class Dog {
public function bark(): void {}
public function eat(): void {}
}
class Shelter {
/**
* @return Cat|Dog
*/
public function adopt(): Cat|Dog {
return new Cat();
}
}
class Test {
public function run(): void {
$shelter = new Shelter();
$pet = $shelter->adopt();
$pet->eat();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_member_missing_from_all_union_branches() {
let php = r#"<?php
class Cat {
public function purr(): void {}
}
class Dog {
public function bark(): void {}
}
class Shelter {
/**
* @return Cat|Dog
*/
public function adopt(): Cat|Dog {
return new Cat();
}
}
class Test {
public function run(): void {
$shelter = new Shelter();
$pet = $shelter->adopt();
$pet->fly();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("fly")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn union_diagnostic_message_mentions_multiple_types() {
let php = r#"<?php
class Cat {
public function purr(): void {}
}
class Dog {
public function bark(): void {}
}
class Shelter {
/**
* @return Cat|Dog
*/
public function adopt(): Cat|Dog {
return new Cat();
}
}
class Test {
public function run(): void {
$shelter = new Shelter();
$pet = $shelter->adopt();
$pet->fly();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let d = diags
.iter()
.find(|d| d.message.contains("fly"))
.expect("expected diagnostic");
assert!(
d.message.contains("Cat") && d.message.contains("Dog"),
"expected both types in message: {}",
d.message
);
}
#[test]
fn diagnostic_when_any_union_branch_has_magic_call_but_chain_continues() {
let php = r#"<?php
class Normal {
public function known(): void {}
}
class Dynamic {
public function __call(string $name, array $args): mixed { return null; }
}
class Test {
/**
* @return Normal|Dynamic
*/
public function get(): Normal|Dynamic { return new Normal(); }
public function run(): void {
$x = $this->get();
$x->anything();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"Should flag unknown method even when a union branch has __call, got: {diags:?}"
);
assert!(
diags[0].message.contains("anything"),
"Diagnostic should mention 'anything', got: {}",
diags[0].message
);
}
#[test]
fn no_diagnostic_for_property_on_stdclass() {
let php = r#"<?php
function test(stdClass $obj): void {
echo $obj->anything;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_method_on_stdclass() {
let php = r#"<?php
function test(stdClass $obj): void {
$obj->anything();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_stdclass_in_union() {
let php = r#"<?php
class Foo { public function a(): void {} }
/**
* @return Foo|stdClass
*/
function get(): Foo|stdClass { return new Foo(); }
function test(): void {
$x = get();
$x->anything;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_stdclass_parameter() {
let php = r#"<?php
function test(stdClass $obj): void {
echo $obj->name;
echo $obj->whatever;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_phpdoc_property_on_child_class() {
let php = r#"<?php
/**
* @property string $virtualProp
*/
class Base {
public function __get(string $name): mixed { return null; }
}
class Child extends Base {}
function test(): void {
$c = new Child();
echo $c->virtualProp;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_phpdoc_property_from_interface() {
let php = r#"<?php
/**
* @property string $name
*/
interface HasName {}
class User implements HasName {
public function __get(string $n): mixed { return null; }
}
function test(): void {
$u = new User();
echo $u->name;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_phpdoc_members_inside_assert() {
let php = r#"<?php
/**
* @method string getName()
*/
class Entity {
public function __call(string $name, array $args): mixed { return null; }
}
class Base {}
class Test {
public function run(Base $item): void {
assert($item instanceof Entity);
echo $item->getName();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_fqn_assert_instanceof() {
let php = r#"<?php
/**
* @method string getName()
*/
class Entity {
public function __call(string $name, array $args): mixed { return null; }
}
class Base {}
class Test {
public function run(Base $item): void {
\assert($item instanceof Entity);
echo $item->getName();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for FQN \\assert instanceof narrowing, got: {diags:?}",
);
}
#[test]
fn no_diagnostic_for_fqn_assert_with_interleaved_array_access() {
let php = r#"<?php
class FormError {
public function getMessage(): string { return ''; }
}
class FormChild {
public function getName(): string { return ''; }
}
/** @var \Iterator<int, mixed> */
$errorIterator = new \ArrayIterator([]);
/** @var FormChild $child */
$child = new FormChild();
/** @var array<string, list<string>> */
$errors = [];
foreach ($errorIterator as $error) {
\assert(
$error instanceof FormError,
'Error is not a FormError!',
);
$errors[$child->getName()][] = $error->getMessage();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for FQN \\assert with interleaved array access, got: {diags:?}",
);
}
#[test]
fn no_diagnostic_for_phpdoc_members_after_instanceof_narrowing() {
let php = r#"<?php
/**
* @method string getName()
*/
class Entity {
public function __call(string $name, array $args): mixed { return null; }
}
class Base {}
class Test {
public function run(Base $item): void {
if ($item instanceof Entity) {
echo $item->getName();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_instanceof_and_chain() {
let php = r#"<?php
class QueryException extends \Exception {
public array $errorInfo = [];
}
function test(\Throwable $e): void {
$e instanceof QueryException && $e->errorInfo;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for && narrowing, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_instanceof_and_chain_in_catch() {
let php = r#"<?php
class QueryException extends \Exception {
public array $errorInfo = [];
}
function test(): void {
try {
throw new \Exception('fail');
} catch (\Throwable $e) {
$e instanceof QueryException && $e->errorInfo;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for && narrowing in catch, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_instanceof_and_chain_method_call() {
let php = r#"<?php
class SpecialException extends \Exception {
public function getDetail(): string { return ''; }
}
function test(\Throwable $e): void {
$e instanceof SpecialException && $e->getDetail();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for && narrowing with method call, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_instanceof_and_chain_in_if_condition() {
let php = r#"<?php
class QueryException extends \Exception {
public array $errorInfo = [];
}
function test(\Throwable $e): void {
if ($e instanceof QueryException && count($e->errorInfo) > 0) {
echo 'has errors';
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for && narrowing in if condition, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_instanceof_and_chain_in_return() {
let php = r#"<?php
class QueryException extends \Exception {
public array $errorInfo = [];
}
trait UniqueConstraintViolation {
protected function isUniqueConstraintViolation(\Throwable $exception): bool {
return $exception instanceof QueryException
&& is_array($exception->errorInfo)
&& count($exception->errorInfo) >= 2
&& ($exception->errorInfo[0] ?? '') === '23000'
&& ($exception->errorInfo[1] ?? 0) === 1062;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for && narrowing in return, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_ternary_instanceof_in_return() {
let php = r#"<?php
class SpecialException extends \Exception {
public function getDetail(): string { return ''; }
}
function test(\Throwable $e): string {
return $e instanceof SpecialException ? $e->getDetail() : 'unknown';
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for ternary instanceof in return, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_chained_and_instanceof() {
let php = r#"<?php
class DetailedException extends \Exception {
public string $detail = '';
public string $context = '';
}
function test(\Throwable $e): void {
$e instanceof DetailedException && $e->detail !== '' && $e->context !== '';
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for chained && narrowing, got: {diags:?}"
);
}
#[test]
fn flags_unknown_member_on_property_chain() {
let php = r#"<?php
class Inner {
public function known(): void {}
}
class Outer {
public Inner $inner;
}
class Test {
public function run(): void {
$o = new Outer();
$o->inner->missing();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("missing")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_valid_property_chain() {
let php = r#"<?php
class Inner {
public function known(): void {}
}
class Outer {
public Inner $inner;
}
class Test {
public function run(): void {
$o = new Outer();
$o->inner->known();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_member_on_method_return_chain() {
let php = r#"<?php
class Inner {
public function known(): void {}
}
class Outer {
public function getInner(): Inner { return new Inner(); }
}
function test(): void {
$o = new Outer();
$o->getInner()->missing();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("missing")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_valid_method_return_chain() {
let php = r#"<?php
class Inner {
public function known(): void {}
}
class Outer {
public function getInner(): Inner { return new Inner(); }
}
function test(): void {
$o = new Outer();
$o->getInner()->known();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_member_on_virtual_property_chain() {
let php = r#"<?php
class Inner {
public function known(): void {}
}
/**
* @property Inner $inner
*/
class Outer {
public function __get(string $name): mixed { return null; }
}
function test(): void {
$o = new Outer();
$o->inner->missing();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("missing")),
"expected diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_scalar_property_type() {
let php = r#"<?php
class Foo {
public int $value = 0;
}
class Test {
public function run(): void {
$foo = new Foo();
$foo->value->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
assert!(
diags
.iter()
.any(|d| d.severity == Some(DiagnosticSeverity::ERROR)),
"expected ERROR severity for scalar access"
);
}
#[test]
fn flags_member_access_on_string_property_type() {
let php = r#"<?php
class Foo {
public string $name = '';
}
class Test {
public function run(): void {
$foo = new Foo();
$foo->name->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("string") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_scalar_method_return() {
let php = r#"<?php
class Foo {
public function getCount(): int { return 0; }
}
class Test {
public function run(): void {
$foo = new Foo();
$foo->getCount()->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_method_call_on_scalar_method_return_chain() {
let php = r#"<?php
class Inner {
public function getValue(): string { return ''; }
}
class Middle {
public function getInner(): Inner { return new Inner(); }
}
class Outer {
public function getMiddle(): Middle { return new Middle(); }
}
class Test {
public function run(): void {
$o = new Outer();
$o->getMiddle()->getInner()->getValue()->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| { d.message.contains("string") && d.message.contains("nonexistent") }),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_method_call_on_scalar_return_typed_param() {
let php = r#"<?php
class Foo {
public function getCount(): int { return 0; }
}
function test(Foo $foo): void {
$foo->getCount()->nonexistent();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_scalar_access_on_static_method_chain() {
let php = r#"<?php
class Foo {
public static function getCount(): int { return 0; }
}
class Test {
public function run(): void {
Foo::getCount()->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_scalar_access_on_function_return_chain() {
let php = r#"<?php
function getNumber(): int { return 42; }
function test(): void {
getNumber()->nonexistent();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_scalar_access_on_docblock_return_type() {
let php = r#"<?php
class Foo {
/**
* @return string
*/
public function getName() { return ''; }
}
class Test {
public function run(): void {
$foo = new Foo();
$foo->getName()->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| { d.message.contains("string") && d.message.contains("nonexistent") }),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_scalar_access_on_static_return_chain() {
let php = r#"<?php
class Foo {
public function getName(): string { return ''; }
}
class Test {
public function run(): void {
$foo = new Foo();
$foo->getName()->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| { d.message.contains("string") && d.message.contains("nonexistent") }),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn no_scalar_diagnostic_for_class_returning_chain() {
let php = r#"<?php
class Builder {
public function where(): self { return $this; }
public function get(): self { return $this; }
}
function test(): void {
$b = new Builder();
$b->where()->get();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no scalar access diagnostic for class-returning chain, got: {diags:?}"
);
}
#[test]
fn flags_scalar_access_on_function_returning_class_chain() {
let php = r#"<?php
class Foo {
public function getName(): string { return ''; }
}
function createFoo(): Foo { return new Foo(); }
function test(): void {
createFoo()->getName()->nonexistent();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| { d.message.contains("string") && d.message.contains("nonexistent") }),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_scalar_access_on_array_element_method_chain() {
let php = r#"<?php
class Item {
public function getLabel(): string { return ''; }
}
function test(): void {
/** @var array<int, Item> $items */
$items = [];
$items[0]->getLabel()->nonexistent();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| { d.message.contains("string") && d.message.contains("nonexistent") }),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_scalar_access_on_deeper_method_chain() {
let php = r#"<?php
class Inner {
public function getValue(): int { return 42; }
}
class Outer {
public function getInner(): Inner { return new Inner(); }
}
class Test {
public function run(): void {
$o = new Outer();
$o->getInner()->getValue()->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_scalar_property_access_on_deeper_method_chain() {
let php = r#"<?php
class Inner {
public string $label = '';
}
class Outer {
public function getInner(): Inner { return new Inner(); }
}
class Test {
public function run(): void {
$o = new Outer();
$o->getInner()->label->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| { d.message.contains("string") && d.message.contains("nonexistent") }),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_virtual_scalar_property() {
let php = r#"<?php
/**
* @property int $age
* @property string $name
*/
class User {
public function __get(string $name): mixed { return null; }
}
class Test {
public function run(): void {
$u = new User();
$u->age->nonexistent();
$u->name->nonexistent2();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic for int property, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_scalar_property_access_itself() {
let php = r#"<?php
class Foo {
public int $count = 0;
}
function test(): void {
$f = new Foo();
echo $f->count;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"scalar property access itself should not be flagged, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_bare_int_variable() {
let php = r#"<?php
class Foo {
public function getCount(): int { return 0; }
}
class Test {
public function run(): void {
$foo = new Foo();
$number = $foo->getCount();
$number->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic for bare int variable, got: {diags:?}"
);
}
#[test]
fn flags_property_access_on_bare_string_variable() {
let php = r#"<?php
class Foo {
public function getName(): string { return ''; }
}
class Test {
public function run(): void {
$foo = new Foo();
$name = $foo->getName();
$name->nonexistent;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| { d.message.contains("string") && d.message.contains("nonexistent") }),
"expected scalar access diagnostic for bare string variable, got: {diags:?}"
);
}
#[test]
fn flags_method_access_on_bare_bool_variable() {
let php = r#"<?php
class Foo {
public function isValid(): bool { return true; }
}
class Test {
public function run(): void {
$foo = new Foo();
$valid = $foo->isValid();
$valid->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("bool") && d.message.contains("nonexistent")),
"expected scalar access diagnostic for bare bool variable, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_scalar_function_return() {
let php = r#"<?php
function getNumber(): int { return 42; }
class Test {
public function run(): void {
$n = getNumber();
$n->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic for function return, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_scalar_method_return_via_variable() {
let php = r#"<?php
class Foo {
public function getCount(): int { return 0; }
}
class Test {
public function run(): void {
$foo = new Foo();
$count = $foo->getCount();
$count->nonexistent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_bare_scalar_variable_without_member_access() {
let php = r#"<?php
function test(): void {
$n = 42;
echo $n;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"bare scalar variable without member access should not produce diagnostic, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_scalar_typed_parameter() {
let php = r#"<?php
function test(int $value): void {
$value->nonexistent();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("int") && d.message.contains("nonexistent")),
"expected scalar access diagnostic for typed parameter, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_unknown_class_parameter() {
let php = r#"<?php
function test(NonExistentClass $obj): void {
$obj->doSomething();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| {
d.message.contains("doSomething") && d.message.contains("NonExistentClass")
}),
"expected diagnostic for unknown class parameter, got: {diags:?}"
);
}
#[test]
fn flags_member_access_on_unknown_return_type_function() {
let php = r#"<?php
/** @return NonExistentClass */
function createObj() { return new stdClass; }
function test(): void {
$obj = createObj();
$obj->doSomething();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
!diags.is_empty(),
"expected diagnostic for unknown return type, got: {diags:?}"
);
}
#[test]
fn no_unknown_class_diagnostic_for_mixed_parameter() {
let php = r#"<?php
function test(mixed $obj): void {
$obj->doSomething();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for mixed parameter, got: {diags:?}"
);
}
#[test]
fn no_unknown_class_diagnostic_for_class_string_parameter() {
let php = r#"<?php
/**
* @param class-string<BackedEnum> $enum
*/
function test(string $enum): void {
$enum::from('test');
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for class-string parameter, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_type_alias_array_shape_object_value() {
let php = r#"<?php
class Service {
public function getName(): string { return ''; }
}
class Factory {
/**
* @return array{service: Service, name: string}
*/
public function create(): array { return []; }
}
class Test {
public function run(): void {
$f = new Factory();
$result = $f->create();
$result['service']->getName();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for array shape object value, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_multiple_type_alias_object_values() {
let php = r#"<?php
class UserService {
public function findAll(): array { return []; }
}
class PostService {
public function findRecent(): array { return []; }
}
class Container {
/**
* @return array{users: UserService, posts: PostService}
*/
public function services(): array { return []; }
}
class Test {
public function run(): void {
$c = new Container();
$services = $c->services();
$services['users']->findAll();
$services['posts']->findRecent();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for multiple array shape values, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_inline_array_element_function_call() {
let php = r#"<?php
class Item {
public function process(): void {}
}
function getItems(): array {
/** @var Item[] */
return [];
}
function test(): void {
getItems()[0]->process();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for inline array element call, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_when_member_exists_on_pre_resolved_base_class() {
let php = r#"<?php
class Builder {
public function where(): self { return $this; }
public function get(): array { return []; }
}
function test(): void {
$b = new Builder();
$b->where();
$b->get();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for existing methods, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_see_tag_method_reference() {
let php = r#"<?php
class Foo {
public function bar(): void {}
/**
* @see Foo::bar()
*/
public function test(): void {}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for @see tag method reference, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_see_tag_constant_reference() {
let php = r#"<?php
class Foo {
const BAR = 1;
/**
* @see Foo::BAR
*/
public function test(): void {}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for @see tag constant reference, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_inline_see_tag_method_reference() {
let php = r#"<?php
class Foo {
public function bar(): void {}
/**
* This delegates to {@see Foo::bar()}.
*/
public function test(): void {}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostic for inline @see reference, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_namespaced_stub_class_member() {
let stubs = HashMap::from([(
"Ns\\StubClass",
r#"<?php
namespace Ns;
class StubClass {
public function stubMethod(): void {}
}
"#,
)]);
let backend = Backend::new_test_with_stubs(stubs);
let php = r#"<?php
use Ns\StubClass;
function test(StubClass $obj): void {
$obj->stubMethod();
}
"#;
let uri = "file:///test.php";
backend.update_ast(uri, php);
let mut out = Vec::new();
backend.collect_unknown_member_diagnostics(uri, php, &mut out);
assert!(
out.is_empty(),
"expected no diagnostic for namespaced stub class member, got: {out:?}"
);
}
#[test]
fn no_false_positive_on_conditional_this_return_in_chain() {
let php = r#"<?php
class Builder {
/**
* @return $this
*/
public function where(): static { return $this; }
public function get(): array { return []; }
}
class Test {
public function run(): void {
$b = new Builder();
$b->where()->get();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no false positive on conditional $this return chain, got: {diags:?}"
);
}
#[test]
fn no_false_positive_when_same_var_has_different_type_in_different_methods() {
let php = r#"<?php
class OrderA {
public function propOnA(): void {}
}
class OrderB {
public function propOnB(): void {}
}
class Service {
public function handleA(OrderA $order): void {
$order->propOnA();
}
public function handleB(OrderB $order): void {
$order->propOnB();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no false positives when same-named variable has different types \
in different methods, got: {diags:?}"
);
}
#[test]
fn no_false_positive_same_var_different_type_top_level_functions() {
let php = r#"<?php
class Alpha {
public function alphaMethod(): void {}
}
class Beta {
public function betaMethod(): void {}
}
function first(Alpha $x): void {
$x->alphaMethod();
}
function second(Beta $x): void {
$x->betaMethod();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no false positives for same-named variable in different \
top-level functions, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_this_inside_closure_in_trait() {
let php = r#"<?php
trait SalesInfoGlobalTrait {
public function getSalesInfo(): void {
$items = array_map(function ($item) {
$this->model;
$this->eventType;
static::where();
static::query();
}, []);
}
}
class SalesReport {
use SalesInfoGlobalTrait;
public string $model = 'Sale';
public string $eventType = 'report';
public static function where(): void {}
public static function query(): void {}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for $this/static:: inside closure in trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_this_inside_arrow_fn_in_trait() {
let php = r#"<?php
trait FilterTrait {
public function applyFilter(): void {
$fn = fn() => $this->filterColumn;
}
}
class Report {
use FilterTrait;
public string $filterColumn = 'status';
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for $this-> inside arrow fn in trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_chain_rooted_at_static_inside_trait() {
let php = r#"<?php
trait SalesInfoGlobalTrait {
public function updateSalesInfo(): void {
static::where('column', 'value')->update(['sales' => 1]);
}
}
class SalesReport extends \Illuminate\Database\Eloquent\Model {
use SalesInfoGlobalTrait;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for static::...->method() chain inside trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_chain_rooted_at_this_inside_trait() {
let php = r#"<?php
trait HasRelation {
public function loadRelation(): void {
$this->items()->first();
}
}
class Order {
use HasRelation;
/** @return \Illuminate\Database\Eloquent\Builder */
public function items(): object { return new \stdClass(); }
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for $this->...->method() chain inside trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_chain_rooted_at_static_inside_closure_in_trait() {
let php = r#"<?php
trait SalesInfoGlobalTrait {
public function updateSalesInfo(): void {
$items = array_map(function ($item) {
static::where('col', 'val')->update(['x' => 1]);
}, []);
}
}
class SalesReport extends \Illuminate\Database\Eloquent\Model {
use SalesInfoGlobalTrait;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for static:: chain inside closure in trait, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_self_chain_inside_trait() {
let php = r#"<?php
trait Creatable {
public function duplicate(): void {
self::create(['name' => 'copy'])->save();
}
}
class Product extends \Illuminate\Database\Eloquent\Model {
use Creatable;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for self::...->method() chain inside trait, got: {diags:?}"
);
}
#[test]
fn variable_chain_inside_trait_still_diagnosed() {
let php = r#"<?php
trait BadTrait {
public function doStuff(): void {
$obj = new \stdClass();
$obj->nonExistentMethod();
}
}
"#;
let backend = Backend::new_test();
let _diags = collect(&backend, "file:///test.php", php);
}
#[test]
fn flags_unknown_member_despite_valid_in_other_method() {
let php = r#"<?php
class HasFoo {
public function foo(): void {}
}
class NoFoo {
public function bar(): void {}
}
class Service {
public function a(HasFoo $x): void {
$x->foo();
}
public function b(NoFoo $x): void {
$x->foo();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("foo") && d.message.contains("NoFoo")),
"expected diagnostic for foo() on NoFoo in method b(), got: {diags:?}"
);
let foo_diags: Vec<_> = diags.iter().filter(|d| d.message.contains("foo")).collect();
assert_eq!(
foo_diags.len(),
1,
"expected exactly one 'foo' diagnostic (in method b), got: {foo_diags:?}"
);
}
#[test]
fn no_false_positive_when_parameter_is_reassigned() {
let php = r#"<?php
class UploadedFile {
public string $originalName;
}
class FileModel {
public int $id;
public string $name;
}
class Result {
public function getFile(): FileModel { return new FileModel(); }
}
class FileUploadService {
public function uploadFile(UploadedFile $file): void {
$file->originalName;
$result = new Result();
$file = $result->getFile();
$file->id;
$file->name;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no false positives when parameter is reassigned mid-body, got: {diags:?}"
);
}
#[test]
fn flags_unknown_member_after_reassignment() {
let php = r#"<?php
class TypeA {
public function onlyOnA(): void {}
}
class TypeB {
public function onlyOnB(): void {}
}
class Service {
public function process(TypeA $var): void {
$var->onlyOnA();
$var = new TypeB();
$var->onlyOnA();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("onlyOnA") && d.message.contains("TypeB")),
"expected diagnostic for onlyOnA() on TypeB after reassignment, got: {diags:?}"
);
let relevant: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("onlyOnA"))
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one 'onlyOnA' diagnostic (after reassignment), got: {relevant:?}"
);
}
#[test]
fn no_false_positive_null_init_foreach_var_to_var_reassign() {
let php = r#"<?php
class Pen {
public function write(): void {}
public function color(): string { return ''; }
}
class Svc {
/** @param list<Pen> $pens */
public function find(array $pens): void {
$found = null;
foreach ($pens as $pen) {
if ($pen->color() === 'blue') {
$found = $pen;
}
}
if ($found) {
$found->write();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let scalar_diags: Vec<_> = diags
.iter()
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
.collect();
assert!(
scalar_diags.is_empty(),
"should not flag scalar_member_access on $found->write() after foreach reassign, got: {scalar_diags:?}"
);
let unknown_diags: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("write"))
.collect();
assert!(
unknown_diags.is_empty(),
"should not flag unknown member 'write' on $found after foreach reassign, got: {unknown_diags:?}"
);
}
#[test]
fn no_false_positive_null_init_foreach_direct_reassign() {
let php = r#"<?php
class Transaction {
public function commit(): void {}
}
class Svc {
/** @param list<string> $items */
public function process(array $items): void {
$tx = null;
foreach ($items as $item) {
$tx = new Transaction();
}
if ($tx) {
$tx->commit();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let bad_diags: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("commit") || d.message.contains("null"))
.collect();
assert!(
bad_diags.is_empty(),
"should not flag commit() or scalar null after foreach reassign, got: {bad_diags:?}"
);
}
#[test]
fn no_false_positive_after_guard_clause_excludes_type() {
let php = r#"<?php
interface Stringable {
public function __toString(): string;
}
interface BackedEnum {
public readonly int|string $value;
}
class Svc {
public static function toString(mixed $value): string
{
if ($value instanceof Stringable) {
return $value->__toString();
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
return '';
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let bad = diags
.iter()
.filter(|d| d.message.contains("value") && d.message.contains("Stringable"))
.collect::<Vec<_>>();
assert!(
bad.is_empty(),
"should not flag 'value' on Stringable after guard clause excludes it, got: {bad:?}"
);
}
#[test]
fn no_false_positive_sequential_instanceof_guards() {
let php = r#"<?php
interface Alpha {
public function alphaMethod(): void;
}
interface Beta {
public function betaMethod(): void;
}
class Gamma {
public function gammaMethod(): void {}
}
class Svc {
public function test(Alpha|Beta|Gamma $x): void
{
if ($x instanceof Alpha) {
return;
}
if ($x instanceof Beta) {
return;
}
$x->gammaMethod();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let bad = diags
.iter()
.filter(|d| {
d.message.contains("gammaMethod")
&& (d.message.contains("Alpha") || d.message.contains("Beta"))
})
.collect::<Vec<_>>();
assert!(
bad.is_empty(),
"should not flag gammaMethod after two guard clauses exclude Alpha and Beta, got: {bad:?}"
);
}
fn create_enum_backend() -> Backend {
let mut stubs = std::collections::HashMap::new();
stubs.insert(
"UnitEnum",
"<?php\ninterface UnitEnum {\n /** @return static[] */\n public static function cases(): array;\n public readonly string $name;\n}\n",
);
stubs.insert(
"BackedEnum",
"<?php\ninterface BackedEnum extends UnitEnum {\n public static function from(int|string $value): static;\n public static function tryFrom(int|string $value): ?static;\n public readonly int|string $value;\n}\n",
);
Backend::new_test_with_stubs(stubs)
}
#[test]
fn no_diagnostic_for_self_enum_case_value() {
let php = r#"<?php
enum SizeUnit: string {
case pcs = 'pcs';
case pair = 'pair';
case g = 'g';
public function translation(): string {
return self::pcs->value;
}
public static function units(): array {
return [
self::pcs->value,
self::pair->value,
self::g->value,
];
}
}
"#;
let backend = create_enum_backend();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_static_enum_case_value() {
let php = r#"<?php
enum Currency: string {
case USD = 'usd';
case EUR = 'eur';
public static function defaults(): array {
return [static::USD->value];
}
}
"#;
let backend = create_enum_backend();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_self_enum_case_name() {
let php = r#"<?php
enum Color: int {
case Red = 1;
case Blue = 2;
public function label(): string {
return self::Red->name;
}
}
"#;
let backend = create_enum_backend();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_self_static_access_on_regular_class() {
let php = r#"<?php
class Config {
public const VERSION = '1.0';
public static function version(): string { return self::VERSION; }
public function test(): string {
return static::version();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_method_on_anonymous_class_variable() {
let php = r#"<?php
class Base {
public function hello(): string { return "hi"; }
}
function test(): void {
$model = new class extends Base {};
$model->hello();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn no_diagnostic_for_trait_method_on_anonymous_class_variable() {
let php = r#"<?php
trait Greetable {
public function greet(): string { return "hello"; }
}
function test(): void {
$obj = new class {
use Greetable;
};
$obj->greet();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn flags_unknown_method_on_anonymous_class_variable() {
let php = r#"<?php
function test(): void {
$obj = new class {
public function known(): void {}
};
$obj->unknown();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("unknown")),
"expected unknown member diagnostic, got: {diags:?}",
);
}
#[test]
fn no_diagnostic_for_standalone_var_docblock_in_closure() {
let php = r#"<?php
class App {
public function make(string $class): mixed { return new $class; }
}
class Foo {
public function test(): void {
$fn = function ($app, $params) {
/**
* @var App $app
* @var array{indexName: string} $params
*/
$app->make('Something');
};
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics when @var declares closure param type, got: {diags:?}",
);
}
#[test]
fn flags_unknown_member_with_standalone_var_docblock_in_closure() {
let php = r#"<?php
class App {
public function make(string $class): mixed { return new $class; }
}
class Foo {
public function test(): void {
$fn = function ($app) {
/** @var App $app */
$app->nonExistentMethod();
};
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("nonExistentMethod")),
"expected unknown member diagnostic for nonExistentMethod, got: {diags:?}",
);
}
#[test]
fn no_diagnostic_for_property_chain_array_access_on_collection() {
let php = r#"<?php
class Day {
public string $from;
public string $to;
}
/**
* @template TKey of array-key
* @template TValue
* @implements \ArrayAccess<TKey, TValue>
*/
class DataCollection implements \ArrayAccess {
/** @return TValue */
public function offsetGet(mixed $offset): mixed {}
public function offsetExists(mixed $offset): bool {}
public function offsetSet(mixed $offset, mixed $value): void {}
public function offsetUnset(mixed $offset): void {}
}
/**
* @extends DataCollection<string, Day>
*/
class OpeningHours extends DataCollection {}
class ServicePoint {
public ?OpeningHours $opening_hours;
}
function test(ServicePoint $sp): void {
$day = $sp->opening_hours['monday'] ?? null;
if ($day !== null) {
$day->from;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for property chain array access on collection, got: {diags:?}",
);
}
#[test]
fn no_diagnostic_for_parent_static_call_return_type() {
let php = r#"<?php
class Response {
public function status(): int { return 200; }
public function body(): string { return ''; }
}
class BaseConnector {
protected function call(string $endpoint): Response
{
return new Response();
}
}
class LoggedConnection extends BaseConnector {
protected function call(string $endpoint): Response
{
$response = parent::call($endpoint);
$response->status();
$response->body();
return $response;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for parent::call() return type chain, got: {diags:?}",
);
}
#[test]
fn chain_propagation_flags_only_first_broken_method() {
let php = r#"<?php
class Machine {
public function knownMethod(): self { return $this; }
}
function test(): void {
$m = new Machine();
$m->callHome()->callMom()->callDad();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (first broken link only), got: {diags:?}"
);
assert!(
diags[0].message.contains("callHome"),
"expected diagnostic for callHome, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_separate_statements_flag_both() {
let php = r#"<?php
class Machine {
public function knownMethod(): self { return $this; }
}
function test(): void {
$m = new Machine();
$m->callHome();
$m->callMom();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
2,
"expected 2 diagnostics (separate statements), got: {diags:?}"
);
let messages: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
assert!(
messages.iter().any(|m| m.contains("callHome")),
"expected callHome diagnostic"
);
assert!(
messages.iter().any(|m| m.contains("callMom")),
"expected callMom diagnostic"
);
}
#[test]
fn chain_propagation_scalar_suppresses_downstream() {
let php = r#"<?php
class User {
public function getAge(): int { return 30; }
}
function test(): void {
$user = new User();
$user->getAge()->value->deep;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (scalar access only), got: {diags:?}"
);
assert!(
diags[0].message.contains("int"),
"expected scalar type 'int' in message, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_second_link_broken_suppresses_rest() {
let php = r#"<?php
class Inner {
public function known(): void {}
}
class Outer {
public function getInner(): Inner { return new Inner(); }
}
function test(): void {
$o = new Outer();
$o->getInner()->fakeMethod()->next()->deep();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (first broken link), got: {diags:?}"
);
assert!(
diags[0].message.contains("fakeMethod"),
"expected diagnostic for fakeMethod, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_scalar_method_return_suppresses_chain() {
let php = r#"<?php
class Inner {
public function getValue(): string { return ''; }
}
class Middle {
public function getInner(): Inner { return new Inner(); }
}
class Outer {
public function getMiddle(): Middle { return new Middle(); }
}
class Test {
public function run(): void {
$o = new Outer();
$o->getMiddle()->getInner()->getValue()->nonexistent()->another();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (scalar access), got: {diags:?}"
);
assert!(
diags[0].message.contains("nonexistent"),
"expected diagnostic for nonexistent, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_property_does_not_match_longer_name() {
let php = r#"<?php
class Foo {
public int $value = 0;
public string $value_extra = '';
}
function test(): void {
$f = new Foo();
$f->value->nope;
$f->value_extra->nope;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
2,
"expected 2 diagnostics (value and value_extra are independent), got: {diags:?}"
);
}
#[test]
fn chain_propagation_static_method_chain() {
let php = r#"<?php
class Foo {
public static function create(): self { return new self(); }
public function known(): self { return $this; }
}
function test(): void {
Foo::create()->unknown()->next();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (first broken link), got: {diags:?}"
);
assert!(
diags[0].message.contains("unknown"),
"expected diagnostic for unknown, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_null_safe_operator() {
let php = r#"<?php
class Machine {
public function knownMethod(): self { return $this; }
}
function test(?Machine $m): void {
$m?->callHome()?->callMom();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (null-safe chain), got: {diags:?}"
);
assert!(
diags[0].message.contains("callHome"),
"expected diagnostic for callHome, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_this_method_chain() {
let php = r#"<?php
class Foo {
public function test(): void {
$this->unknownMethod()->next()->deep();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic ($this chain), got: {diags:?}"
);
assert!(
diags[0].message.contains("unknownMethod"),
"expected diagnostic for unknownMethod, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_property_chain_suppresses_downstream() {
let php = r#"<?php
class Inner {
public string $label = '';
}
class Outer {
public function getInner(): Inner { return new Inner(); }
}
class Test {
public function run(): void {
$o = new Outer();
$o->getInner()->label->nonexistent->deep;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (scalar property access), got: {diags:?}"
);
assert!(
diags[0].message.contains("nonexistent") || diags[0].message.contains("string"),
"expected diagnostic about scalar access on string, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_mixed_arrow_and_static_chain() {
let php = r#"<?php
class Inner {
public function known(): void {}
}
class Outer {
public function getInner(): Inner { return new Inner(); }
}
function test(): void {
$o = new Outer();
$o->getInner()::staticMissing()->next();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"expected exactly 1 diagnostic (first broken static link), got: {diags:?}"
);
assert!(
diags[0].message.contains("staticMissing"),
"expected diagnostic for staticMissing, got: {:?}",
diags[0].message
);
}
#[test]
fn chain_propagation_does_not_suppress_errors_inside_closure_arguments() {
let php = r#"<?php
class Joe {
public function where(callable $cb): self { return $this; }
}
class ShowThisError {
public function valid(): void {}
}
function test(): void {
$joe = new Joe();
$showThisError = new ShowThisError();
$joe::whereInvalid()->where(fn() => $showThisError->unknown())->hideMe()->hideMe();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let messages: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
assert!(
messages.iter().any(|m| m.contains("whereInvalid")),
"expected diagnostic for whereInvalid (outer chain), got: {messages:?}"
);
assert!(
messages.iter().any(|m| m.contains("unknown")),
"expected diagnostic for unknown (inside closure), got: {messages:?}"
);
assert!(
!messages.iter().any(|m| m.contains("hideMe")),
"hideMe should be suppressed (downstream of whereInvalid), got: {messages:?}"
);
assert_eq!(
diags.len(),
2,
"expected exactly 2 diagnostics (whereInvalid + unknown), got: {messages:?}"
);
}
#[test]
fn no_false_positive_and_short_circuit_null_narrowing() {
let php = r#"<?php
class Carbon {
public function diffInDays(Carbon $other): int { return 0; }
public function startOfDay(): static { return $this; }
}
class Period {
public Carbon $ending;
}
class Svc {
/** @param list<Period> $periods */
public function gaps(array $periods): void {
$lastPaidEnd = null;
$periodStart = new Carbon();
foreach ($periods as $period) {
if ($lastPaidEnd !== null && $lastPaidEnd->diffInDays($periodStart) > 0) {
// should not report: Cannot access method 'diffInDays' on type 'null'
}
$lastPaidEnd = $period->ending->startOfDay();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let scalar_diags: Vec<_> = diags
.iter()
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
.collect();
assert!(
scalar_diags.is_empty(),
"should not flag scalar_member_access on $lastPaidEnd->diffInDays() after !== null guard in &&, got: {scalar_diags:?}"
);
}
#[test]
fn no_false_positive_and_short_circuit_truthy_narrowing() {
let php = r#"<?php
class Logger {
public function log(string $msg): void {}
}
class Svc {
public function run(): void {
$logger = null;
if (rand(0,1)) {
$logger = new Logger();
}
$logger && $logger->log('hello');
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let scalar_diags: Vec<_> = diags
.iter()
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
.collect();
assert!(
scalar_diags.is_empty(),
"should not flag scalar_member_access on $logger->log() after truthy guard in &&, got: {scalar_diags:?}"
);
}
#[test]
fn no_false_positive_chained_and_null_narrowing() {
let php = r#"<?php
class Foo {
public function bar(): int { return 0; }
}
class Svc {
public function test(): void {
$a = null;
$b = null;
if (rand(0,1)) { $a = new Foo(); }
if (rand(0,1)) { $b = new Foo(); }
if ($a !== null && $b !== null && $a->bar() > 0) {
// both $a and $b are non-null here
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let scalar_diags: Vec<_> = diags
.iter()
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
.collect();
assert!(
scalar_diags.is_empty(),
"should not flag scalar_member_access on $a->bar() in chained && with null guards, got: {scalar_diags:?}"
);
}
#[test]
fn no_false_positive_if_body_triple_null_narrowing() {
let php = r#"<?php
class Foo {
public function bar(): int { return 0; }
public function baz(): static { return $this; }
}
class Svc {
public function test(): void {
$x = null;
$y = null;
$z = null;
if (rand(0,1)) { $x = new Foo(); }
if (rand(0,1)) { $y = new Foo(); }
if (rand(0,1)) { $z = new Foo(); }
if ($x !== null && $y !== null && $z !== null && $x->baz()->bar() > 0) {
$z->bar();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let scalar_diags: Vec<_> = diags
.iter()
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
.collect();
assert!(
scalar_diags.is_empty(),
"should not flag scalar_member_access on $z->bar() inside if-body after triple && null guard, got: {scalar_diags:?}"
);
}
#[test]
fn no_false_positive_if_body_null_narrowing() {
let php = r#"<?php
class Foo {
public function bar(): int { return 0; }
}
class Svc {
public function test(): void {
$a = null;
$b = null;
if (rand(0,1)) { $a = new Foo(); }
if (rand(0,1)) { $b = new Foo(); }
if ($a !== null && $b !== null && $a->bar() > 0) {
$b->bar();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let scalar_diags: Vec<_> = diags
.iter()
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
.collect();
assert!(
scalar_diags.is_empty(),
"should not flag scalar_member_access on $b->bar() inside if-body after && null guard, got: {scalar_diags:?}"
);
}
#[test]
fn no_false_positive_ternary_wrapped_and_null_narrowing() {
let php = r#"<?php
class Foo {
public function val(): int { return 0; }
}
class Svc {
public function test(): int {
$c = null;
if (rand(0,1)) { $c = new Foo(); }
return $c !== null && $c->val() > 5 ? 1 : 0;
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let scalar_diags: Vec<_> = diags
.iter()
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
.collect();
assert!(
scalar_diags.is_empty(),
"should not flag scalar_member_access on $c->val() inside ternary-wrapped &&, got: {scalar_diags:?}"
);
}
#[test]
fn assignment_in_if_condition_resolves_in_body() {
let php = r#"<?php
class AdminUser {
public function assignRole(string $role): void {}
/** @return ?static */
public static function first(): ?static { return new static(); }
}
function test(string $role): void {
if ($admin = AdminUser::first()) {
$admin->assignRole($role);
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let bad: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("assignRole") || d.message.contains("admin"))
.collect();
assert!(
bad.is_empty(),
"should resolve $admin from if-condition assignment, got: {bad:?}"
);
}
#[test]
fn assignment_in_if_condition_with_comparison() {
let php = r#"<?php
class Conn {
public function query(string $sql): void {}
}
function getConn(): ?Conn { return new Conn(); }
function test(): void {
if (($conn = getConn()) !== null) {
$conn->query('SELECT 1');
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let bad: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("query") || d.message.contains("conn"))
.collect();
assert!(
bad.is_empty(),
"should resolve $conn from if-condition assignment with !== null, got: {bad:?}"
);
}
#[test]
fn flags_member_on_array_access_class_without_generics() {
let php = r#"<?php
class Application implements \ArrayAccess {
public function offsetExists(mixed $offset): bool { return true; }
public function offsetGet(mixed $offset): mixed { return null; }
public function offsetSet(mixed $offset, mixed $value): void {}
public function offsetUnset(mixed $offset): void {}
public function useStoragePath(string $path): void {}
}
function test(Application $app): void {
$app['config']->set('logging.default', 'stderr');
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
!diags.iter().any(|d| d.message.contains("Application")),
"should not report 'set' as missing on Application — bracket access returns mixed, got: {diags:?}",
);
assert!(
diags
.iter()
.any(|d| d.message.contains("could not be resolved")),
"expected 'could not be resolved' diagnostic for unresolvable bracket access, got: {diags:?}",
);
}
#[test]
fn flags_member_on_array_access_subclass_without_generics() {
let php = r#"<?php
namespace Tests;
use ArrayAccess;
class Container2 implements ArrayAccess
{
public function offsetExists($offset): bool
{
return false;
}
public function offsetGet($offset): mixed
{
return '';
}
public function offsetSet($offset, $value): void
{
}
public function offsetUnset($offset): void
{
}
}
class Application2 extends Container2
{
}
class TestCase
{
public function defineEnvironment(): void
{
$test4 = new Application2();
$test4['config']->set('logging.channels.stack.channels', ['stderr']);
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
!diags.iter().any(|d| d.message.contains("Application2")),
"should not report 'set' as missing on Application2 — bracket access returns mixed, got: {diags:?}",
);
}
#[test]
fn assignment_in_while_condition_resolves_in_body() {
let php = r#"<?php
class Row {
public function toArray(): array { return []; }
}
function nextRow(): ?Row { return new Row(); }
function test(): void {
while ($row = nextRow()) {
$row->toArray();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let bad: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("toArray") || d.message.contains("row"))
.collect();
assert!(
bad.is_empty(),
"should resolve $row from while-condition assignment, got: {bad:?}"
);
}
#[test]
fn magic_call_chain_flags_unknown_but_continues() {
let php = r#"<?php
class AppleCart {
public function getApples(): array { return []; }
}
class Builder {
public function __call(string $name, array $args): static { return $this; }
public function first(): AppleCart { return new AppleCart(); }
}
class Svc {
public function run(): void {
$b = new Builder();
$b->doesntExist()->first()->getApples();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"Should flag only doesntExist(), not first() or getApples(), got: {diags:?}"
);
assert!(
diags[0].message.contains("doesntExist"),
"Diagnostic should mention 'doesntExist', got: {}",
diags[0].message
);
}
#[test]
fn magic_call_chain_flags_multiple_unknown_methods() {
let php = r#"<?php
class AppleCart {
public function getApples(): array { return []; }
}
class Builder {
public function __call(string $name, array $args): static { return $this; }
public function first(): AppleCart { return new AppleCart(); }
}
class Svc {
public function run(): void {
$b = new Builder();
$b->doesntExist()->first()->getApples();
$b->doesntExist()->alsoDoesntExist()->first()->getApples();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
let unknown_diags: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("doesntExist") || d.message.contains("alsoDoesntExist"))
.collect();
assert_eq!(
unknown_diags.len(),
3,
"Should flag doesntExist twice and alsoDoesntExist once, got: {diags:?}"
);
let false_positives: Vec<_> = diags
.iter()
.filter(|d| d.message.contains("first") || d.message.contains("getApples"))
.collect();
assert!(
false_positives.is_empty(),
"Should not flag first() or getApples(), got: {false_positives:?}"
);
}
#[test]
fn magic_call_concrete_return_continues_chain() {
let php = r#"<?php
class Result {
public function getData(): array { return []; }
}
class Proxy {
public function __call(string $name, array $args): Result { return new Result(); }
}
class Svc {
public function run(): void {
$p = new Proxy();
$p->anything()->getData();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert_eq!(
diags.len(),
1,
"Should flag 'anything' but not 'getData', got: {diags:?}"
);
assert!(
diags[0].message.contains("anything"),
"Diagnostic should mention 'anything', got: {}",
diags[0].message
);
}
#[test]
fn magic_call_mixed_return_breaks_chain_downstream() {
let php = r#"<?php
class Loose {
public function __call(string $name, array $args): mixed { return null; }
}
class Svc {
public function run(): void {
$l = new Loose();
$l->unknown()->somethingElse();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("unknown")),
"Should flag 'unknown', got: {diags:?}"
);
}
#[test]
fn no_false_positive_when_variable_reassigned_inside_try_block() {
let php = r#"<?php
class LuxplusCustomer {
public function getName(): string { return ''; }
}
class MollieCustomer {
public function createPayment(string $data): MolliePayment { return new MolliePayment(); }
}
class MolliePayment {
public function getCheckoutUrl(): string { return ''; }
}
class MollieClient {
public function getOrCreateCustomer(LuxplusCustomer $c): MollieCustomer { return new MollieCustomer(); }
}
class Gateway {
public function charge(LuxplusCustomer $customer): void {
$client = new MollieClient();
try {
$customer = $client->getOrCreateCustomer($customer);
$molliePayment = $customer->createPayment('data');
$url = $molliePayment->getCheckoutUrl();
} catch (\Exception $e) {
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for reassigned variable inside try block, got: {diags:?}"
);
}
#[test]
fn flags_unknown_member_after_reassignment_inside_try_block() {
let php = r#"<?php
class OriginalType {
public function onlyOnOriginal(): void {}
}
class ReplacementType {
public function onlyOnReplacement(): void {}
}
class Service {
public function process(OriginalType $var): void {
try {
$var = new ReplacementType();
$var->onlyOnOriginal();
} catch (\Exception $e) {
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags
.iter()
.any(|d| d.message.contains("onlyOnOriginal")
&& d.message.contains("ReplacementType")),
"expected diagnostic for onlyOnOriginal() on ReplacementType after reassignment in try, got: {diags:?}"
);
}
#[test]
fn try_block_reassignment_is_conditional_after_try() {
let php = r#"<?php
class TypeA {
public function methodA(): void {}
}
class TypeB {
public function methodB(): void {}
}
class Svc {
public function run(TypeA $var): void {
try {
$var = new TypeB();
} catch (\Exception $e) {
}
$var->methodA();
$var->methodB();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"after try/catch, both original and reassigned types should be accepted, got: {diags:?}"
);
}
#[test]
fn catch_block_variable_reassignment_tracked() {
let php = r#"<?php
class ErrorResult {
public function getErrorCode(): int { return 0; }
}
class SuccessResult {
public function getData(): string { return ''; }
}
class Handler {
public function handle(): void {
$result = new SuccessResult();
try {
$result->getData();
} catch (\Exception $e) {
$result = new ErrorResult();
$result->getErrorCode();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for reassigned variable inside catch block, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_this_items_on_generic_collection_subclass() {
let php = r#"<?php
/**
* @template TKey
* @template TValue
*/
class Collection {
/** @var array<TKey, TValue> */
public array $items = [];
/** @return TValue|null */
public function first(): mixed { return null; }
}
class PurchaseFileProduct {
public int $order_amount = 0;
public string $name = '';
}
/**
* @template TKey
* @template TValue
* @param array<TKey, TValue> $array
* @param callable(TValue, TKey): bool $callback
* @return bool
*/
function array_any(array $array, callable $callback): bool { return false; }
/**
* @extends Collection<int, PurchaseFileProduct>
*/
final class PurchaseFileProductCollection extends Collection {
public function hasIssues(): bool {
return array_any($this->items, fn($item) => $item->order_amount > 0);
}
public function hasName(): bool {
return array_any($this->items, fn($item) => $item->name !== '');
}
public function foreachWorks(): void {
foreach ($this->items as $item) {
$item->order_amount;
$item->name;
}
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for $this->items on generic Collection subclass, got: {diags:?}"
);
}
#[test]
fn no_false_positive_when_variable_reassigned_inside_try_inside_foreach() {
let php = r#"<?php
class Decimal {
public function sub(string $v): self { return new self(); }
public function isZero(): bool { return true; }
public function isNegative(): bool { return true; }
public function isPositive(): bool { return true; }
public function toFixed(int $places): string { return ''; }
}
/**
* @property Decimal $amount
* @property string $state
*/
class Payment {
}
/**
* @property Decimal $amount
*/
class Order {
}
class CaptureException extends \Exception {}
class InvalidStateException extends \Exception {}
class CaptureService {
public function captureReservedPayment(Payment $p, Decimal $amount): void {}
}
class OrderService {
/** @param list<Payment> $payments */
public function capture(Order $order, array $payments): void {
$remaining = $order->amount;
foreach ($payments as $payment) {
if ($payment->state === 'paid') {
$remaining = $remaining->sub('1');
}
}
$svc = new CaptureService();
foreach ($payments as $payment) {
if ($payment->state !== 'reserved') {
continue;
}
$toCapture = $remaining->isPositive() ? $payment->amount : $remaining;
if ($toCapture->isZero() || $toCapture->isNegative()) {
break;
}
try {
$svc->captureReservedPayment($payment, $toCapture);
$remaining = $remaining->sub('1');
} catch (CaptureException|InvalidStateException $e) {
}
}
if ($remaining->isPositive() && !$remaining->isZero()) {
throw new \RuntimeException('remaining: ' . $remaining->toFixed(2));
}
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for variable reassigned inside try-inside-foreach, got: {diags:?}"
);
}
#[test]
fn no_false_positive_when_variable_reassigned_inside_nested_foreach() {
let php = r#"<?php
class Decimal {
public function add(string $v): self { return new self(); }
public function mul(string $v): self { return new self(); }
}
class Item {
public Decimal $cost;
public function isBundle(): bool { return false; }
/** @return list<Item> */
public function getChildren(): array { return []; }
}
class OrderService {
/** @param list<Item> $items */
public function calculateCost(array $items): Decimal {
$zero = new Decimal();
$result = $zero;
foreach ($items as $item) {
if ($item->isBundle()) {
$children = $item->getChildren();
foreach ($children as $child) {
$result = $result->add($child->cost->mul('1'));
}
continue;
}
$result = $result->add($item->cost->mul('1'));
}
return $result->mul('1');
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for variable reassigned inside nested foreach loops, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_object_parameter_type() {
let php = r#"<?php
function test(object $obj): void {
echo $obj->anything;
$obj->whatever();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for object parameter type, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_after_is_object_guard() {
let php = r#"<?php
function test(mixed $data): void {
if (is_object($data)) {
echo $data->error_link;
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics after is_object() guard, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_after_is_object_guard_with_negated_early_return() {
let php = r#"<?php
function test(mixed $data): void {
if (!is_object($data)) {
return;
}
echo $data->error_link;
echo $data->something_else;
$data->doStuff();
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics after negated is_object() early return, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_after_is_object_in_compound_and_condition() {
let php = r#"<?php
function test(mixed $data): void {
if (is_object($data) && property_exists($data, 'error_link') && is_string($data->error_link)) {
echo stripslashes($data->error_link);
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics after is_object() in compound && condition, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_object_typed_parameter() {
let php = r#"<?php
function test(object $data): void {
echo $data->name;
$data->doStuff();
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for object-typed parameter, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_class_string_static_return_in_foreach() {
let php = r#"<?php
class OptionList {
/**
* @param class-string<BackedEnum> $class
*/
public static function enum(BackedEnum $value, string $class, array $exclude = [], string $method = ''): void {
foreach ($class::cases() as $item) {
if (in_array($item, $exclude, true)) {
continue;
}
$name = $method ? $item->{$method}() : $item->name;
$val = $item->value;
}
}
}
"#;
let backend = create_enum_backend();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for class-string<BackedEnum> foreach item members, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_class_string_static_return_chained() {
let php = r#"<?php
class Svc {
/**
* @param class-string<BackedEnum> $class
*/
public function resolve(string $class): void {
$result = $class::from('foo');
$name = $result->name;
$val = $result->value;
}
}
"#;
let backend = create_enum_backend();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for class-string<BackedEnum> static return chain, got: {diags:?}"
);
}
#[test]
fn in_array_guard_does_not_wipe_type_when_element_matches() {
let php = r#"<?php
class Foo {
public string $name;
}
class Svc {
/**
* @param array<int, Foo> $exclude
*/
public function run(Foo $item, array $exclude): void {
if (in_array($item, $exclude, true)) {
return;
}
$name = $item->name;
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"in_array guard should not wipe variable type when element type matches, got: {diags:?}"
);
}
#[test]
fn in_array_guard_still_narrows_union_type() {
let php = r#"<?php
class Foo {
public string $fooName;
}
class Bar {
public string $barName;
}
class Svc {
/**
* @param Foo|Bar $item
* @param array<int, Foo> $fooList
*/
public function run(object $item, array $fooList): void {
if (in_array($item, $fooList, true)) {
return;
}
$name = $item->barName;
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"in_array guard should still narrow union types, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_when_instanceof_target_unresolvable_ternary() {
let php = r#"<?php
interface Type {
public function describe(): string;
}
class Test {
/** @param Type $argType */
public function run(Type $argType): void {
$types = $argType instanceof UnionType ? $argType->getTypes() : [$argType];
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics when instanceof target is unresolvable (ternary), got: {diags:?}"
);
}
#[test]
fn no_diagnostic_when_instanceof_target_unresolvable_if_body() {
let php = r#"<?php
interface Type {
public function describe(): string;
}
class Test {
public function run(Type $argType): void {
if ($argType instanceof UnionType) {
$argType->getTypes();
}
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics when instanceof target is unresolvable (if-body), got: {diags:?}"
);
}
#[test]
fn no_diagnostic_when_instanceof_target_unresolvable_assert() {
let php = r#"<?php
interface Type {
public function describe(): string;
}
class Test {
public function run(Type $argType): void {
assert($argType instanceof UnionType);
$argType->getTypes();
}
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics when instanceof target is unresolvable (assert), got: {diags:?}"
);
}
#[test]
fn no_diagnostic_when_instanceof_target_unresolvable_and_chain() {
let php = r#"<?php
interface Type {
public function describe(): string;
}
function test(Type $t): void {
$t instanceof UnionType && $t->getTypes();
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics when instanceof target is unresolvable (&& chain), got: {diags:?}"
);
}
#[test]
fn no_unresolved_for_variable_assigned_from_method_chain() {
let php = r#"<?php
class DebtCollection {
public function isResolved(): bool { return false; }
}
class Order {
public function getDebtCollection(): ?DebtCollection { return null; }
}
class Period {
public function getOrder(): ?Order { return null; }
}
class Test {
public function run(Period $period): void {
$debt = $period->getOrder()?->getDebtCollection();
if ($debt) {
$debt->isResolved();
}
}
}
"#;
let backend = Backend::new_test();
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for variable assigned from method chain, got: {diags:?}"
);
}
#[test]
fn no_diagnostic_for_interleaved_array_access_property_chain() {
let php = r#"<?php
class ExtraPointsDto {
public string $label;
}
class ActivityResultDto {
/** @var list<ExtraPointsDto> */
public array $extras = [];
public int $activityId;
}
class WeeklyResultDto {
/** @var array<int, ActivityResultDto> */
public array $activities;
public int $week;
}
function test(): void {
/** @var array<int, WeeklyResultDto> */
$results = [];
$results[0]->activities[1]->extras[] = new ExtraPointsDto();
$results[0]->activities[1]->activityId;
$results[0]->week;
}
"#;
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.is_empty(),
"expected no diagnostics for interleaved array-access property chain, got: {diags:?}",
);
}
}