use mago_allocator::Arena;
use mago_word::Word;
use mago_word::word;
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;
use mago_bytes::BytesDisplay;
pub fn check_method_visibility<'ctx, A>(
context: &mut Context<'ctx, '_, A>,
block_context: &BlockContext<'ctx>,
fqcn: &[u8],
method_name: &[u8],
access_span: Span,
member_span: Option<Span>,
) -> bool
where
A: Arena,
{
let declaring_class = context.codebase.get_declaring_method_class(fqcn, method_name).unwrap_or_else(|| word(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.as_bytes(),
block_context.scope.get_class_like_name(),
);
if !is_visible {
let declaring_class_name = context
.codebase
.get_class_like(declaring_class.as_bytes())
.map_or_else(|| declaring_class, |metadata| metadata.original_name);
let issue_title =
format!("Cannot access {} method `{}::{}`.", visibility, declaring_class_name, BytesDisplay(method_name));
let help_text = format!(
"Change the visibility of method `{}` to `public`, or call it from an allowed scope.",
BytesDisplay(method_name)
);
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, A>(
context: &mut Context<'ctx, '_, A>,
block_context: &BlockContext<'ctx>,
fqcn: &[u8],
property_name: &[u8],
access_span: Span,
member_span: Option<Span>,
) -> bool
where
A: Arena,
{
let property_name = word(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.as_bytes()) 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(&word(b"set"))
&& !property_metadata.hooks.contains_key(&word(b"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.as_bytes(),
block_context.scope.get_class_like_name(),
);
if !is_visible {
let issue_title = format!(
"Cannot read {} property `{}` from class `{}`.",
visibility, 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, A>(
context: &mut Context<'ctx, '_, A>,
block_context: &BlockContext<'ctx>,
fqcn: &[u8],
property_name: &[u8],
access_span: Span,
member_span: Option<Span>,
) -> bool
where
A: Arena,
{
let property_name = word(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.as_bytes()) 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(&word(b"get"))
&& !property_metadata.hooks.contains_key(&word(b"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.as_bytes(),
block_context.scope.get_class_like_name(),
);
if !is_visible {
let issue_title = format!(
"Cannot write to {} property `{}` on class `{}`.",
visibility, 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.as_bytes(),
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),
);
} else {
}
is_visible
}
fn is_visible_from_scope<A>(
context: &Context<'_, '_, A>,
visibility: Visibility,
declaring_class_id: &[u8],
current_class_opt: Option<Word>,
) -> bool
where
A: Arena,
{
match visibility {
Visibility::Public => true,
Visibility::Protected => {
if let Some(current_class_id) = current_class_opt {
current_class_id.as_bytes().eq_ignore_ascii_case(declaring_class_id)
|| context.codebase.is_instance_of(current_class_id.as_bytes(), declaring_class_id)
|| context.codebase.is_instance_of(declaring_class_id, current_class_id.as_bytes())
|| is_visible_via_required_extends(context, current_class_id.as_bytes(), declaring_class_id)
} else {
false
}
}
Visibility::Private => {
if let Some(current_class_id) = current_class_opt {
current_class_id.as_bytes().eq_ignore_ascii_case(declaring_class_id)
|| context.codebase.class_uses_trait(current_class_id.as_bytes(), declaring_class_id)
|| context.codebase.class_uses_trait(declaring_class_id, current_class_id.as_bytes())
} else {
false
}
}
}
}
fn is_visible_via_required_extends<A>(
context: &Context<'_, '_, A>,
current_class_id: &[u8],
declaring_class_id: &[u8],
) -> bool
where
A: Arena,
{
let current_class_id_lc = mago_word::ascii_lowercase_word(current_class_id);
let Some(current_metadata) = context.codebase.get_class_like(current_class_id_lc.as_bytes()) 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.as_bytes(), declaring_class_id)
|| context.codebase.class_uses_trait(required_class.as_bytes(), declaring_class_id)
{
return true;
}
}
false
}
fn can_initialize_readonly_property<A>(
context: &Context<'_, '_, A>,
declaring_class_id: &[u8],
current_class_opt: Option<Word>,
current_function_opt: Option<&FunctionLikeMetadata>,
) -> bool
where
A: Arena,
{
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.as_bytes().eq_ignore_ascii_case(b"__clone")
{
return true;
}
false
});
is_allowed_method
&& current_class_opt.is_some_and(|current_class_id| {
current_class_id.as_bytes().eq_ignore_ascii_case(declaring_class_id)
|| context.codebase.is_instance_of(current_class_id.as_bytes(), declaring_class_id)
|| context.codebase.is_instance_of(declaring_class_id, current_class_id.as_bytes())
})
}
fn report_visibility_issue<'ctx, A>(
context: &mut Context<'ctx, '_, A>,
block_context: &BlockContext<'ctx>,
code: IssueCode,
title: String,
visibility: Visibility,
access_span: Span,
member_span: Option<Span>,
definition_span: Option<Span>,
help_text: String,
) where
A: Arena,
{
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)),
)
.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)),
);
}
issue = issue.with_help(help_text);
context.collector.report_with_code(code, issue);
}
fn report_readonly_issue<'ctx, A>(
context: &mut Context<'ctx, '_, A>,
block_context: &BlockContext<'ctx>,
code: IssueCode,
access_span: Span,
member_span: Option<Span>,
definition_span: Option<Span>,
) where
A: Arena,
{
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);
}