use std::ops::ControlFlow;
use php_ast::visitor::{Visitor, walk_expr};
use php_ast::{Expr, ExprKind, Stmt};
use tower_lsp_server::ls_types::Position;
use crate::text::utf16_offset_to_byte;
pub(crate) const REQUEST_FIELD_METHODS: &[&str] = &["input", "get", "post", "query"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Receiver {
Variable(String),
ThisKeyword,
}
pub(crate) fn method_call_string_prefix(
source: &str,
position: Position,
) -> Option<(Receiver, String)> {
let lines: Vec<&str> = source.lines().collect();
let line = *lines.get(position.line as usize)?;
let byte_col = utf16_offset_to_byte(line, position.character as usize);
let before = &line[..byte_col];
let quote_idx = before.rfind(['\'', '"'])?;
let before_quote = before[..quote_idx].trim_end();
let before_paren = before_quote.strip_suffix('(')?.trim_end();
let method_start = REQUEST_FIELD_METHODS.iter().find_map(|m| {
let m_len = m.len();
if before_paren.len() >= m_len
&& before_paren[before_paren.len() - m_len..].eq_ignore_ascii_case(m)
&& word_boundary_before(before_paren, before_paren.len() - m_len)
{
Some(before_paren.len() - m_len)
} else {
None
}
})?;
let before_method = before_paren[..method_start].trim_end();
let arrow_len = if before_method.ends_with("?->") {
3
} else if before_method.ends_with("->") {
2
} else {
return None;
};
let receiver_text = &before_method[..before_method.len() - arrow_len];
let var_name: String = receiver_text
.chars()
.rev()
.take_while(|&c| c.is_alphanumeric() || c == '_' || c == '$')
.collect::<Vec<char>>()
.into_iter()
.rev()
.collect();
let bare = var_name.trim_start_matches('$');
if !bare.eq_ignore_ascii_case("request") {
return None;
}
Some((
Receiver::Variable(bare.to_string()),
before[quote_idx + 1..].to_string(),
))
}
fn word_boundary_before(s: &str, idx: usize) -> bool {
idx == 0 || !matches!(s.as_bytes()[idx - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')
}
pub(crate) fn harvest_fields(stmts: &[Stmt<'_, '_>], receiver: &Receiver) -> Vec<String> {
struct Collector<'r> {
receiver: &'r Receiver,
out: Vec<String>,
}
impl<'arena, 'src> Visitor<'arena, 'src> for Collector<'_> {
fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
if let ExprKind::MethodCall(mc) = &expr.kind
&& let ExprKind::Identifier(method) = &mc.method.kind
&& REQUEST_FIELD_METHODS
.iter()
.any(|m| method.eq_ignore_ascii_case(m))
&& receiver_matches(mc.object, self.receiver)
&& let Some(arg) = mc.args.first()
&& arg.name.is_none()
&& let Some(arg_value) = &arg.value
&& let ExprKind::String(s) = &arg_value.kind
&& !self.out.iter().any(|f| f == *s)
{
self.out.push((*s).to_string());
}
walk_expr(self, expr)
}
}
let mut collector = Collector {
receiver,
out: Vec::new(),
};
for stmt in stmts {
let _ = collector.visit_stmt(stmt);
}
collector.out
}
fn receiver_matches(object: &Expr<'_, '_>, receiver: &Receiver) -> bool {
match (&object.kind, receiver) {
(ExprKind::Variable(v), Receiver::Variable(name)) => v.eq_ignore_ascii_case(name),
(ExprKind::Variable(v), Receiver::ThisKeyword) => v.as_str() == "this",
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::ast::ParsedDoc;
fn parse(src: &str) -> ParsedDoc {
ParsedDoc::parse(src.to_string())
}
#[test]
fn method_call_string_prefix_matches_request_input() {
let src = "<?php\n$request->input('em";
let pos = Position {
line: 1,
character: 19,
};
let (receiver, prefix) = method_call_string_prefix(src, pos).unwrap();
assert_eq!(receiver, Receiver::Variable("request".to_string()));
assert_eq!(prefix, "em");
}
#[test]
fn method_call_string_prefix_rejects_unrelated_variable() {
let src = "<?php\n$foo->input('em";
let pos = Position {
line: 1,
character: 15,
};
assert!(method_call_string_prefix(src, pos).is_none());
}
#[test]
fn method_call_string_prefix_rejects_unrelated_method() {
let src = "<?php\n$request->validate('em";
let pos = Position {
line: 1,
character: 22,
};
assert!(method_call_string_prefix(src, pos).is_none());
}
#[test]
fn harvest_fields_collects_distinct_calls_on_matching_variable() {
let doc = parse(
"<?php\nfunction f() {\n $request->input('email');\n $request->get('name');\n $request->input('email');\n $other->input('ignored');\n}\n",
);
let fields = harvest_fields(
&doc.program().stmts,
&Receiver::Variable("request".to_string()),
);
assert_eq!(fields, vec!["email".to_string(), "name".to_string()]);
}
#[test]
fn harvest_fields_matches_this_keyword() {
let doc = parse(
"<?php\nclass R {\n public function prep() {\n $this->input('email');\n }\n}\n",
);
let fields = harvest_fields(&doc.program().stmts, &Receiver::ThisKeyword);
assert_eq!(fields, vec!["email".to_string()]);
}
}