use tree_sitter::Node;
#[derive(Debug, Default)]
pub struct PhpDocTags {
pub params: Vec<ParamTag>,
pub returns: Option<String>,
pub var_type: Option<String>,
}
#[derive(Debug)]
pub struct ParamTag {
pub name: String,
pub type_str: String,
#[allow(dead_code)] pub description: Option<String>,
}
pub fn extract_phpdoc_comment(node: Node, content: &[u8]) -> Option<String> {
if let Some(comment) = try_extract_comment(node, content) {
return Some(comment);
}
if let Some(parent) = node.parent()
&& matches!(
parent.kind(),
"visibility_modifier" | "static_modifier" | "abstract_modifier" | "final_modifier"
)
{
return try_extract_comment(parent, content);
}
None
}
fn try_extract_comment(node: Node, content: &[u8]) -> Option<String> {
let node_start_line = node.start_position().row;
let mut prev_sibling = node.prev_sibling();
while let Some(sibling) = prev_sibling {
if sibling.kind() == "comment" {
let comment_text = sibling.utf8_text(content).ok()?;
if comment_text.starts_with("/**") && comment_text.ends_with("*/") {
let comment_end_line = sibling.end_position().row;
let line_distance = node_start_line.saturating_sub(comment_end_line);
if line_distance <= 2 {
return Some(comment_text.to_string());
}
return None;
}
} else if !sibling.kind().contains("whitespace") {
break;
}
prev_sibling = sibling.prev_sibling();
}
None
}
pub fn parse_phpdoc_tags(phpdoc_comment: &str) -> PhpDocTags {
let mut tags = PhpDocTags::default();
let comment_body = phpdoc_comment
.trim_start_matches("/**")
.trim_end_matches("*/")
.trim();
for line in comment_body.lines() {
let line = line.trim().trim_start_matches('*').trim();
if line.starts_with("@param") {
if let Some(param) = parse_param_tag(line) {
tags.params.push(param);
}
}
else if line.starts_with("@return") {
if let Some(type_str) = extract_type_from_tag(line) {
tags.returns = Some(type_str);
}
}
else if line.starts_with("@var")
&& let Some(type_str) = extract_type_from_tag(line)
{
tags.var_type = Some(type_str);
}
}
tags
}
fn parse_param_tag(line: &str) -> Option<ParamTag> {
let after_keyword = line.trim_start_matches("@param").trim();
let (type_str, end_index) = extract_balanced_braces_with_index(after_keyword)?;
let after_type = &after_keyword[end_index + 1..].trim();
let name = extract_param_name(after_type)?;
let description = after_type
.split_once(&name)
.and_then(|(_, rest)| rest.trim().strip_prefix('-'))
.map(|s| s.trim().to_string());
Some(ParamTag {
name,
type_str,
description,
})
}
fn extract_param_name(text: &str) -> Option<String> {
let text = text.trim();
let text_to_parse = if let Some(rest) = text.strip_prefix("...") {
rest.trim()
} else {
text
};
if let Some(dollar_idx) = text_to_parse.find('$') {
let param_part = &text_to_parse[dollar_idx..];
let name = param_part
.split(|c: char| c.is_whitespace() || c == '-')
.next()?
.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
None
}
fn extract_type_from_tag(line: &str) -> Option<String> {
extract_balanced_braces(line)
}
fn extract_balanced_braces_with_index(s: &str) -> Option<(String, usize)> {
let start = s.find('{')?;
let mut depth = 0;
let mut end = start;
for (i, ch) in s[start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = start + i;
break;
}
}
_ => {}
}
}
if depth != 0 {
return None; }
Some((s[start + 1..end].to_string(), end))
}
fn extract_balanced_braces(s: &str) -> Option<String> {
extract_balanced_braces_with_index(s).map(|(content, _)| content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_balanced_braces() {
assert_eq!(
extract_balanced_braces("@param {string} $name"),
Some("string".to_string())
);
assert_eq!(
extract_balanced_braces("@param {User|Admin} $user"),
Some("User|Admin".to_string())
);
assert_eq!(
extract_balanced_braces("@param {array<string>} $items"),
Some("array<string>".to_string())
);
}
#[test]
fn test_extract_param_name() {
assert_eq!(extract_param_name("$name"), Some("$name".to_string()));
assert_eq!(
extract_param_name("$count - the count"),
Some("$count".to_string())
);
assert_eq!(extract_param_name("...$rest"), Some("$rest".to_string()));
assert_eq!(
extract_param_name("...$args - variadic args"),
Some("$args".to_string())
);
}
#[test]
fn test_parse_param_tag() {
let tag = parse_param_tag("@param {string} $name - description").unwrap();
assert_eq!(tag.name, "$name");
assert_eq!(tag.type_str, "string");
assert_eq!(tag.description, Some("description".to_string()));
let tag = parse_param_tag("@param {int} $count").unwrap();
assert_eq!(tag.name, "$count");
assert_eq!(tag.type_str, "int");
}
#[test]
fn test_parse_phpdoc_tags() {
let phpdoc = r"/**
* @param {string} $name
* @param {int} $age
* @return {User}
*/";
let tags = parse_phpdoc_tags(phpdoc);
assert_eq!(tags.params.len(), 2);
assert_eq!(tags.params[0].name, "$name");
assert_eq!(tags.params[1].name, "$age");
assert_eq!(tags.returns, Some("User".to_string()));
}
#[test]
fn test_parse_phpdoc_var_tag() {
let phpdoc = r"/**
* @var {string} $username
*/";
let tags = parse_phpdoc_tags(phpdoc);
assert_eq!(tags.var_type, Some("string".to_string()));
}
#[test]
fn test_parse_phpdoc_union_types() {
let phpdoc = r"/**
* @param {string|int} $value
* @return {bool}
*/";
let tags = parse_phpdoc_tags(phpdoc);
assert_eq!(tags.params[0].type_str, "string|int");
assert_eq!(tags.returns, Some("bool".to_string()));
}
#[test]
fn test_parse_phpdoc_array_types() {
let phpdoc = r"/**
* @param {User[]} $users
* @return {array<string, mixed>}
*/";
let tags = parse_phpdoc_tags(phpdoc);
assert_eq!(tags.params[0].type_str, "User[]");
assert_eq!(tags.returns, Some("array<string, mixed>".to_string()));
}
}