use std::collections::HashMap;
use mago_docblock::document::TagKind;
use super::parser::{DocblockInfo, collapse_newlines, parse_docblock_for_tags};
use super::types::split_type_token;
use crate::php_type::PhpType;
use crate::types::{TemplateVariance, TypeAliasDef};
use crate::util::strip_fqn_prefix;
pub fn extract_template_params(docblock: &str) -> Vec<String> {
extract_template_params_full(docblock)
.into_iter()
.map(|(name, _, _, _)| name)
.collect()
}
pub fn extract_template_params_from_info(info: &DocblockInfo) -> Vec<String> {
extract_template_params_full_from_info(info)
.into_iter()
.map(|(name, _, _, _)| name)
.collect()
}
pub fn extract_template_params_with_bounds(docblock: &str) -> Vec<(String, Option<PhpType>)> {
extract_template_params_full(docblock)
.into_iter()
.map(|(name, bound, _, _)| (name, bound))
.collect()
}
pub fn extract_template_params_with_bounds_from_info(
info: &DocblockInfo,
) -> Vec<(String, Option<PhpType>)> {
extract_template_params_full_from_info(info)
.into_iter()
.map(|(name, bound, _, _)| (name, bound))
.collect()
}
pub fn extract_template_params_full(
docblock: &str,
) -> Vec<(String, Option<PhpType>, TemplateVariance, Option<PhpType>)> {
let Some(info) = parse_docblock_for_tags(docblock) else {
return Vec::new();
};
extract_template_params_full_from_info(&info)
}
pub(crate) const fn variance_for(kind: TagKind) -> TemplateVariance {
match kind {
TagKind::TemplateCovariant
| TagKind::PhpstanTemplateCovariant
| TagKind::PsalmTemplateCovariant => TemplateVariance::Covariant,
TagKind::TemplateContravariant
| TagKind::PhpstanTemplateContravariant
| TagKind::PsalmTemplateContravariant => TemplateVariance::Contravariant,
_ => TemplateVariance::Invariant,
}
}
pub(crate) const TEMPLATE_KINDS: &[TagKind] = &[
TagKind::Template,
TagKind::TemplateCovariant,
TagKind::TemplateContravariant,
TagKind::PhpstanTemplate,
TagKind::PhpstanTemplateCovariant,
TagKind::PhpstanTemplateContravariant,
TagKind::PsalmTemplate,
TagKind::PsalmTemplateCovariant,
TagKind::PsalmTemplateContravariant,
];
pub fn extract_template_params_full_from_info(
info: &DocblockInfo,
) -> Vec<(String, Option<PhpType>, TemplateVariance, Option<PhpType>)> {
let mut results = Vec::new();
for tag in info.tags_by_kinds(TEMPLATE_KINDS) {
let desc = tag.description.trim();
if desc.is_empty() {
continue;
}
let variance = variance_for(tag.kind);
let mut tokens = desc.split_whitespace();
if let Some(name) = tokens.next() {
if name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
{
let rest = desc[name.len()..].trim_start();
let (bound, rest_after_bound) = if let Some(after_of) =
rest.strip_prefix("of").and_then(|s| {
s.strip_prefix(|c: char| c.is_whitespace())
}) {
let after_of = after_of.trim_start();
if after_of.is_empty() {
(None, "")
} else {
let (type_tok, remainder) = split_type_token(after_of);
if type_tok.is_empty() {
(None, remainder)
} else {
(Some(PhpType::parse(type_tok)), remainder)
}
}
} else {
(None, rest)
};
let rest_trimmed = rest_after_bound.trim_start();
let default = if let Some(after_eq) = rest_trimmed.strip_prefix('=') {
let after_eq = after_eq.trim_start();
if after_eq.is_empty() {
None
} else {
let (default_tok, _) = split_type_token(after_eq);
if default_tok.is_empty() {
None
} else {
Some(PhpType::parse(default_tok))
}
}
} else {
None
};
results.push((name.to_string(), bound, variance, default));
}
}
}
results
}
pub fn extract_template_param_bindings(
docblock: &str,
template_params: &[String],
) -> Vec<(String, String)> {
if template_params.is_empty() {
return Vec::new();
}
let Some(info) = parse_docblock_for_tags(docblock) else {
return Vec::new();
};
extract_template_param_bindings_from_info(&info, template_params)
}
pub fn extract_template_param_bindings_from_info(
info: &DocblockInfo,
template_params: &[String],
) -> Vec<(String, String)> {
if template_params.is_empty() {
return Vec::new();
}
let mut results = Vec::new();
for tag in info.tags_by_kinds(&[TagKind::PhpstanParam, TagKind::PsalmParam, TagKind::Param]) {
let desc = tag.description.trim();
if desc.is_empty() {
continue;
}
let (type_token, remainder) = split_type_token(desc);
let param_name = match remainder.split_whitespace().next() {
Some(name) if name.starts_with('$') => name,
Some(name) if name.starts_with("...$") => &name[3..],
_ => continue,
};
let parsed = PhpType::parse(type_token);
collect_template_bindings(&parsed, template_params, param_name, &mut results);
}
results
}
pub fn extract_generics_tag(docblock: &str, tag: &str) -> Vec<(String, Vec<PhpType>)> {
let Some(info) = parse_docblock_for_tags(docblock) else {
return Vec::new();
};
extract_generics_tag_from_info(&info, tag)
}
fn collect_template_bindings(
ty: &PhpType,
template_params: &[String],
param_name: &str,
results: &mut Vec<(String, String)>,
) {
match ty {
PhpType::Named(name) => {
if let Some(t) = template_params.iter().find(|t| t.as_str() == name) {
results.push((t.to_string(), param_name.to_string()));
}
}
PhpType::Nullable(inner) => {
collect_template_bindings(inner, template_params, param_name, results);
}
PhpType::Union(members) | PhpType::Intersection(members) => {
for member in members {
collect_template_bindings(member, template_params, param_name, results);
}
}
PhpType::Array(inner) => {
collect_template_bindings(inner, template_params, param_name, results);
}
PhpType::Generic(_, args) => {
for arg in args {
collect_template_bindings(arg, template_params, param_name, results);
}
}
PhpType::ClassString(Some(inner))
| PhpType::InterfaceString(Some(inner))
| PhpType::KeyOf(inner)
| PhpType::ValueOf(inner) => {
collect_template_bindings(inner, template_params, param_name, results);
}
PhpType::Callable {
params,
return_type,
..
} => {
for p in params {
collect_template_bindings(&p.type_hint, template_params, param_name, results);
}
if let Some(rt) = return_type {
collect_template_bindings(rt, template_params, param_name, results);
}
}
PhpType::ArrayShape(entries) | PhpType::ObjectShape(entries) => {
for entry in entries {
collect_template_bindings(&entry.value_type, template_params, param_name, results);
}
}
PhpType::IndexAccess(target, index) => {
collect_template_bindings(target, template_params, param_name, results);
collect_template_bindings(index, template_params, param_name, results);
}
PhpType::Conditional {
condition,
then_type,
else_type,
..
} => {
collect_template_bindings(condition, template_params, param_name, results);
collect_template_bindings(then_type, template_params, param_name, results);
collect_template_bindings(else_type, template_params, param_name, results);
}
_ => {}
}
}
pub fn extract_generics_tag_from_info(
info: &DocblockInfo,
tag: &str,
) -> Vec<(String, Vec<PhpType>)> {
let bare_tag = tag.strip_prefix('@').unwrap_or(tag);
let (kinds, name_fallbacks): (Vec<TagKind>, Vec<&str>) = match bare_tag {
"extends" => (
vec![TagKind::Extends, TagKind::TemplateExtends],
vec!["phpstan-extends"],
),
"implements" => (
vec![TagKind::Implements, TagKind::TemplateImplements],
vec!["phpstan-implements"],
),
"use" => (
vec![TagKind::Use, TagKind::TemplateUse],
vec!["phpstan-use"],
),
_ => (vec![], vec![bare_tag]),
};
let mut results = Vec::new();
for tag in info.tags_by_kinds(&kinds) {
if let Some(result) = parse_generics_from_description(&tag.description) {
results.push(result);
}
}
for tag in &info.tags {
if name_fallbacks.contains(&tag.name.as_str())
&& tag.kind == TagKind::Other
&& let Some(result) = parse_generics_from_description(&tag.description)
{
results.push(result);
}
}
results
}
fn parse_generics_from_description(desc: &str) -> Option<(String, Vec<PhpType>)> {
let desc = desc.trim();
if desc.is_empty() {
return None;
}
let normalised = collapse_newlines(desc);
let (type_token, _remainder) = split_type_token(&normalised);
let parsed = PhpType::parse(type_token);
match parsed {
PhpType::Generic(name, args) if !args.is_empty() => {
let base_name = strip_fqn_prefix(&name).to_string();
if base_name.is_empty() {
return None;
}
Some((base_name, args))
}
_ => None,
}
}
pub fn extract_type_aliases(docblock: &str) -> HashMap<String, TypeAliasDef> {
let Some(info) = parse_docblock_for_tags(docblock) else {
return HashMap::new();
};
extract_type_aliases_from_info(&info)
}
pub fn extract_type_aliases_from_info(info: &DocblockInfo) -> HashMap<String, TypeAliasDef> {
let mut aliases = HashMap::new();
for tag in info.tags_by_kinds(&[TagKind::PhpstanType, TagKind::PsalmType, TagKind::Type]) {
let desc = tag.description.trim();
if desc.is_empty() {
continue;
}
let normalised = collapse_newlines(desc);
if let Some((name, def)) = parse_local_type_alias(&normalised)
&& !name.is_empty()
&& !def.is_empty()
{
aliases.insert(name.to_string(), TypeAliasDef::Local(PhpType::parse(def)));
}
}
for tag in info.tags_by_kinds(&[
TagKind::PhpstanImportType,
TagKind::PsalmImportType,
TagKind::ImportType,
]) {
let desc = tag.description.trim();
if desc.is_empty() {
continue;
}
if let Some((alias_name, definition)) = parse_import_type_alias(desc) {
aliases.insert(alias_name, definition);
}
}
aliases
}
fn parse_local_type_alias(rest: &str) -> Option<(&str, &str)> {
let name_end = rest
.find(|c: char| !c.is_alphanumeric() && c != '_')
.unwrap_or(rest.len());
let name = &rest[..name_end];
if name.is_empty() {
return None;
}
let after_name = rest[name_end..].trim_start();
let definition = after_name
.strip_prefix('=')
.unwrap_or(after_name)
.trim_start();
if definition.is_empty() {
return None;
}
let definition = definition.trim_end();
Some((name, definition))
}
fn parse_import_type_alias(rest: &str) -> Option<(String, TypeAliasDef)> {
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() < 3 || parts[1] != "from" {
return None;
}
let original_name = parts[0];
let source_class = parts[2];
let alias_name = if parts.len() >= 5 && parts[3] == "as" {
parts[4].to_string()
} else {
original_name.to_string()
};
let definition = TypeAliasDef::Import {
source_class: source_class.to_string(),
original_name: original_name.to_string(),
};
Some((alias_name, definition))
}
pub fn synthesize_template_conditional(
docblock: &str,
template_params: &[String],
return_type: Option<&PhpType>,
has_existing_conditional: bool,
) -> Option<PhpType> {
let info = parse_docblock_for_tags(docblock)?;
synthesize_template_conditional_from_info(
&info,
template_params,
return_type,
has_existing_conditional,
)
}
pub fn synthesize_template_conditional_from_info(
info: &DocblockInfo,
template_params: &[String],
return_type: Option<&PhpType>,
has_existing_conditional: bool,
) -> Option<PhpType> {
if has_existing_conditional {
return None;
}
if template_params.is_empty() {
return None;
}
let ret = return_type?;
let stripped_name = match ret {
PhpType::Nullable(inner) => {
if let PhpType::Named(n) = inner.as_ref() {
n.as_str()
} else {
return None;
}
}
PhpType::Named(n) => n.as_str(),
_ => return None,
};
if !template_params.iter().any(|t| t == stripped_name) {
return None;
}
let param_name = find_class_string_param_name_from_info(info, stripped_name)?;
Some(PhpType::Conditional {
param: format!("${param_name}"),
negated: false,
condition: Box::new(PhpType::ClassString(None)),
then_type: Box::new(PhpType::mixed()),
else_type: Box::new(PhpType::mixed()),
})
}
fn find_class_string_param_name_from_info(
info: &DocblockInfo,
template_name: &str,
) -> Option<String> {
for tag in info.tags_by_kinds(&[TagKind::PhpstanParam, TagKind::PsalmParam, TagKind::Param]) {
let desc = tag.description.trim();
if desc.is_empty() {
continue;
}
let (type_token, remainder) = split_type_token(desc);
let parsed = PhpType::parse(type_token);
if !contains_class_string_of(&parsed, template_name) {
continue;
}
let mut search = remainder;
while let Some(rest) = search.strip_prefix('|') {
let rest = rest.trim_start();
let (_, after) = split_type_token(rest);
search = after;
}
if let Some(var_name) = search.split_whitespace().next() {
let var_name = var_name.strip_prefix("...").unwrap_or(var_name);
if let Some(name) = var_name.strip_prefix('$') {
return Some(name.to_string());
}
}
}
None
}
fn contains_class_string_of(ty: &PhpType, template_name: &str) -> bool {
match ty {
PhpType::ClassString(Some(inner)) => {
matches!(inner.as_ref(), PhpType::Named(name) if name == template_name)
}
PhpType::Nullable(inner) => contains_class_string_of(inner, template_name),
PhpType::Union(members) => members
.iter()
.any(|m| contains_class_string_of(m, template_name)),
PhpType::Intersection(members) => members
.iter()
.any(|m| contains_class_string_of(m, template_name)),
_ => false,
}
}