#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubjectExpr {
This,
SelfKw,
StaticKw,
Parent,
Variable(String),
PropertyChain {
base: Box<SubjectExpr>,
property: String,
},
CallExpr {
callee: Box<SubjectExpr>,
args_text: String,
},
MethodCall {
base: Box<SubjectExpr>,
method: String,
},
StaticMethodCall {
class: String,
method: String,
},
StaticAccess {
class: String,
member: String,
},
NewExpr {
class_name: String,
},
ClassName(String),
FunctionCall(String),
ArrayAccess {
base: Box<SubjectExpr>,
segments: Vec<BracketSegment>,
},
InlineArray {
elements: Vec<String>,
index_segments: Vec<BracketSegment>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BracketSegment {
StringKey(String),
ElementAccess,
}
impl SubjectExpr {
pub fn parse(subject: &str) -> Self {
let subject = subject.trim();
if subject.is_empty() {
return SubjectExpr::ClassName(String::new());
}
match subject {
"$this" => return SubjectExpr::This,
"self" => return SubjectExpr::SelfKw,
"static" => return SubjectExpr::StaticKw,
"parent" => return SubjectExpr::Parent,
_ => {}
}
if let Some(class_name) = parse_new_expression_class(subject) {
return SubjectExpr::NewExpr { class_name };
}
if subject.starts_with('[')
&& subject.contains("][")
&& let Some(result) = parse_inline_array(subject)
{
return result;
}
if subject.ends_with(')')
&& let Some((call_body, args_text)) = split_call_subject_raw(subject)
{
let callee = parse_callee(call_body);
return SubjectExpr::CallExpr {
callee: Box::new(callee),
args_text: args_text.to_string(),
};
}
if subject.ends_with(']')
&& let Some(result) = parse_call_array_access(subject)
{
return result;
}
if subject.starts_with('$')
&& subject.contains("::")
&& !subject.ends_with(')')
&& let Some((var_part, member)) = subject.split_once("::")
&& !member.contains("->")
{
return SubjectExpr::StaticMethodCall {
class: var_part.to_string(),
method: member.to_string(),
};
}
if !subject.starts_with('$')
&& subject.contains("::")
&& !subject.ends_with(')')
&& let Some((class_part, member)) = subject.split_once("::")
&& !member.contains("->")
{
return SubjectExpr::StaticAccess {
class: class_part.to_string(),
member: member.to_string(),
};
}
if subject.contains('[')
&& subject.ends_with(']')
&& let Some(result) = parse_variable_array_access(subject)
{
return result;
}
if subject.contains("->")
&& let Some((base_str, prop)) = split_last_arrow_raw(subject)
{
let base = SubjectExpr::parse(base_str);
return SubjectExpr::PropertyChain {
base: Box::new(base),
property: prop.to_string(),
};
}
if subject.starts_with('$') {
return SubjectExpr::Variable(subject.to_string());
}
SubjectExpr::ClassName(subject.to_string())
}
pub fn to_subject_text(&self) -> String {
match self {
SubjectExpr::This => "$this".to_string(),
SubjectExpr::SelfKw => "self".to_string(),
SubjectExpr::StaticKw => "static".to_string(),
SubjectExpr::Parent => "parent".to_string(),
SubjectExpr::Variable(v) => v.clone(),
SubjectExpr::PropertyChain { base, property } => {
format!("{}->{}", base.to_subject_text(), property)
}
SubjectExpr::CallExpr { callee, args_text } => {
let needs_parens = matches!(
callee.as_ref(),
SubjectExpr::PropertyChain { .. }
| SubjectExpr::This
| SubjectExpr::SelfKw
| SubjectExpr::StaticKw
| SubjectExpr::Parent
| SubjectExpr::ArrayAccess { .. }
| SubjectExpr::InlineArray { .. }
| SubjectExpr::CallExpr { .. }
);
if needs_parens {
format!("({})({})", callee.to_subject_text(), args_text)
} else {
format!("{}({})", callee.to_subject_text(), args_text)
}
}
SubjectExpr::MethodCall { base, method } => {
format!("{}->{}", base.to_subject_text(), method)
}
SubjectExpr::StaticMethodCall { class, method } => {
format!("{}::{}", class, method)
}
SubjectExpr::StaticAccess { class, member } => {
format!("{}::{}", class, member)
}
SubjectExpr::NewExpr { class_name } => {
format!("new {}", class_name)
}
SubjectExpr::ClassName(name) => name.clone(),
SubjectExpr::FunctionCall(name) => name.clone(),
SubjectExpr::ArrayAccess { base, segments } => {
let mut s = base.to_subject_text();
for seg in segments {
match seg {
BracketSegment::StringKey(k) => {
s.push_str(&format!("['{}']", k));
}
BracketSegment::ElementAccess => {
s.push_str("[]");
}
}
}
s
}
SubjectExpr::InlineArray {
elements,
index_segments,
} => {
let mut s = format!("[{}]", elements.join(", "));
for seg in index_segments {
match seg {
BracketSegment::StringKey(k) => {
s.push_str(&format!("['{}']", k));
}
BracketSegment::ElementAccess => {
s.push_str("[]");
}
}
}
s
}
}
}
pub fn is_self_like(&self) -> bool {
matches!(
self,
SubjectExpr::This | SubjectExpr::SelfKw | SubjectExpr::StaticKw
)
}
pub fn parse_callee(call_body: &str) -> SubjectExpr {
parse_callee(call_body)
}
}
fn parse_callee(call_body: &str) -> SubjectExpr {
let call_body = call_body.trim();
if call_body.starts_with('(') && call_body.ends_with(')') {
let mut depth = 0i32;
let bytes = call_body.as_bytes();
let mut closes_at_end = false;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
closes_at_end = i == bytes.len() - 1;
break;
}
}
_ => {}
}
}
if closes_at_end {
let inner = &call_body[1..call_body.len() - 1];
return SubjectExpr::parse(inner);
}
}
if let Some(class_name) = call_body
.strip_prefix("new ")
.map(|s| s.trim().trim_start_matches('\\'))
.filter(|s| !s.is_empty())
{
let clean = class_name
.find(|c: char| c == '(' || c.is_whitespace())
.map_or(class_name, |pos| &class_name[..pos]);
return SubjectExpr::NewExpr {
class_name: clean.to_string(),
};
}
if let Some((base_str, method)) = split_last_arrow_raw(call_body) {
let base = SubjectExpr::parse(base_str);
return SubjectExpr::MethodCall {
base: Box::new(base),
method: method.to_string(),
};
}
if let Some(pos) = call_body.rfind("::") {
let class_part = &call_body[..pos];
let method_name = &call_body[pos + 2..];
return SubjectExpr::StaticMethodCall {
class: class_part.to_string(),
method: method_name.to_string(),
};
}
if call_body.starts_with('$') {
return SubjectExpr::Variable(call_body.to_string());
}
SubjectExpr::FunctionCall(call_body.to_string())
}
fn split_last_arrow_raw(subject: &str) -> Option<(&str, &str)> {
let bytes = subject.as_bytes();
let mut depth = 0i32;
let mut last_arrow: Option<(usize, usize)> = None;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
b'-' if depth == 0 && i + 1 < bytes.len() && bytes[i + 1] == b'>' => {
let arrow_start = if i > 0 && bytes[i - 1] == b'?' {
i - 1
} else {
i
};
let prop_start = i + 2;
last_arrow = Some((arrow_start, prop_start));
i += 2;
continue;
}
_ => {}
}
i += 1;
}
let (arrow_start, prop_start) = last_arrow?;
if prop_start >= subject.len() {
return None;
}
let base = &subject[..arrow_start];
let prop = &subject[prop_start..];
if base.is_empty() || prop.is_empty() {
return None;
}
Some((base, prop))
}
fn split_call_subject_raw(subject: &str) -> Option<(&str, &str)> {
let inner = subject.strip_suffix(')')?;
let bytes = inner.as_bytes();
let mut depth: u32 = 0;
let mut open = None;
for i in (0..bytes.len()).rev() {
match bytes[i] {
b')' => depth += 1,
b'(' => {
if depth == 0 {
open = Some(i);
break;
}
depth -= 1;
}
_ => {}
}
}
let open = open?;
let call_body = &inner[..open];
let args_text = inner[open + 1..].trim();
if call_body.is_empty() {
return None;
}
Some((call_body, args_text))
}
pub(crate) fn parse_new_expression_class(s: &str) -> Option<String> {
let inner = if s.starts_with('(') && s.ends_with(')') {
&s[1..s.len() - 1]
} else {
s
};
let rest = inner.trim().strip_prefix("new ")?;
let rest = rest.trim_start();
let end = rest
.find(|c: char| c == '(' || c.is_whitespace())
.unwrap_or(rest.len());
let class_name = rest[..end].trim_start_matches('\\');
if class_name.is_empty()
|| class_name == "class"
|| !class_name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '\\')
{
return None;
}
Some(class_name.to_string())
}
fn parse_variable_array_access(subject: &str) -> Option<SubjectExpr> {
let first_bracket = subject.find('[')?;
let base_var = &subject[..first_bracket];
if base_var.len() < 2 {
return None;
}
let mut segments = Vec::new();
let mut rest = &subject[first_bracket..];
while rest.starts_with('[') {
let close = rest.find(']')?;
let inner = rest[1..close].trim();
if let Some(key) = crate::util::unquote_php_string(inner) {
segments.push(BracketSegment::StringKey(key.to_string()));
} else {
segments.push(BracketSegment::ElementAccess);
}
rest = &rest[close + 1..];
}
if segments.is_empty() {
return None;
}
let mut result = SubjectExpr::ArrayAccess {
base: Box::new(SubjectExpr::parse(base_var)),
segments,
};
while !rest.is_empty() {
let (arrow_len, is_nullsafe) = if rest.starts_with("?->") {
(3, true)
} else if rest.starts_with("->") {
(2, false)
} else {
break;
};
let after_arrow = &rest[arrow_len..];
if after_arrow.is_empty() {
break;
}
let prop_end = after_arrow.find('[').unwrap_or(after_arrow.len());
let prop_name = &after_arrow[..prop_end];
if prop_name.is_empty() {
break;
}
let _ = is_nullsafe; result = SubjectExpr::PropertyChain {
base: Box::new(result),
property: prop_name.to_string(),
};
rest = &after_arrow[prop_end..];
if rest.starts_with('[') {
let mut new_segments = Vec::new();
while rest.starts_with('[') {
let close = match rest.find(']') {
Some(c) => c,
None => break,
};
let inner = rest[1..close].trim();
if let Some(key) = crate::util::unquote_php_string(inner) {
new_segments.push(BracketSegment::StringKey(key.to_string()));
} else {
new_segments.push(BracketSegment::ElementAccess);
}
rest = &rest[close + 1..];
}
if !new_segments.is_empty() {
result = SubjectExpr::ArrayAccess {
base: Box::new(result),
segments: new_segments,
};
}
}
}
Some(result)
}
fn parse_call_array_access(subject: &str) -> Option<SubjectExpr> {
let bytes = subject.as_bytes();
let mut depth = 0i32;
let mut split = None;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
split = Some(i + 1); }
}
_ => {}
}
}
let split = split?;
let call_part = &subject[..split];
let bracket_part = &subject[split..];
if !call_part.ends_with(')') {
return None;
}
let mut segments = Vec::new();
let mut rest = bracket_part;
while rest.starts_with('[') {
let close = rest.find(']')?;
let inner = rest[1..close].trim();
if let Some(key) = crate::util::unquote_php_string(inner) {
segments.push(BracketSegment::StringKey(key.to_string()));
} else {
segments.push(BracketSegment::ElementAccess);
}
rest = &rest[close + 1..];
}
if segments.is_empty() {
return None;
}
let base = SubjectExpr::parse(call_part);
if !matches!(base, SubjectExpr::CallExpr { .. }) {
return None;
}
Some(SubjectExpr::ArrayAccess {
base: Box::new(base),
segments,
})
}
fn parse_inline_array(subject: &str) -> Option<SubjectExpr> {
let split_pos = subject.find("][")?;
let literal_text = &subject[..split_pos + 1];
if !literal_text.starts_with('[') || !literal_text.ends_with(']') {
return None;
}
let inner = literal_text[1..literal_text.len() - 1].trim();
let elements: Vec<String> = inner.split(',').map(|e| e.trim().to_string()).collect();
let index_part = &subject[split_pos + 1..];
let mut index_segments = Vec::new();
let mut rest = index_part;
while rest.starts_with('[') {
let close = rest.find(']')?;
let idx_inner = rest[1..close].trim();
if let Some(key) = idx_inner
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.or_else(|| {
idx_inner
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
})
{
index_segments.push(BracketSegment::StringKey(key.to_string()));
} else {
index_segments.push(BracketSegment::ElementAccess);
}
rest = &rest[close + 1..];
}
Some(SubjectExpr::InlineArray {
elements,
index_segments,
})
}
#[cfg(test)]
#[path = "subject_expr_tests.rs"]
mod tests;