Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Static method calls resolve return types as accurately as instance calls.** `Foo::bar()` previously missed inference that `$foo->bar()` already had: return types behind a `@phpstan-type` alias, inherited return types substituted through a generic interface or trait, and the `__callStatic()` magic-method fallback. These now resolve the same way for both call styles.
- **Facade static calls keep concrete method return types.** Static calls on Laravel-style facades now resolve missing methods through `getFacadeAccessor()` and facade `@mixin` targets before falling back to `__callStatic()`, so values like `Driver::details()` keep the concrete provider method return type instead of degrading to the facade's broad magic-call return. Contributed by @calebdw.
- **`class-string<static>` parameters no longer reject sibling subclass constants.** Static helper calls from a shared base class that pass concrete `::class` constants for sibling subclasses no longer report false argument-type mismatches against `class-string<static>`. Contributed by @calebdw.
- **`class-string<static>` parameters now diagnose provably invalid class strings.** Passing an unrelated class to a `class-string<static>` parameter is now flagged as a type mismatch instead of being silently accepted. The diagnostic resolves `static` to the declaring class at the call site and checks whether the argument class is in the inheritance hierarchy, so child classes and siblings are still accepted while unrelated classes are rejected. Contributed by @calebdw.
- **Built-in PHP classes shadowed by vendor polyfills resolve to the real definition.** When an installed package ships a polyfill for a PHP built-in (for example symfony/polyfill-php84's `RoundingMode`), resolution sometimes picked the polyfill's legacy pre-enum declaration instead of the built-in, turning enum cases into plain int constants and reporting false "expects RoundingMode, got int" argument mismatches. Which declaration won could change from one run to the next, making whole-project analysis results nondeterministic. Global names of built-in classes now always resolve to the bundled PHP definition, and classes discovered inside phar archives are indexed in a stable order.
- **Member name positions no longer suggest classes.** Typing a name after `function`, `const`, or enum `case` (for example `protected function getC`) no longer offers unrelated class names from the project. Property names were already safe because they start with `$`. Contributed by @calebdw.
- **Null-initialized variables reassigned in an untyped foreach are not stuck as `null`.** When the iterable has no known element type (for example an untyped parameter), the loop value is now treated as `mixed`, so `$x = $value` after `$x = null` participates in post-loop merge and `is_null` early-return narrowing instead of leaving a false `null` type at later call sites. Contributed by @calebdw.
Expand Down
56 changes: 54 additions & 2 deletions src/diagnostics/type_errors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,33 @@ impl Backend {
// mapping positional args to parameter indices.
let mut positional_idx: usize = 0;

let call_context_class: Option<String> = {
let class_part = if let Some(pos) = expr.find("::") {
Some(&expr[..pos])
} else if expr.starts_with("$this->") {
Some("$this" as &str)
} else {
None
};
class_part.and_then(|cp| {
let low = cp.to_ascii_lowercase();
match low.as_str() {
"self" | "static" | "$this" => {
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
.map(|c| c.fqn().to_string())
}
"parent" => {
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
.and_then(|c| c.parent_class.as_ref().map(|p| p.to_string()))
}
_ => class_loader(cp).map(|cls| cls.fqn().to_string()),
}
})
};
let ctx_parent_fqn: Option<String> = call_context_class.as_ref().and_then(|fqn| {
class_loader(fqn).and_then(|cls| cls.parent_class.as_ref().map(|p| p.to_string()))
});

// Check each argument against its parameter.
for (arg_idx, resolved_arg) in resolved_args.args.iter().enumerate() {
// Skip spread arguments.
Expand Down Expand Up @@ -606,8 +633,21 @@ impl Backend {
continue;
}

let resolved_param;
let effective_param_type = if param_type.contains_self_ref() {
if let Some(ref fqn) = call_context_class {
resolved_param =
param_type.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
&resolved_param
} else {
param_type
}
} else {
param_type
};

// Check compatibility.
if is_type_compatible(arg_type, param_type, &class_loader, strict_types) {
if is_type_compatible(arg_type, effective_param_type, &class_loader, strict_types) {
// Even when the array types are compatible, validate
// string literals against model-property<Model> when
// the param type is an array with model-property in
Expand Down Expand Up @@ -659,9 +699,21 @@ impl Backend {
&& !alt_type.is_untyped()
&& !alt_type.is_mixed()
{
let resolved_alt;
let effective_alt = if alt_type.contains_self_ref() {
if let Some(ref fqn) = call_context_class {
resolved_alt = alt_type
.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
&resolved_alt
} else {
alt_type
}
} else {
alt_type
};
return is_type_compatible(
arg_type,
alt_type,
effective_alt,
&class_loader,
strict_types,
);
Expand Down
62 changes: 62 additions & 0 deletions tests/integration/diagnostics_type_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1918,6 +1918,68 @@ final class SClass3 extends AClass {}
);
}

#[test]
fn diagnose_unrelated_class_string_passed_to_static_bound() {
let php = r#"<?php
abstract class Base273 {
/** @param class-string<static> $class */
private static function make(string $class): void {}

public static function run(): void {
static::make(Unrelated273::class);
}
}

final class Child273 extends Base273 {}
final class Unrelated273 {}
"#;
let diags = collect(php);
assert!(
has_type_error(&diags),
"Should flag unrelated class-string passed to class-string<static>"
);
}

#[test]
fn no_diagnostic_for_child_class_string_via_explicit_call() {
let php = r#"<?php
abstract class Base273b {
/** @param class-string<static> $class */
public static function make(string $class): void {}
}

final class Child273b extends Base273b {}

function test273b(): void {
Base273b::make(Child273b::class);
}
"#;
let diags = collect(php);
assert!(
!has_type_error(&diags),
"Should not flag child class-string passed to class-string<static> via explicit call, got: {diags:?}"
);
}

#[test]
fn no_diagnostic_for_self_class_string_to_static_bound() {
let php = r#"<?php
abstract class Base273c {
/** @param class-string<static> $class */
private static function make(string $class): void {}

public static function run(): void {
self::make(static::class);
}
}
"#;
let diags = collect(php);
assert!(
!has_type_error(&diags),
"Should not flag static::class passed to class-string<static> via self:: call, got: {diags:?}"
);
}

// ═══════════════════════════════════════════════════════════════════════════
// New rules: iterable<...> accepts arrays
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading