use std::collections::HashMap;
use mago_docblock::document::TagKind;
use super::parser::parse_docblock_for_tags;
use super::tags::sanitise_and_parse_docblock_type;
use super::types::split_type_token;
use crate::php_type::PhpType;
use crate::types::{MethodInfo, ParameterInfo, Visibility};
pub fn extract_property_tags(docblock: &str) -> Vec<(String, Option<PhpType>)> {
let Some(info) = parse_docblock_for_tags(docblock) else {
return Vec::new();
};
const PROPERTY_KINDS: &[TagKind] = &[
TagKind::Property,
TagKind::PropertyRead,
TagKind::PropertyWrite,
TagKind::PsalmProperty,
TagKind::PsalmPropertyRead,
TagKind::PsalmPropertyWrite,
];
let mut results = Vec::new();
for tag in info.tags_by_kinds(PROPERTY_KINDS) {
let desc = tag.description.trim();
if desc.is_empty() {
continue;
}
if desc.starts_with('$') {
let prop_name = desc.split_whitespace().next().unwrap_or(desc);
let name = prop_name.strip_prefix('$').unwrap_or(prop_name);
if name.is_empty() {
continue;
}
results.push((name.to_string(), None));
continue;
}
let (type_token, remainder) = split_type_token(desc);
let prop_name = match remainder.split_whitespace().find(|t| t.starts_with('$')) {
Some(name) => name,
None => continue,
};
let name = prop_name.strip_prefix('$').unwrap_or(prop_name);
if name.is_empty() {
continue;
}
let type_str = type_token.trim_end_matches(['.', ',']);
let parsed = if type_str.is_empty() {
None
} else {
sanitise_and_parse_docblock_type(type_str)
};
results.push((name.to_string(), parsed));
}
results
}
pub fn extract_method_tags(docblock: &str) -> Vec<MethodInfo> {
let Some(info) = parse_docblock_for_tags(docblock) else {
return Vec::new();
};
const METHOD_KINDS: &[TagKind] = &[TagKind::Method, TagKind::PsalmMethod];
let mut results = Vec::new();
for tag in info.tags_by_kinds(METHOD_KINDS) {
let desc = tag.description.trim();
if desc.is_empty() {
continue;
}
let desc = desc.replace('\n', " ");
let rest = desc.as_str();
let (is_static, rest) = if let Some(after_static) = rest.strip_prefix("static") {
if after_static.is_empty() {
continue;
}
let next_char = after_static.chars().next().unwrap();
if next_char.is_whitespace() || next_char == '(' {
(true, after_static.trim_start())
} else {
(false, rest)
}
} else {
(false, rest)
};
let paren_pos = match rest.find('(') {
Some(p) => p,
None => continue,
};
let before_paren = &rest[..paren_pos];
let after_paren = &rest[paren_pos + 1..];
let before_paren = before_paren.trim();
if before_paren.is_empty() {
continue;
}
let (return_type_raw, method_name) =
if let Some(last_space) = before_paren.rfind(|c: char| c.is_whitespace()) {
let ret = before_paren[..last_space].trim();
let name = before_paren[last_space..].trim();
(Some(ret), name)
} else {
(None, before_paren)
};
if method_name.is_empty() {
continue;
}
let return_type: Option<PhpType> = return_type_raw
.map(|s| s.trim_end_matches(['.', ',']))
.filter(|s| !s.is_empty())
.map(PhpType::parse);
let params_str = if let Some(close_paren) = after_paren.rfind(')') {
after_paren[..close_paren].trim()
} else {
after_paren.trim()
};
let parameters = if params_str.is_empty() {
Vec::new()
} else {
parse_method_tag_params(params_str)
};
results.push(MethodInfo {
name: method_name.to_string(),
name_offset: 0,
parameters,
return_type,
native_return_type: None,
description: None,
return_description: None,
links: Vec::new(),
see_refs: Vec::new(),
is_static,
visibility: Visibility::Public,
conditional_return: None,
deprecation_message: None,
deprecated_replacement: None,
template_params: Vec::new(),
template_param_bounds: HashMap::new(),
template_bindings: Vec::new(),
has_scope_attribute: false,
is_abstract: false,
is_virtual: true,
type_assertions: Vec::new(),
throws: Vec::new(),
});
}
results
}
fn parse_method_tag_params(params_str: &str) -> Vec<ParameterInfo> {
let parts = split_params(params_str);
let mut result = Vec::new();
for part in &parts {
let part = part.trim();
if part.is_empty() {
continue;
}
let has_default = part.contains('=');
let is_variadic = part.contains("...");
let dollar_pos = part.rfind('$');
let (parsed_type, param_name) = if let Some(dp) = dollar_pos {
let name_and_rest = &part[dp..];
let name_end = name_and_rest
.find(|c: char| c.is_whitespace() || c == '=' || c == ')')
.unwrap_or(name_and_rest.len());
let name = &name_and_rest[..name_end];
let before = part[..dp].trim().trim_end_matches("...");
let parsed_type = if before.is_empty() {
None
} else {
let trimmed = before.trim_end_matches(['.', ',']);
if trimmed.is_empty() {
None
} else {
Some(PhpType::parse(trimmed))
}
};
(parsed_type, name.to_string())
} else {
continue;
};
let is_required = !has_default && !is_variadic;
result.push(ParameterInfo {
name: param_name,
is_required,
type_hint: parsed_type.clone(),
native_type_hint: parsed_type,
description: None,
default_value: None,
is_variadic,
is_reference: false,
closure_this_type: None,
});
}
result
}
fn split_params(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth_angle = 0i32;
let mut depth_paren = 0i32;
let mut start = 0;
for (i, ch) in s.char_indices() {
match ch {
'<' => depth_angle += 1,
'>' => depth_angle -= 1,
'(' => depth_paren += 1,
')' => depth_paren -= 1,
',' if depth_angle == 0 && depth_paren == 0 => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}