use std::collections::HashMap;
use php_ast::{
ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Stmt, StmtKind, TypeHint,
TypeHintKind,
};
use tower_lsp::lsp_types::Position;
use crate::document::ast::{ParsedDoc, SourceView};
use crate::lang::docblock::{docblock_before, parse_docblock};
use crate::lang::phpstorm_meta::PhpStormMeta;
use crate::text::fqn_short_name;
#[derive(Debug, Default, Clone)]
pub struct TypeMap(HashMap<String, String>);
impl TypeMap {
#[cfg(test)]
pub fn from_doc(doc: &ParsedDoc) -> Self {
Self::from_doc_with_meta(doc, None)
}
pub fn from_doc_with_meta(doc: &ParsedDoc, meta: Option<&PhpStormMeta>) -> Self {
let mut map = HashMap::new();
collect_types_stmts(
doc.source(),
&doc.program().stmts,
&mut map,
meta,
None,
doc,
);
TypeMap(map)
}
pub fn from_doc_at_position(
doc: &ParsedDoc,
meta: Option<&PhpStormMeta>,
position: Position,
) -> Self {
let cursor_byte = {
let line_starts = doc.line_starts();
let line = position.line as usize;
if line < line_starts.len() {
let line_start = line_starts[line] as usize;
let col_byte = crate::text::utf16_offset_to_byte(
&doc.source()[line_start..],
position.character as usize,
);
Some((line_start + col_byte) as u32)
} else {
None
}
};
let mut map = HashMap::new();
collect_types_stmts(
doc.source(),
&doc.program().stmts,
&mut map,
meta,
cursor_byte,
doc,
);
TypeMap(map)
}
pub fn get<'a>(&'a self, var: &str) -> Option<&'a str> {
self.0.get(var).map(|s| s.as_str())
}
}
fn type_hint_to_class_string(
hint: &TypeHint<'_, '_>,
enclosing_class: Option<&str>,
doc: Option<&ParsedDoc>,
) -> Option<String> {
use mir_types::Atomic;
let union = mir_analyzer::parser::type_from_hint(hint, enclosing_class);
let classes: Vec<String> = union
.types
.iter()
.filter_map(|a| match a {
Atomic::TNamedObject { fqcn, .. }
| Atomic::TSelf { fqcn }
| Atomic::TStaticObject { fqcn } => {
let short = fqn_short_name(fqcn);
Some(short.to_string())
}
Atomic::TParent { fqcn } => {
if let (Some(doc), Some(enc_class)) = (doc, enclosing_class) {
if let Some(parent) = parent_class_name(doc, enc_class) {
let short = fqn_short_name(&parent);
Some(short.to_string())
} else {
let short = fqn_short_name(fqcn);
Some(short.to_string())
}
} else {
let short = fqn_short_name(fqcn);
Some(short.to_string())
}
}
Atomic::TIntersection { parts } => {
let intersection_classes: Vec<String> = parts
.iter()
.flat_map(|part| {
part.types.iter().filter_map(|a| match a {
Atomic::TNamedObject { fqcn, .. }
| Atomic::TSelf { fqcn }
| Atomic::TStaticObject { fqcn } => {
let short = fqn_short_name(fqcn);
Some(short.to_string())
}
Atomic::TParent { fqcn } => {
if let (Some(doc), Some(enc_class)) = (doc, enclosing_class) {
if let Some(parent) = parent_class_name(doc, enc_class) {
let short = fqn_short_name(&parent);
Some(short.to_string())
} else {
let short =
fqcn.rsplit('\\').next().unwrap_or(fqcn.as_ref());
Some(short.to_string())
}
} else {
let short = fqn_short_name(fqcn);
Some(short.to_string())
}
}
_ => None,
})
})
.collect();
if intersection_classes.is_empty() {
None
} else {
Some(intersection_classes.join("|"))
}
}
_ => None,
})
.collect();
if classes.is_empty() {
None
} else {
Some(classes.join("|"))
}
}
fn docblock_class_parts(type_hint: &str) -> Vec<String> {
type_hint
.split('|')
.map(|p| p.trim().trim_start_matches('\\').trim_start_matches('?'))
.filter(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
.filter_map(|p| p.rsplit('\\').next())
.map(|p| p.to_string())
.collect()
}
fn collect_param_docblock_types(source: &str, span_start: u32, map: &mut HashMap<String, String>) {
let Some(raw) = docblock_before(source, span_start) else {
return;
};
let db = parse_docblock(&raw);
for param in &db.params {
let classes = docblock_class_parts(¶m.type_hint);
if classes.is_empty() {
continue;
}
let key = if param.name.starts_with('$') {
param.name.clone()
} else {
format!("${}", param.name)
};
map.entry(key).or_insert_with(|| classes.join("|"));
}
}
#[allow(clippy::too_many_arguments)]
fn collect_types_stmts(
source: &str,
stmts: &[Stmt<'_, '_>],
map: &mut HashMap<String, String>,
meta: Option<&PhpStormMeta>,
cursor_byte: Option<u32>,
doc: &ParsedDoc,
) {
for stmt in stmts {
if let Some(raw) = docblock_before(source, stmt.span.start) {
let db = parse_docblock(&raw);
if let Some(type_str) = db.var_type {
let class_name = docblock_class_parts(&type_str).into_iter().next();
if let Some(class_name) = class_name {
if let Some(vname) = db.var_name {
map.insert(format!("${}", vname.as_str()), class_name);
} else if let StmtKind::Expression(e) = &stmt.kind
&& let ExprKind::Assign(a) = &e.kind
&& let ExprKind::Variable(vn) = &a.target.kind
{
map.insert(format!("${}", vn.as_str()), class_name);
}
}
}
}
match &stmt.kind {
StmtKind::Expression(e) => collect_types_expr(source, e, map, meta, cursor_byte, doc),
StmtKind::Function(f) => {
let in_scope =
cursor_byte.is_none_or(|c| stmt.span.start <= c && c <= stmt.span.end);
if !in_scope {
continue;
}
collect_param_docblock_types(source, stmt.span.start, map);
for p in f.params.iter() {
if let Some(hint) = &p.type_hint
&& let Some(class_str) = type_hint_to_class_string(hint, None, Some(doc))
{
map.insert(format!("${}", p.name), class_str);
}
}
collect_types_stmts(source, &f.body.stmts, map, meta, cursor_byte, doc);
}
StmtKind::Class(c) => {
let class_name = c.name.map(|n| n.to_string());
for member in c.body.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind {
let in_scope = cursor_byte
.is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
if !in_scope {
continue;
}
collect_param_docblock_types(source, member.span.start, map);
for p in m.params.iter() {
if let Some(hint) = &p.type_hint
&& let Some(class_str) = type_hint_to_class_string(
hint,
class_name.as_deref(),
Some(doc),
)
{
map.insert(format!("${}", p.name), class_str);
}
}
if !m.is_static
&& let Some(ref cname) = class_name
{
map.insert("$this".to_string(), cname.clone());
}
if let Some(body) = &m.body {
collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
}
}
}
}
StmtKind::Trait(t) => {
for member in t.body.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind {
let in_scope = cursor_byte
.is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
if !in_scope {
continue;
}
for p in m.params.iter() {
if let Some(hint) = &p.type_hint
&& let Some(class_str) =
type_hint_to_class_string(hint, None, Some(doc))
{
map.insert(format!("${}", p.name), class_str);
}
}
if let Some(body) = &m.body {
collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
}
}
}
}
StmtKind::Enum(e) => {
for member in e.body.members.iter() {
if let EnumMemberKind::Method(m) = &member.kind {
let in_scope = cursor_byte
.is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
if !in_scope {
continue;
}
for p in m.params.iter() {
if let Some(hint) = &p.type_hint
&& let Some(class_str) =
type_hint_to_class_string(hint, None, Some(doc))
{
map.insert(format!("${}", p.name), class_str);
}
}
if let Some(body) = &m.body {
collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
}
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_types_stmts(source, &inner.stmts, map, meta, cursor_byte, doc);
}
}
StmtKind::If(if_stmt) => {
collect_types_stmts(
source,
std::slice::from_ref(if_stmt.then_branch),
map,
meta,
cursor_byte,
doc,
);
for elseif in if_stmt.elseif_branches.iter() {
collect_types_stmts(
source,
std::slice::from_ref(&elseif.body),
map,
meta,
cursor_byte,
doc,
);
}
if let Some(else_branch) = if_stmt.else_branch {
collect_types_stmts(
source,
std::slice::from_ref(else_branch),
map,
meta,
cursor_byte,
doc,
);
}
}
StmtKind::Foreach(f) => {
collect_types_stmts(
source,
std::slice::from_ref(f.body),
map,
meta,
cursor_byte,
doc,
);
}
StmtKind::TryCatch(t) => {
collect_types_stmts(source, &t.body.stmts, map, meta, cursor_byte, doc);
for catch in t.catches.iter() {
collect_types_stmts(source, &catch.body.stmts, map, meta, cursor_byte, doc);
}
if let Some(finally) = &t.finally {
collect_types_stmts(source, &finally.stmts, map, meta, cursor_byte, doc);
}
}
StmtKind::StaticVar(vars) => {
for var in vars.iter() {
let var_key = format!("${}", &var.name.to_string());
if let Some(default) = &var.default {
if let ExprKind::New(new_expr) = &default.kind
&& let Some(class_name) = extract_class_name(new_expr.class)
{
map.insert(var_key.clone(), class_name);
}
if let ExprKind::Array(_) = &default.kind {
map.insert(var_key, "array".to_string());
}
}
}
}
_ => {}
}
}
}
fn collect_types_expr(
source: &str,
expr: &php_ast::Expr<'_, '_>,
map: &mut HashMap<String, String>,
meta: Option<&PhpStormMeta>,
cursor_byte: Option<u32>,
doc: &ParsedDoc,
) {
match &expr.kind {
ExprKind::Assign(assign) => {
if let ExprKind::Variable(var_name) = &assign.target.kind {
if let ExprKind::New(new_expr) = &assign.value.kind
&& let Some(class_name) = extract_class_name(new_expr.class)
{
map.insert(format!("${}", var_name.as_str()), class_name);
}
if let Some(meta) = meta
&& let Some(inferred) = infer_from_meta_method_call(assign.value, map, meta)
{
map.insert(format!("${}", var_name.as_str()), inferred);
}
}
collect_types_expr(source, assign.value, map, meta, cursor_byte, doc);
}
ExprKind::Closure(c) => {
for p in c.params.iter() {
if let Some(hint) = &p.type_hint
&& let TypeHintKind::Named(name) = &hint.kind
{
map.insert(format!("${}", p.name), name.to_string_repr().to_string());
}
}
let use_var_snapshot: Vec<(String, String)> = c
.use_vars
.iter()
.filter_map(|uv| {
let key = format!("${}", &uv.name.to_string());
map.get(&key).map(|ty| (key, ty.clone()))
})
.collect();
collect_types_stmts(source, &c.body.stmts, map, meta, cursor_byte, doc);
for (key, ty) in use_var_snapshot {
map.insert(key, ty);
}
}
ExprKind::ArrowFunction(af) => {
for p in af.params.iter() {
if let Some(hint) = &p.type_hint
&& let TypeHintKind::Named(name) = &hint.kind
{
map.insert(format!("${}", p.name), name.to_string_repr().to_string());
}
}
collect_types_expr(source, af.body, map, meta, cursor_byte, doc);
}
_ => {}
}
}
fn extract_class_name(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
match &expr.kind {
ExprKind::Identifier(name) => Some(name.as_str().to_string()),
_ => None,
}
}
fn infer_from_meta_method_call(
expr: &php_ast::Expr<'_, '_>,
var_map: &HashMap<String, String>,
meta: &PhpStormMeta,
) -> Option<String> {
let ExprKind::MethodCall(m) = &expr.kind else {
return None;
};
let receiver_class = match &m.object.kind {
ExprKind::Variable(v) => {
let key = format!("${}", v.as_str());
var_map.get(&key)?.clone()
}
_ => return None,
};
let method_name = match &m.method.kind {
ExprKind::Identifier(n) => n.to_string(),
_ => return None,
};
let arg = m.args.first()?;
let arg_str = match &arg.value.kind {
ExprKind::String(s) => s.trim_start_matches('\\').to_string(),
ExprKind::ClassConstAccess(c) if c.member.name_str() == Some("class") => {
match &c.class.kind {
ExprKind::Identifier(n) => n
.trim_start_matches('\\')
.rsplit('\\')
.next()
.unwrap_or(n)
.to_string(),
_ => return None,
}
}
_ => return None,
};
meta.resolve_return_type(&receiver_class, &method_name, &arg_str)
.map(|s| s.to_string())
}
pub fn parent_class_name(doc: &ParsedDoc, class_name: &str) -> Option<String> {
parent_in_stmts(&doc.program().stmts, class_name)
}
fn parent_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str) -> Option<String> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c)
if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
{
return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let found @ Some(_) = parent_in_stmts(&inner.stmts, class_name)
{
return found;
}
}
_ => {}
}
}
None
}
#[derive(Debug, Default)]
pub struct ClassMembers {
pub methods: Vec<(String, bool)>,
pub properties: Vec<(String, bool)>,
pub readonly_properties: Vec<String>,
pub constants: Vec<String>,
pub parent: Option<String>,
pub trait_uses: Vec<String>,
pub found: bool,
}
pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
let mut out = ClassMembers::default();
out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
out
}
fn collect_members_stmts(
source: &str,
stmts: &[Stmt<'_, '_>],
class_name: &str,
out: &mut ClassMembers,
) -> Option<String> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c)
if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
{
out.found = true;
if let Some(raw) = docblock_before(source, stmt.span.start) {
let db = parse_docblock(&raw);
for prop in &db.properties {
out.properties.push((prop.name.clone(), false));
}
for method in &db.methods {
out.methods.push((method.name.clone(), method.is_static));
}
}
for member in c.body.members.iter() {
match &member.kind {
ClassMemberKind::Method(m) => {
out.methods.push((m.name.to_string(), m.is_static));
if m.name == "__construct" {
for p in m.params.iter() {
if p.visibility.is_some() {
out.properties.push((p.name.to_string(), false));
let param_src =
&source[p.span.start as usize..p.span.end as usize];
if param_src.contains("readonly") {
out.readonly_properties.push(p.name.to_string());
}
}
}
}
}
ClassMemberKind::Property(p) => {
out.properties.push((p.name.to_string(), p.is_static));
if p.is_readonly {
out.readonly_properties.push(p.name.to_string());
}
}
ClassMemberKind::ClassConst(c) => {
out.constants.push(c.name.to_string());
}
ClassMemberKind::TraitUse(t) => {
for name in t.traits.iter() {
out.trait_uses.push(name.to_string_repr().to_string());
}
}
}
}
return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
}
StmtKind::Enum(e) if e.name == class_name => {
out.found = true;
let is_backed = e.scalar_type.is_some();
out.properties.push(("name".to_string(), false));
if is_backed {
out.properties.push(("value".to_string(), false));
}
out.methods.push(("cases".to_string(), true));
if is_backed {
out.methods.push(("from".to_string(), true));
out.methods.push(("tryFrom".to_string(), true));
}
for member in e.body.members.iter() {
match &member.kind {
EnumMemberKind::Case(c) => {
out.constants.push(c.name.to_string());
}
EnumMemberKind::Method(m) => {
out.methods.push((m.name.to_string(), m.is_static));
}
EnumMemberKind::ClassConst(c) => {
out.constants.push(c.name.to_string());
}
_ => {}
}
}
return None; }
StmtKind::Trait(t) if t.name == class_name => {
out.found = true;
for member in t.body.members.iter() {
match &member.kind {
ClassMemberKind::Method(m) => {
out.methods.push((m.name.to_string(), m.is_static));
}
ClassMemberKind::Property(p) => {
out.properties.push((p.name.to_string(), p.is_static));
}
ClassMemberKind::ClassConst(c) => {
out.constants.push(c.name.to_string());
}
ClassMemberKind::TraitUse(t) => {
for name in t.traits.iter() {
out.trait_uses.push(name.to_string_repr().to_string());
}
}
}
}
return None; }
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
let result = collect_members_stmts(source, &inner.stmts, class_name, out);
if result.is_some() {
return result;
}
}
}
_ => {}
}
}
None
}
pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
let source = doc.source();
mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
}
fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c)
if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
{
if let Some(raw) = docblock_before(source, stmt.span.start) {
return parse_docblock(&raw).mixins;
}
return vec![];
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
let found = mixin_classes_in_stmts(source, &inner.stmts, class_name);
if !found.is_empty() {
return found;
}
}
}
_ => {}
}
}
vec![]
}
pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
let sv = doc.view();
enclosing_class_in_stmts(sv, &doc.program().stmts, position)
}
pub fn enclosing_class_range_at(
doc: &ParsedDoc,
position: Position,
) -> Option<tower_lsp::lsp_types::Range> {
let sv = doc.view();
enclosing_class_range_in_stmts(sv, &doc.program().stmts, position)
}
pub fn collect_all_class_ranges(doc: &ParsedDoc) -> Vec<tower_lsp::lsp_types::Range> {
let sv = doc.view();
let mut out = Vec::new();
collect_class_ranges_in_stmts(sv, &doc.program().stmts, &mut out);
out
}
fn collect_class_ranges_in_stmts(
sv: SourceView<'_>,
stmts: &[Stmt<'_, '_>],
out: &mut Vec<tower_lsp::lsp_types::Range>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(_)
| StmtKind::Interface(_)
| StmtKind::Trait(_)
| StmtKind::Enum(_) => {
out.push(sv.range_of(stmt.span));
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_class_ranges_in_stmts(sv, &inner.stmts, out);
}
}
_ => {}
}
}
}
fn enclosing_class_range_in_stmts(
sv: SourceView<'_>,
stmts: &[Stmt<'_, '_>],
pos: Position,
) -> Option<tower_lsp::lsp_types::Range> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(_)
| StmtKind::Interface(_)
| StmtKind::Trait(_)
| StmtKind::Enum(_) => {
let r = sv.range_of(stmt.span);
if pos.line >= r.start.line && pos.line <= r.end.line {
return Some(r);
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let Some(r) = enclosing_class_range_in_stmts(sv, &inner.stmts, pos)
{
return Some(r);
}
}
_ => {}
}
}
None
}
fn enclosing_class_in_stmts(
sv: SourceView<'_>,
stmts: &[Stmt<'_, '_>],
pos: Position,
) -> Option<String> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c) => {
let start = sv.position_of(stmt.span.start).line;
let end = sv.position_of(stmt.span.end).line;
if pos.line >= start && pos.line <= end {
return c.name.map(|n| n.to_string());
}
}
StmtKind::Interface(i) => {
let start = sv.position_of(stmt.span.start).line;
let end = sv.position_of(stmt.span.end).line;
if pos.line >= start && pos.line <= end {
return Some(i.name.to_string());
}
}
StmtKind::Trait(t) => {
let start = sv.position_of(stmt.span.start).line;
let end = sv.position_of(stmt.span.end).line;
if pos.line >= start && pos.line <= end {
return Some(t.name.to_string());
}
}
StmtKind::Enum(e) => {
let start = sv.position_of(stmt.span.start).line;
let end = sv.position_of(stmt.span.end).line;
if pos.line >= start && pos.line <= end {
return Some(e.name.to_string());
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let Some(found) = enclosing_class_in_stmts(sv, &inner.stmts, pos)
{
return Some(found);
}
}
_ => {}
}
}
None
}
pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
let mut out = Vec::new();
collect_params_stmts(&doc.program().stmts, func_name, &mut out);
out
}
pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
let mut out = Vec::new();
collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
out
}
fn collect_method_params_stmts(
stmts: &[php_ast::Stmt<'_, '_>],
class_name: &str,
method_name: &str,
out: &mut Vec<String>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c)
if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
{
for member in c.body.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& m.name == method_name
{
for p in m.params.iter() {
out.push(p.name.to_string());
}
return;
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_method_params_stmts(&inner.stmts, class_name, method_name, out);
}
}
_ => {}
}
}
}
pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
is_enum_in_stmts(&doc.program().stmts, class_name)
}
fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
for stmt in stmts {
match &stmt.kind {
StmtKind::Enum(e) if e.name == name => return true,
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& is_enum_in_stmts(&inner.stmts, name)
{
return true;
}
}
_ => {}
}
}
false
}
pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
is_backed_enum_in_stmts(&doc.program().stmts, class_name)
}
fn is_backed_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
for stmt in stmts {
match &stmt.kind {
StmtKind::Enum(e) if e.name == name => return e.scalar_type.is_some(),
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& is_backed_enum_in_stmts(&inner.stmts, name)
{
return true;
}
}
_ => {}
}
}
false
}
fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Function(f) if f.name == func_name => {
for p in f.params.iter() {
out.push(p.name.to_string());
}
return;
}
StmtKind::Class(c) => {
for member in c.body.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& m.name == func_name
{
for p in m.params.iter() {
out.push(p.name.to_string());
}
return;
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_params_stmts(&inner.stmts, func_name, out);
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infers_type_from_new_expression() {
let src = "<?php\n$obj = new Foo();";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(tm.get("$obj"), Some("Foo"));
}
#[test]
fn unknown_variable_returns_none() {
let src = "<?php\n$obj = new Foo();";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert!(tm.get("$other").is_none());
}
#[test]
fn multiple_assignments() {
let src = "<?php\n$a = new Foo();\n$b = new Bar();";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(tm.get("$a"), Some("Foo"));
assert_eq!(tm.get("$b"), Some("Bar"));
}
#[test]
fn later_assignment_overwrites() {
let src = "<?php\n$a = new Foo();\n$a = new Bar();";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(tm.get("$a"), Some("Bar"));
}
#[test]
fn infers_type_from_typed_param() {
let src = "<?php\nfunction process(Mailer $mailer): void { $mailer-> }";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(tm.get("$mailer"), Some("Mailer"));
}
#[test]
fn parent_class_name_finds_parent() {
let src = "<?php\nclass Base {}\nclass Child extends Base {}";
let doc = ParsedDoc::parse(src.to_string());
assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
}
#[test]
fn parent_class_name_returns_none_for_top_level() {
let src = "<?php\nclass Base {}";
let doc = ParsedDoc::parse(src.to_string());
assert!(parent_class_name(&doc, "Base").is_none());
}
#[test]
fn members_of_class_includes_parent_field() {
let src = "<?php\nclass Base {}\nclass Child extends Base {}";
let doc = ParsedDoc::parse(src.to_string());
let m = members_of_class(&doc, "Child");
assert_eq!(m.parent.as_deref(), Some("Base"));
}
#[test]
fn members_of_class_finds_methods() {
let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Calc");
let names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"add"), "missing 'add'");
assert!(names.contains(&"sub"), "missing 'sub'");
}
#[test]
fn members_of_unknown_class_is_empty() {
let src = "<?php\nclass Calc { public function add() {} }";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Unknown");
assert!(members.methods.is_empty());
}
#[test]
fn constructor_promoted_params_appear_as_properties() {
let src = "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Point");
let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
assert!(
prop_names.contains(&"x"),
"promoted param x should be a property"
);
assert!(
prop_names.contains(&"y"),
"promoted param y should be a property"
);
}
#[test]
fn promoted_readonly_params_appear_in_readonly_properties() {
let src = "<?php\nclass User {\n public function __construct(\n public readonly string $name,\n public int $age,\n ) {}\n}";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "User");
let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
assert!(
prop_names.contains(&"name"),
"promoted param name should be a property"
);
assert!(
prop_names.contains(&"age"),
"promoted param age should be a property"
);
assert!(
members.readonly_properties.contains(&"name".to_string()),
"readonly promoted param name should be in readonly_properties"
);
assert!(
!members.readonly_properties.contains(&"age".to_string()),
"non-readonly promoted param age should not be in readonly_properties"
);
}
#[test]
fn enum_instance_members_include_name() {
let src = "<?php\nenum Status { case Active; case Inactive; }";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Status");
let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
assert!(
prop_names.contains(&"name"),
"pure enum should expose ->name"
);
assert!(
!prop_names.contains(&"value"),
"pure enum should not expose ->value"
);
}
#[test]
fn backed_enum_exposes_value_and_factory_methods() {
let src = "<?php\nenum Color: string { case Red = 'red'; }";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Color");
let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
assert!(
prop_names.contains(&"value"),
"backed enum should expose ->value"
);
assert!(
method_names.contains(&"from"),
"backed enum should have ::from()"
);
assert!(
method_names.contains(&"tryFrom"),
"backed enum should have ::tryFrom()"
);
assert!(
method_names.contains(&"cases"),
"enum should have ::cases()"
);
}
#[test]
fn enum_cases_appear_as_constants() {
let src = "<?php\nenum Status { case Active; case Inactive; }";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Status");
assert!(members.constants.contains(&"Active".to_string()));
assert!(members.constants.contains(&"Inactive".to_string()));
}
#[test]
fn trait_members_are_collected() {
let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Logging");
let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
assert!(
method_names.contains(&"log"),
"trait method log should be collected"
);
assert!(
prop_names.contains(&"logFile"),
"trait property logFile should be collected"
);
}
#[test]
fn class_with_trait_use_lists_trait() {
let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "App");
assert!(
members.trait_uses.contains(&"Logging".to_string()),
"should list used trait"
);
}
#[test]
fn var_docblock_with_explicit_varname_infers_type() {
let src = "<?php\n/** @var Mailer $mailer */\n$mailer = $container->get('mailer');";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$mailer"),
Some("Mailer"),
"@var with explicit name should map the variable"
);
}
#[test]
fn var_docblock_without_varname_infers_from_assignment() {
let src = "<?php\n/** @var Repository */\n$repo = $this->getRepository();";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$repo"),
Some("Repository"),
"@var without name should use assignment LHS"
);
}
#[test]
fn var_docblock_does_not_map_primitive_types() {
let src = "<?php\n/** @var string */\n$name = 'hello';";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert!(
tm.get("$name").is_none(),
"primitive @var should not produce a class mapping"
);
}
#[test]
fn var_nullable_docblock_maps_to_class() {
let src = "<?php\n/** @var ?Mailer $mailer */\n$mailer = null;";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$mailer"),
Some("Mailer"),
"@var ?Foo should map to 'Foo', not 'Foo|null'"
);
}
#[test]
fn var_union_docblock_maps_first_class() {
let src = "<?php\n/** @var Repository|null $repo */\n$repo = null;";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$repo"),
Some("Repository"),
"@var Foo|null should map to 'Foo', not 'Foo|null'"
);
}
#[test]
fn is_enum_pure() {
let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
let doc = ParsedDoc::parse(src.to_string());
assert!(is_enum(&doc, "Suit"));
assert!(!is_backed_enum(&doc, "Suit"));
}
#[test]
fn is_backed_enum_string() {
let src = "<?php\nenum Status: string { case Active = 'active'; }";
let doc = ParsedDoc::parse(src.to_string());
assert!(is_enum(&doc, "Status"));
assert!(is_backed_enum(&doc, "Status"));
}
#[test]
fn is_enum_false_for_class() {
let src = "<?php\nclass Foo {}";
let doc = ParsedDoc::parse(src.to_string());
assert!(!is_enum(&doc, "Foo"));
assert!(!is_backed_enum(&doc, "Foo"));
}
#[test]
fn closure_use_var_type_is_available_inside_body() {
let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc->process(); };";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$svc"),
Some("PaymentService"),
"captured use variable should retain its outer type inside closure body"
);
}
#[test]
fn closure_use_var_inner_assignment_does_not_override_outer_type() {
let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc = new OtherService(); };";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$svc"),
Some("PaymentService"),
"outer type should not be overwritten by inner assignment in closure"
);
}
#[test]
fn param_docblock_type_inferred() {
let src = "<?php\n/**\n * @param Mailer $mailer\n */\nfunction send($mailer) { $mailer-> }";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(tm.get("$mailer"), Some("Mailer"));
}
#[test]
fn param_docblock_does_not_override_ast_hint() {
let src = "<?php\n/**\n * @param OtherClass $x\n */\nfunction foo(Foo $x) {}";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(tm.get("$x"), Some("Foo"));
}
#[test]
fn not_null_check_preserves_existing_type() {
let src = "<?php\n$x = new Foo();\nif ($x !== null) { $x-> }";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(tm.get("$x"), Some("Foo"));
}
#[test]
fn docblock_property_appears_in_members() {
let src =
"<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "User");
let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
assert!(props.contains(&"email"));
assert!(props.contains(&"id"));
}
#[test]
fn docblock_method_appears_in_members() {
let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
let doc = ParsedDoc::parse(src.to_string());
let members = members_of_class(&doc, "Model");
let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
assert!(method_names.contains(&"find"));
assert!(method_names.contains(&"where"));
let where_static = members
.methods
.iter()
.find(|(n, _)| n == "where")
.map(|(_, s)| *s);
assert_eq!(where_static, Some(true));
}
#[test]
fn union_type_param_maps_both_classes() {
let src = "<?php\nfunction f(Foo|Bar $x) {}";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
let val = tm.get("$x").expect("$x should be in the type map");
assert!(
val.contains("Foo"),
"union type should contain 'Foo', got: {}",
val
);
assert!(
val.contains("Bar"),
"union type should contain 'Bar', got: {}",
val
);
}
#[test]
fn nullable_param_resolves_to_class() {
let src = "<?php\nfunction f(?Foo $x) {}";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$x"),
Some("Foo"),
"nullable type hint ?Foo should map $x to Foo"
);
}
#[test]
fn null_assignment_does_not_overwrite_class() {
let src = "<?php\n$x = new Foo();\n$x = null;\n";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$x"),
Some("Foo"),
"$x should retain its Foo type after being assigned null"
);
}
#[test]
fn infers_type_from_assignment_inside_trait_method() {
let src = "<?php\ntrait Builder { public function make(): void { $obj = new Widget(); } }";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$obj"),
Some("Widget"),
"type map should walk into trait method bodies"
);
}
#[test]
fn infers_type_from_assignment_inside_enum_method() {
let src = "<?php\nenum Color { case Red; public function make(): void { $obj = new Palette(); } }";
let doc = ParsedDoc::parse(src.to_string());
let tm = TypeMap::from_doc(&doc);
assert_eq!(
tm.get("$obj"),
Some("Palette"),
"type map should walk into enum method bodies"
);
}
}