use mago_atom::Atom;
use mago_atom::AtomMap;
use mago_codex::identifier::function_like::FunctionLikeIdentifier;
use mago_codex::identifier::method::MethodIdentifier;
use mago_codex::metadata::class_like::ClassLikeMetadata;
use mago_codex::metadata::function_like::FunctionLikeMetadata;
use mago_codex::ttype::atomic::TAtomic;
use mago_codex::ttype::atomic::object::TObject;
use mago_codex::ttype::expander::StaticClassType;
use mago_codex::ttype::get_never;
use mago_codex::ttype::template::TemplateResult;
use mago_codex::ttype::union::TUnion;
use mago_span::HasSpan;
use mago_span::Span;
use mago_syntax::ast::ArgumentList;
use mago_syntax::ast::Call;
use mago_syntax::ast::ClassLikeMemberSelector;
use mago_syntax::ast::Expression;
use mago_syntax::ast::MethodCall;
use mago_syntax::ast::NullSafeMethodCall;
use mago_syntax::ast::Variable;
use crate::analyzable::Analyzable;
use crate::artifacts::AnalysisArtifacts;
use crate::context::Context;
use crate::context::block::BlockContext;
use crate::error::AnalysisError;
use crate::expression::call::analyze_invocation_targets;
use crate::invocation::Invocation;
use crate::invocation::InvocationArgumentsSource;
use crate::invocation::InvocationTarget;
use crate::invocation::MethodTargetContext;
use crate::invocation::post_process::post_invocation_process;
use crate::invocation::return_type_fetcher::fetch_invocation_return_type;
use crate::invocation::template_result::populate_template_result_from_invocation;
use crate::plugin::ExpressionHookResult;
use crate::plugin::context::HookContext;
use crate::resolver::method::resolve_method_targets;
use crate::utils::expression::get_expression_id;
use crate::visibility::check_method_visibility;
impl<'ast, 'arena> Analyzable<'ast, 'arena> for MethodCall<'arena> {
fn analyze<'ctx>(
&'ast self,
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
) -> Result<(), AnalysisError> {
if context.plugin_registry.has_method_call_hooks() {
let mut hook_context = HookContext::new(context.codebase, block_context, artifacts);
let result = context.plugin_registry.before_method_call(self, &mut hook_context)?;
for reported in hook_context.take_issues() {
context.collector.report_with_code(reported.code, reported.issue);
}
match result {
ExpressionHookResult::Continue => {}
ExpressionHookResult::Skip => {
return Ok(());
}
ExpressionHookResult::SkipWithType(ty) => {
artifacts.set_expression_type(&self.span(), ty);
return Ok(());
}
}
}
analyze_method_call(
context,
block_context,
artifacts,
self.object,
&self.method,
&self.argument_list,
false, self.span(),
)?;
if context.plugin_registry.has_method_call_hooks() {
let mut hook_context = HookContext::new(context.codebase, block_context, artifacts);
context.plugin_registry.after_method_call(self, &mut hook_context)?;
for reported in hook_context.take_issues() {
context.collector.report_with_code(reported.code, reported.issue);
}
}
Ok(())
}
}
impl<'ast, 'arena> Analyzable<'ast, 'arena> for NullSafeMethodCall<'arena> {
fn analyze<'ctx>(
&'ast self,
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
) -> Result<(), AnalysisError> {
if context.plugin_registry.has_nullsafe_method_call_hooks() {
let mut hook_context = HookContext::new(context.codebase, block_context, artifacts);
let result = context.plugin_registry.before_nullsafe_method_call(self, &mut hook_context)?;
for reported in hook_context.take_issues() {
context.collector.report_with_code(reported.code, reported.issue);
}
match result {
ExpressionHookResult::Continue => {}
ExpressionHookResult::Skip => {
return Ok(());
}
ExpressionHookResult::SkipWithType(ty) => {
artifacts.set_expression_type(&self.span(), ty);
return Ok(());
}
}
}
analyze_method_call(
context,
block_context,
artifacts,
self.object,
&self.method,
&self.argument_list,
true, self.span(),
)?;
if context.plugin_registry.has_nullsafe_method_call_hooks() {
let mut hook_context = HookContext::new(context.codebase, block_context, artifacts);
context.plugin_registry.after_nullsafe_method_call(self, &mut hook_context)?;
for reported in hook_context.take_issues() {
context.collector.report_with_code(reported.code, reported.issue);
}
}
Ok(())
}
}
pub fn analyze_implicit_method_call<'ctx, 'arena>(
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
object_type: &TObject,
object_variable: Option<&str>,
method_identifier: MethodIdentifier,
class_like_metadata: &'ctx ClassLikeMetadata,
method_metadata: &'ctx FunctionLikeMetadata,
arguments_source: Option<InvocationArgumentsSource<'_, 'arena>>,
span: Span,
) -> Result<TUnion, AnalysisError> {
if !check_method_visibility(
context,
block_context,
method_identifier.get_class_name(),
method_identifier.get_method_name(),
span,
None,
) {
return Ok(get_never()); }
let mut template_result = TemplateResult::default();
let method_target_context = MethodTargetContext {
declaring_method_id: Some(method_identifier),
class_like_metadata,
class_type: StaticClassType::Object(object_type.clone()),
};
let invocation = Invocation::new(
InvocationTarget::FunctionLike {
identifier: FunctionLikeIdentifier::Method(
*method_identifier.get_class_name(),
*method_identifier.get_method_name(),
),
metadata: method_metadata,
inferred_return_type: None,
method_context: Some(method_target_context),
span,
},
arguments_source.unwrap_or(InvocationArgumentsSource::None(span)),
span,
);
populate_template_result_from_invocation(context, &invocation, &mut template_result);
let result = fetch_invocation_return_type(
context,
block_context,
artifacts,
&invocation,
&template_result,
&Default::default(),
);
post_invocation_process(
context,
block_context,
artifacts,
&invocation,
object_variable,
&template_result,
&AtomMap::default(),
false,
)?;
Ok(result)
}
fn analyze_method_call<'ctx, 'ast, 'arena>(
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
object: &'ast Expression<'arena>,
selector: &'ast ClassLikeMemberSelector<'arena>,
argument_list: &'ast ArgumentList<'arena>,
is_null_safe: bool,
span: Span,
) -> Result<(), AnalysisError> {
if block_context.flags.collect_initializations()
&& let ClassLikeMemberSelector::Identifier(method_ident) = selector
&& is_this_or_self_returning_chain(object, context, block_context)
{
let method_name = mago_atom::ascii_lowercase_atom(method_ident.value);
block_context.definitely_called_methods.insert(method_name);
block_context.called_methods.insert(method_name);
}
let method_resolution =
resolve_method_targets(context, block_context, artifacts, object, selector, is_null_safe, span)?;
let mut invocation_targets = vec![];
for resolved_method in method_resolution.resolved_methods {
let metadata = context
.codebase
.get_class_like(&resolved_method.classname)
.expect("class-like metadata should exist for resolved method");
let method_metadata = context
.codebase
.get_method_by_id(&resolved_method.method_identifier)
.expect("method metadata should exist for resolved method");
let method_target_context = MethodTargetContext {
declaring_method_id: Some(resolved_method.method_identifier),
class_like_metadata: metadata,
class_type: resolved_method.static_class_type,
};
invocation_targets.push(InvocationTarget::FunctionLike {
identifier: FunctionLikeIdentifier::Method(
*resolved_method.method_identifier.get_class_name(),
*resolved_method.method_identifier.get_method_name(),
),
metadata: method_metadata,
inferred_return_type: None,
method_context: Some(method_target_context),
span,
});
}
let this_variable = get_expression_id(
object,
block_context.scope.get_class_like_name(),
context.resolved_names,
Some(context.codebase),
);
if is_null_safe && let Some(ref var_id) = this_variable {
artifacts
.true_branch_only_assertions
.entry((span.start.offset, span.end.offset))
.or_default()
.entry(*var_id)
.or_default()
.push(vec![mago_codex::assertion::Assertion::IsNotType(mago_codex::ttype::atomic::TAtomic::Null)]);
}
analyze_invocation_targets(
context,
block_context,
artifacts,
method_resolution.template_result,
invocation_targets,
InvocationArgumentsSource::ArgumentList(argument_list),
span,
this_variable.as_deref(),
method_resolution.has_invalid_target,
method_resolution.encountered_mixed,
is_null_safe && method_resolution.encountered_null,
artifacts.get_expression_type(object).is_some_and(|t| t.has_nullsafe_null()),
method_resolution.all_methods_non_nullable_return,
)
}
fn is_this_or_self_returning_chain<'ctx, 'arena>(
expr: &Expression<'arena>,
context: &Context<'ctx, 'arena>,
block_context: &BlockContext<'ctx>,
) -> bool {
match expr {
Expression::Variable(Variable::Direct(var)) if var.name == "$this" => true,
Expression::Call(Call::Method(method_call)) => {
if !is_this_or_self_returning_chain(method_call.object, context, block_context) {
return false;
}
let ClassLikeMemberSelector::Identifier(method_ident) = &method_call.method else {
return false;
};
let Some(class_like) = block_context.scope.get_class_like() else {
return false;
};
let method_name = mago_atom::ascii_lowercase_atom(method_ident.value);
method_returns_self_or_static(context, class_like.name, method_name)
}
Expression::Call(Call::NullSafeMethod(method_call)) => {
if !is_this_or_self_returning_chain(method_call.object, context, block_context) {
return false;
}
let ClassLikeMemberSelector::Identifier(method_ident) = &method_call.method else {
return false;
};
let Some(class_like) = block_context.scope.get_class_like() else {
return false;
};
let method_name = mago_atom::ascii_lowercase_atom(method_ident.value);
method_returns_self_or_static(context, class_like.name, method_name)
}
Expression::Parenthesized(paren) => is_this_or_self_returning_chain(paren.expression, context, block_context),
_ => false,
}
}
fn method_returns_self_or_static(context: &Context<'_, '_>, class_name: Atom, method_name: Atom) -> bool {
let method_id = MethodIdentifier::new(class_name, method_name);
let Some(method_meta) = context.codebase.get_method_by_id(&method_id) else {
return false;
};
if let Some(return_type_meta) = &method_meta.return_type_declaration_metadata {
for atomic in return_type_meta.type_union.types.iter() {
match atomic {
TAtomic::Object(TObject::Named(named_obj)) if named_obj.is_this => return true,
TAtomic::Object(TObject::Named(named_obj)) if named_obj.name.eq_ignore_ascii_case(&class_name) => {
return true;
}
_ => {}
}
}
}
false
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use crate::code::IssueCode;
use crate::test_analysis;
test_analysis! {
name = nullsafe_method_call_on_null,
code = indoc! {r#"
<?php
declare(strict_types=1);
interface WriteInterface
{
/**
* @param non-empty-string $data
*/
public function write(string $data): void;
}
function get_writer(): null|WriteInterface
{
return null;
}
function write_line(string $message): void
{
$message = $message . "\n";
get_writer()?->write($message);
}
"#}
}
test_analysis! {
name = possible_method_call_on_null,
code = indoc! {r#"
<?php
declare(strict_types=1);
interface WriteInterface
{
/**
* @param non-empty-string $data
*/
public function write(string $data): void;
}
function get_writer(): null|WriteInterface
{
return null;
}
function write_line(string $message): void
{
$message = $message . "\n";
get_writer()->write($message);
}
"#},
issues = [
IssueCode::PossibleMethodAccessOnNull
]
}
test_analysis! {
name = method_call_on_mixed,
code = indoc! {r#"
<?php
declare(strict_types=1);
function get_mixed(): mixed
{
return "Hello, World!";
}
function call_method_on_mixed(): void
{
$mixed = get_mixed();
$mixed->someMethod();
}
"#},
issues = [
IssueCode::MixedAssignment,
IssueCode::MixedMethodAccess
]
}
test_analysis! {
name = method_call_on_undefined_variable,
code = indoc! {r"
<?php
declare(strict_types=1);
function method_call_on_undefined_variable(): void
{
$mixed->someMethod();
}
"},
issues = [
IssueCode::UndefinedVariable,
IssueCode::MixedMethodAccess
]
}
test_analysis! {
name = method_call_on_non_object,
code = indoc! {r"
<?php
declare(strict_types=1);
function call_method_on_non_object(): void
{
$non_object = 42;
$non_object->someMethod();
}
"},
issues = [
IssueCode::InvalidMethodAccess
]
}
test_analysis! {
name = method_call_on_generic_parameter,
code = indoc! {r"
<?php
class A
{
public function getString(): string
{
return 'Hello, world!';
}
}
class B
{
public function getString(): string
{
return 'Hello, world!';
}
}
/**
* @template T of A|B
*
* @param T $object
*/
function foo(A|B $object): string
{
return $object->getString();
}
"},
}
test_analysis! {
name = ambiguous_object_method_call,
code = indoc! {r"
<?php
declare(strict_types=1);
function call_ambiguous_method(object $obj): void
{
$obj->someMethod();
}
"},
issues = [
IssueCode::AmbiguousObjectMethodAccess
]
}
test_analysis! {
name = template_resolution,
code = indoc! {r"
<?php
/**
* @template-covariant T
*/
interface TypeInterface
{
/**
* @param mixed $value
* @return T
*/
public function assert(mixed $value): mixed;
}
/**
* @param TypeInterface<non-empty-string> $type
*
* @return string
*/
function to_string(mixed $value, TypeInterface $type): string
{
return $type->assert($value);
}
"},
}
test_analysis! {
name = intersection_read_write_calls,
code = indoc! {r"
<?php
interface ReadHandle {
public function read(): string;
}
interface WriteHandle {
public function write(string $data): void;
}
/**
* @template T as array-key
* @param iterable<T, ReadHandle&WriteHandle> $handles
* @return array<T, string>
*/
function task(iterable $handles): array {
$result = [];
foreach ($handles as $index => $handle) {
$data = $handle->read();
$handle->write($data);
$result[$index] = $data;
}
return $result;
}
"},
}
test_analysis! {
name = intersection_template_resolution,
code = indoc! {r"
<?php
interface MockObject
{
}
abstract class TestCase
{
/**
* @template T of object
*
* @param class-string<T> $className
*
* @return MockObject&T
*/
protected function createMock(string $className): MockObject
{
exit('Not implemented');
}
/**
* @template T of object
*
* @param class-string<T> $className
*
* @return T&MockObject
*/
protected function createMockTwo(string $className): MockObject
{
exit('Not implemented');
}
}
interface ServiceInterface
{
}
class MyTestCase extends TestCase
{
private null|(MockObject&ServiceInterface) $service = null;
public function setup(): void
{
$this->service = $this->createMock(ServiceInterface::class);
$this->service = $this->createMockTwo(ServiceInterface::class);
}
}
"},
issues = [
IssueCode::WriteOnlyProperty,
]
}
test_analysis! {
name = trait_method_access,
code = indoc! {r#"
<?php
trait A {
private function x(): void {
echo "hello 1";
}
protected function y(): void {
echo "hello 2";
}
}
class B {
use A;
public function c(): void {
$this->x();
$this->y();
}
}
new B()->c();
"#},
}
test_analysis! {
name = calling_method_on_parent_class,
code = indoc! {r"
<?php
/**
* @template TKey of array-key
* @template-covariant T
*/
interface ReadableCollection
{
/**
* @return list<T>
*/
public function getValues(): array;
}
/**
* @template TKey of array-key
* @template T
*
* @template-extends ReadableCollection<TKey, T>
*/
interface Collection extends ReadableCollection
{
}
class Filing
{
}
class Storage
{
/**
* @var Collection<string, Filing>
*/
private $filings;
/**
* @param Collection<string, Filing> $filings
*/
public function __construct(Collection $filings)
{
$this->filings = $filings;
}
/**
* @return list<Filing>
*/
public function getFilings(): array
{
return $this->filings->getValues();
}
}
"},
issues = [
IssueCode::UnusedTemplateParameter,
]
}
test_analysis! {
name = where_constraints,
code = indoc! {r"
<?php
interface Stringable
{
public function __toString(): string;
}
function take_string(string $s): void
{
take_string($s);
}
function take_int(int $i): void
{
take_int($i);
}
function take_array(array $arr): void
{
take_array($arr);
}
/** @param scalar|Stringable $value */
function take_scalar_or_stringable(mixed $value): void
{
take_scalar_or_stringable($value);
}
final class Message implements Stringable
{
public function __construct(
private string $message,
) {}
public function __toString(): string
{
return $this->message;
}
}
/**
* @template-covariant T
*/
final class Box
{
/**
* @param T $value
*/
public function __construct(
public mixed $value,
) {}
/**
* @where T is string|int|float|Stringable
*/
public function toString(): string
{
take_scalar_or_stringable($this->value);
return (string) $this->value;
}
/**
* @template Y
* @template Z
*
* @where T is list{Y, Z}
*
* @return list{Box<Y>, Box<Z>}
*/
public function unzip(): array
{
take_array($this->value);
[$first, $second] = $this->value;
return [
new Box($first),
new Box($second),
];
}
}
$a = new Box('Hello, World!');
take_string($a->toString()); // OK
$b = new Box(42);
take_string($b->toString()); // OK
$c = new Box(3.14);
take_string($c->toString()); // OK
$d = new Box(new Message('This is a message.'));
take_string($d->toString()); // OK
$f = new Box(['foo', 123]);
[$g, $h] = $f->unzip(); // OK
take_string($g->value); // OK
take_int($h->value); // OK
"},
}
test_analysis! {
name = where_constraints_violation,
code = indoc! {r"
<?php
/**
* @template-covariant T
*/
final class Box
{
/**
* @param T $value
*/
public function __construct(
public mixed $value,
) {}
/**
* @where T is string|int|float
*/
public function toString(): string
{
return (string) $this->value;
}
}
$a = new Box(['foo', 123]);
$a->toString(); // violation of @where constraint
"},
issues = [
IssueCode::WhereConstraintViolation
]
}
}