use mago_atom::Atom;
use mago_atom::atom;
use mago_codex::metadata::function_like::FunctionLikeMetadata;
use mago_codex::visibility::Visibility;
use mago_php_version::feature::Feature;
use mago_reporting::Annotation;
use mago_reporting::Issue;
use mago_span::Span;
use crate::code::IssueCode;
use crate::context::Context;
use crate::context::block::BlockContext;
pub fn check_method_visibility<'ctx>(
context: &mut Context<'ctx, '_>,
block_context: &BlockContext<'ctx>,
fqcn: &str,
method_name: &str,
access_span: Span,
member_span: Option<Span>,
) -> bool {
let declaring_class = context.codebase.get_declaring_method_class(fqcn, method_name).unwrap_or_else(|| atom(fqcn));
let Some(method_metadata) = context.codebase.get_declaring_method(fqcn, method_name) else {
return true;
};
let Some(visibility) = context.codebase.get_method_visibility(fqcn, method_name) else {
return true;
};
if visibility == Visibility::Public {
return true;
}
let is_visible =
is_visible_from_scope(context, visibility, &declaring_class, block_context.scope.get_class_like_name());
if !is_visible {
let declaring_class_name = context
.codebase
.get_class_like(&declaring_class)
.map_or_else(|| declaring_class, |metadata| metadata.original_name);
let issue_title =
format!("Cannot access {} method `{}::{}`.", visibility.as_str(), declaring_class_name, method_name);
let help_text =
format!("Change the visibility of method `{method_name}` to `public`, or call it from an allowed scope.");
report_visibility_issue(
context,
block_context,
IssueCode::InvalidMethodAccess,
issue_title,
visibility,
access_span,
member_span,
Some(method_metadata.span),
help_text,
);
}
is_visible
}
pub fn check_property_read_visibility<'ctx>(
context: &mut Context<'ctx, '_>,
block_context: &BlockContext<'ctx>,
fqcn: &str,
property_name: &str,
access_span: Span,
member_span: Option<Span>,
) -> bool {
let property_name = atom(property_name);
let Some(class_metadata) = context.codebase.get_class_like(fqcn) else {
return true;
};
let Some(declaring_class_id) = class_metadata.declaring_property_ids.get(&property_name) else {
return true;
};
let Some(declaring_class_metadata) = context.codebase.get_class_like(declaring_class_id) else {
return true;
};
let Some(property_metadata) = declaring_class_metadata.properties.get(&property_name) else {
return true;
};
if property_metadata.flags.is_magic_property() && property_metadata.flags.is_writeonly() {
let class_name = &declaring_class_metadata.original_name;
context.collector.report_with_code(
IssueCode::InvalidPropertyRead,
Issue::error(format!(
"Cannot read from write-only property `{class_name}::{property_name}`."
))
.with_annotation(
Annotation::primary(member_span.unwrap_or(access_span))
.with_message("Attempt to read from a write-only property"),
)
.with_annotation(
Annotation::secondary(declaring_class_metadata.name_span.unwrap_or(declaring_class_metadata.span))
.with_message(format!("Property is defined as write-only via a `@property-write` tag on class `{class_name}`")),
)
.with_note("Properties defined with `@property-write` are 'magic' properties that can be assigned to, but not read from.")
.with_help("If this property should be readable, change its docblock definition from `@property-write` to `@property`."),
);
return false;
}
if !property_metadata.hooks.is_empty()
&& property_metadata.hooks.contains_key(&atom("set"))
&& !property_metadata.hooks.contains_key(&atom("get"))
&& property_metadata.flags.is_virtual_property()
{
let class_name = &declaring_class_metadata.original_name;
context.collector.report_with_code(
IssueCode::InvalidPropertyRead,
Issue::error(format!(
"Cannot read from write-only property `{class_name}::{property_name}` - property only has a set hook."
))
.with_annotation(Annotation::primary(member_span.unwrap_or(access_span)).with_message("Read access here"))
.with_annotation(
Annotation::secondary(property_metadata.span.or(property_metadata.name_span).unwrap_or(access_span))
.with_message("Property defined here with only a set hook"),
)
.with_help("Add a get hook to make this property readable."),
);
return false;
}
let visibility = property_metadata.read_visibility;
let is_visible =
is_visible_from_scope(context, visibility, declaring_class_id, block_context.scope.get_class_like_name());
if !is_visible {
let issue_title = format!(
"Cannot read {} property `{}` from class `{}`.",
visibility.as_str(),
property_name,
declaring_class_metadata.original_name
);
let help_text =
format!("Make the property `{property_name}` readable (e.g., `public`), or add a public getter method.");
report_visibility_issue(
context,
block_context,
IssueCode::InvalidPropertyRead,
issue_title,
visibility,
access_span,
member_span,
property_metadata.span.or(property_metadata.name_span),
help_text,
);
}
is_visible
}
pub fn check_property_write_visibility<'ctx>(
context: &mut Context<'ctx, '_>,
block_context: &BlockContext<'ctx>,
fqcn: &str,
property_name: &str,
access_span: Span,
member_span: Option<Span>,
) -> bool {
let property_name = atom(property_name);
let Some(class_metadata) = context.codebase.get_class_like(fqcn) else {
return true;
};
let Some(declaring_class_name) = class_metadata.declaring_property_ids.get(&property_name) else {
return true;
};
let Some(declaring_class_metadata) = context.codebase.get_class_like(declaring_class_name) else {
return true;
};
let Some(property_metadata) = declaring_class_metadata.properties.get(&property_name) else {
return true;
};
if !property_metadata.hooks.is_empty()
&& property_metadata.hooks.contains_key(&atom("get"))
&& !property_metadata.hooks.contains_key(&atom("set"))
&& property_metadata.flags.is_virtual_property()
{
let class_name = &declaring_class_metadata.original_name;
context.collector.report_with_code(
IssueCode::InvalidPropertyWrite,
Issue::error(format!(
"Cannot write to read-only property `{class_name}::{property_name}` - property only has a get hook."
))
.with_annotation(Annotation::primary(member_span.unwrap_or(access_span)).with_message("Write access here"))
.with_annotation(
Annotation::secondary(property_metadata.span.or(property_metadata.name_span).unwrap_or(access_span))
.with_message("Property defined here with only a get hook"),
)
.with_help("Add a set hook to make this property writable."),
);
return false;
}
let visibility = property_metadata.write_visibility;
let is_visible =
is_visible_from_scope(context, visibility, declaring_class_name, block_context.scope.get_class_like_name());
if !is_visible {
let issue_title = format!(
"Cannot write to {} property `{}` on class `{}`.",
visibility.as_str(),
property_name,
declaring_class_metadata.original_name
);
let help_text = format!(
"Make the property `{property_name}` writable (e.g., `public` or `public(set)`), or add a public setter method."
);
report_visibility_issue(
context,
block_context,
IssueCode::InvalidPropertyWrite,
issue_title,
visibility,
access_span,
member_span,
property_metadata.span.or(property_metadata.name_span),
help_text,
);
} else if property_metadata.flags.is_readonly()
&& !can_initialize_readonly_property(
context,
declaring_class_name,
block_context.scope.get_class_like_name(),
block_context.scope.get_function_like(),
)
{
report_readonly_issue(
context,
block_context,
IssueCode::InvalidPropertyWrite,
access_span,
member_span,
property_metadata.span.or(property_metadata.name_span),
);
}
is_visible
}
fn is_visible_from_scope(
context: &Context<'_, '_>,
visibility: Visibility,
declaring_class_id: &str,
current_class_opt: Option<Atom>,
) -> bool {
match visibility {
Visibility::Public => true,
Visibility::Protected => {
if let Some(current_class_id) = current_class_opt {
current_class_id.eq_ignore_ascii_case(declaring_class_id)
|| context.codebase.is_instance_of(¤t_class_id, declaring_class_id)
|| context.codebase.is_instance_of(declaring_class_id, ¤t_class_id)
|| is_visible_via_required_extends(context, ¤t_class_id, declaring_class_id)
} else {
false
}
}
Visibility::Private => {
if let Some(current_class_id) = current_class_opt {
current_class_id.eq_ignore_ascii_case(declaring_class_id)
|| context.codebase.class_uses_trait(¤t_class_id, declaring_class_id)
|| context.codebase.class_uses_trait(declaring_class_id, ¤t_class_id)
} else {
false
}
}
}
}
fn is_visible_via_required_extends(
context: &Context<'_, '_>,
current_class_id: &str,
declaring_class_id: &str,
) -> bool {
let current_class_id_lc = mago_atom::ascii_lowercase_atom(current_class_id);
let Some(current_metadata) = context.codebase.get_class_like(¤t_class_id_lc) else {
return false;
};
if current_metadata.require_extends.is_empty() {
return false;
}
for required_class in current_metadata.require_extends.iter() {
if context.codebase.is_instance_of(required_class, declaring_class_id)
|| context.codebase.class_uses_trait(required_class, declaring_class_id)
{
return true;
}
}
false
}
fn can_initialize_readonly_property(
context: &Context<'_, '_>,
declaring_class_id: &str,
current_class_opt: Option<Atom>,
current_function_opt: Option<&FunctionLikeMetadata>,
) -> bool {
let is_allowed_method = current_function_opt.is_some_and(|func| {
if func.method_metadata.as_ref().is_some_and(|m| m.is_constructor) {
return true;
}
if context.settings.version.is_supported(Feature::ReadonlyPropertyReinitializationInClone)
&& func.name.is_some_and(|name| name.eq_ignore_ascii_case("__clone"))
{
return true;
}
false
});
is_allowed_method
&& current_class_opt.is_some_and(|current_class_id| {
current_class_id.eq_ignore_ascii_case(declaring_class_id)
|| context.codebase.is_instance_of(¤t_class_id, declaring_class_id)
|| context.codebase.is_instance_of(declaring_class_id, ¤t_class_id)
})
}
fn report_visibility_issue<'ctx>(
context: &mut Context<'ctx, '_>,
block_context: &BlockContext<'ctx>,
code: IssueCode,
title: String,
visibility: Visibility,
access_span: Span,
member_span: Option<Span>,
definition_span: Option<Span>,
help_text: String,
) {
let current_scope_str = if let Some(current_class) = block_context.scope.get_class_like_name() {
format!("from within `{current_class}`")
} else {
"from the global scope".to_string()
};
let primary_annotation_span = member_span.unwrap_or(access_span);
let mut issue = Issue::error(title)
.with_annotation(
Annotation::primary(primary_annotation_span)
.with_message(format!("This member is {} and cannot be accessed here", visibility.as_str())),
)
.with_annotation(
Annotation::secondary(access_span).with_message(format!("Invalid access occurs here, {current_scope_str}")),
);
if let Some(definition_span) = definition_span
&& definition_span != primary_annotation_span
{
issue = issue.with_annotation(
Annotation::secondary(definition_span)
.with_message(format!("Member is defined as `{}` here", visibility.as_str())),
);
}
issue = issue.with_help(help_text);
context.collector.report_with_code(code, issue);
}
fn report_readonly_issue<'ctx>(
context: &mut Context<'ctx, '_>,
block_context: &BlockContext<'ctx>,
code: IssueCode,
access_span: Span,
member_span: Option<Span>,
definition_span: Option<Span>,
) {
let current_scope_str = if let Some(current_class) = block_context.scope.get_class_like_name() {
format!("from within `{current_class}`")
} else {
"from the global scope".to_string()
};
let primary_annotation_span = member_span.unwrap_or(access_span);
let (note, help) = if context.settings.version.is_supported(Feature::ReadonlyPropertyReinitializationInClone) {
(
"Readonly properties can only be initialized once, within `__construct` or `__clone` methods of the declaring class or its descendants.",
"Move this initialization to `__construct` or `__clone`.",
)
} else {
(
"Readonly properties can only be initialized once within `__construct`. Since PHP 8.3, re-initialization is also allowed in `__clone`.",
"Move this initialization to the constructor, or upgrade to PHP 8.3+ to use `__clone` for re-initialization.",
)
};
let mut issue = Issue::error("Cannot modify a readonly property after initialization.")
.with_annotation(
Annotation::primary(primary_annotation_span).with_message("Illegal write to readonly property"),
)
.with_annotation(
Annotation::secondary(access_span).with_message(format!("Write attempt occurs here, {current_scope_str}")),
)
.with_note(note)
.with_help(help);
if let Some(definition_span) = definition_span {
issue = issue.with_annotation(
Annotation::secondary(definition_span).with_message("Property is defined as `readonly` here"),
);
}
context.collector.report_with_code(code, issue);
}