use std::ops::ControlFlow;
use php_ast::visitor::{Visitor, walk_expr};
use php_ast::{ArrayElement, ClassMemberKind, Expr, ExprKind, NamespaceBody, Span, Stmt, StmtKind};
use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position};
use crate::document::ast::ParsedDoc;
const RULE_NAMES: &[&str] = &[
"accepted",
"accepted_if",
"active_url",
"after",
"after_or_equal",
"alpha",
"alpha_dash",
"alpha_num",
"array",
"ascii",
"bail",
"before",
"before_or_equal",
"between",
"boolean",
"confirmed",
"contains",
"current_password",
"date",
"date_equals",
"date_format",
"decimal",
"declined",
"declined_if",
"different",
"digits",
"digits_between",
"dimensions",
"distinct",
"doesnt_start_with",
"doesnt_end_with",
"email",
"ends_with",
"enum",
"exclude",
"exclude_if",
"exclude_unless",
"exclude_with",
"exclude_without",
"exists",
"extensions",
"file",
"filled",
"gt",
"gte",
"hex_color",
"image",
"in",
"in_array",
"integer",
"ip",
"ipv4",
"ipv6",
"json",
"lowercase",
"lt",
"lte",
"mac_address",
"max",
"max_digits",
"mimes",
"mimetypes",
"min",
"min_digits",
"missing",
"missing_if",
"missing_unless",
"missing_with",
"missing_with_all",
"multiple_of",
"not_in",
"not_regex",
"nullable",
"numeric",
"present",
"present_if",
"present_unless",
"present_with",
"present_with_all",
"prohibited",
"prohibited_if",
"prohibited_unless",
"prohibits",
"regex",
"required",
"required_array_keys",
"required_if",
"required_if_accepted",
"required_unless",
"required_with",
"required_with_all",
"required_without",
"required_without_all",
"same",
"size",
"sometimes",
"starts_with",
"string",
"timezone",
"ulid",
"unique",
"uploaded",
"url",
"uuid",
];
pub(crate) fn validation_rule_completions(prefix: &str) -> Vec<CompletionItem> {
let mut items: Vec<CompletionItem> = RULE_NAMES
.iter()
.filter(|name| name.starts_with(prefix))
.map(|name| CompletionItem {
label: (*name).to_string(),
kind: Some(CompletionItemKind::KEYWORD),
insert_text: Some((*name).to_string()),
..Default::default()
})
.collect();
items.sort_by(|a, b| a.label.cmp(&b.label));
items
}
fn is_ident(expr: &Expr<'_, '_>, name: &str) -> bool {
matches!(&expr.kind, ExprKind::Identifier(n) if n.eq_ignore_ascii_case(name))
}
fn rule_value_span_at(
elements: &[ArrayElement<'_, '_>],
cursor: u32,
require_key: bool,
) -> Option<Span> {
for el in elements {
if require_key && el.key.is_none() {
continue;
}
if el.value.span.start <= cursor && cursor <= el.value.span.end {
return match &el.value.kind {
ExprKind::String(_) => Some(el.value.span),
ExprKind::Array(nested) => rule_value_span_at(nested, cursor, false),
_ => None,
};
}
}
None
}
struct RulesCallVisitor {
cursor: u32,
found: Option<Span>,
}
impl<'arena, 'src> Visitor<'arena, 'src> for RulesCallVisitor {
fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
if self.found.is_some() {
return ControlFlow::Break(());
}
let array_arg = match &expr.kind {
ExprKind::MethodCall(mc) if is_ident(mc.method, "validate") => mc.args.first(),
ExprKind::StaticMethodCall(s) if is_ident(s.method, "validate") => s.args.first(),
ExprKind::StaticMethodCall(s)
if is_ident(s.method, "make") && is_ident(s.class, "Validator") =>
{
s.args.get(1)
}
_ => None,
};
if let Some(arg) = array_arg
&& let Some(arg_value) = &arg.value
&& let ExprKind::Array(elements) = &arg_value.kind
&& arg_value.span.start <= self.cursor
&& self.cursor <= arg_value.span.end
&& let Some(span) = rule_value_span_at(elements, self.cursor, true)
{
self.found = Some(span);
return ControlFlow::Break(());
}
walk_expr(self, expr)
}
}
fn find_return_array_span<'a>(stmts: &[Stmt<'a, 'a>], cursor: u32) -> Option<Span> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Return(Some(expr)) => {
if let ExprKind::Array(elements) = &expr.kind
&& expr.span.start <= cursor
&& cursor <= expr.span.end
{
return rule_value_span_at(elements, cursor, true);
}
}
StmtKind::Block(inner) => {
if let Some(span) = find_return_array_span(&inner.stmts, cursor) {
return Some(span);
}
}
_ => {}
}
}
None
}
fn find_rules_method_return_span<'a>(stmts: &[Stmt<'a, 'a>], cursor: u32) -> Option<Span> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c) => {
if !(stmt.span.start <= cursor && cursor <= stmt.span.end) {
continue;
}
for member in c.body.members.iter() {
let ClassMemberKind::Method(m) = &member.kind else {
continue;
};
if m.name != "rules" {
continue;
}
let Some(body) = &m.body else { continue };
if !(body.span.start <= cursor && cursor <= body.span.end) {
continue;
}
if let Some(span) = find_return_array_span(&body.stmts, cursor) {
return Some(span);
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let Some(span) = find_rules_method_return_span(&inner.stmts, cursor)
{
return Some(span);
}
}
_ => {}
}
}
None
}
pub(crate) fn validation_rule_prefix(
doc: &ParsedDoc,
source: &str,
position: Position,
) -> Option<String> {
let sv = doc.view();
let cursor = sv.byte_of_position(position);
let mut visitor = RulesCallVisitor {
cursor,
found: None,
};
for stmt in doc.program().stmts.iter() {
if matches!(visitor.visit_stmt(stmt), ControlFlow::Break(())) {
break;
}
}
let span = visitor
.found
.or_else(|| find_rules_method_return_span(&doc.program().stmts, cursor))?;
let start = span.start as usize + 1; let end = cursor as usize;
if end < start || end > source.len() {
return None;
}
let raw = &source[start..end];
Some(raw.rsplit('|').next().unwrap_or(raw).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(src: &str) -> ParsedDoc {
ParsedDoc::parse(src.to_string())
}
#[test]
fn validation_rule_completions_filters_by_prefix_and_sorts() {
let items = validation_rule_completions("requ");
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert_eq!(
labels,
vec![
"required",
"required_array_keys",
"required_if",
"required_if_accepted",
"required_unless",
"required_with",
"required_with_all",
"required_without",
"required_without_all",
]
);
}
#[test]
fn validation_rule_completions_empty_for_unknown_prefix() {
assert!(validation_rule_completions("zzz").is_empty());
}
#[test]
fn prefix_matches_pipe_form_first_rule_in_request_validate() {
let src = "<?php\nclass C {\n public function store($request) {\n $request->validate(['email' => 'requ\n";
let doc = parse(src);
let pos = Position {
line: 3,
character: 44,
};
assert_eq!(
validation_rule_prefix(&doc, src, pos).as_deref(),
Some("requ")
);
}
#[test]
fn prefix_matches_pipe_form_second_rule() {
let src = "<?php\nclass C {\n public function store($request) {\n $request->validate(['email' => 'required|em\n";
let doc = parse(src);
let pos = Position {
line: 3,
character: 51,
};
assert_eq!(
validation_rule_prefix(&doc, src, pos).as_deref(),
Some("em")
);
}
#[test]
fn prefix_matches_array_form_element() {
let src = "<?php\nclass C {\n public function store($request) {\n $request->validate(['email' => ['required', 'em\n";
let doc = parse(src);
let pos = Position {
line: 3,
character: 55,
};
assert_eq!(
validation_rule_prefix(&doc, src, pos).as_deref(),
Some("em")
);
}
#[test]
fn prefix_matches_validator_make_second_argument() {
let src = "<?php\nValidator::make($data, ['email' => 'requ\n";
let doc = parse(src);
let pos = Position {
line: 1,
character: 40,
};
assert_eq!(
validation_rule_prefix(&doc, src, pos).as_deref(),
Some("requ")
);
}
#[test]
fn prefix_matches_rules_method_return_array() {
let src = "<?php\nclass StoreUserRequest {\n public function rules() {\n return ['email' => 'requ\n";
let doc = parse(src);
let pos = Position {
line: 3,
character: 32,
};
assert_eq!(
validation_rule_prefix(&doc, src, pos).as_deref(),
Some("requ")
);
}
#[test]
fn prefix_none_for_field_name_position() {
let src = "<?php\n$request->validate(['ema";
let doc = parse(src);
let pos = Position {
line: 1,
character: 24,
};
assert!(validation_rule_prefix(&doc, src, pos).is_none());
}
#[test]
fn prefix_none_for_unrelated_array() {
let src = "<?php\n$x = ['email' => 'requ";
let doc = parse(src);
let pos = Position {
line: 1,
character: 22,
};
assert!(validation_rule_prefix(&doc, src, pos).is_none());
}
#[test]
fn prefix_none_for_validate_call_on_unrelated_class() {
let src = "<?php\nFoo::make($data, ['email' => 'requ";
let doc = parse(src);
let pos = Position {
line: 1,
character: 34,
};
assert!(validation_rule_prefix(&doc, src, pos).is_none());
}
}