use bumpalo::Bump;
use mago_docblock::document::{Element, TagKind, TextSegment};
use mago_span::Span;
#[derive(Debug, Clone)]
pub struct TagInfo {
pub name: String,
pub kind: TagKind,
pub description: String,
pub span: Span,
pub description_span: Span,
}
#[derive(Debug, Clone)]
pub struct DocblockInfo {
pub description: Option<String>,
pub tags: Vec<TagInfo>,
}
impl DocblockInfo {
pub fn tags_by_kind(&self, kind: TagKind) -> impl Iterator<Item = &TagInfo> {
self.tags.iter().filter(move |t| t.kind == kind)
}
pub fn tags_by_kinds<'a>(&'a self, kinds: &'a [TagKind]) -> impl Iterator<Item = &'a TagInfo> {
self.tags.iter().filter(move |t| kinds.contains(&t.kind))
}
pub fn first_tag_by_kind(&self, kind: TagKind) -> Option<&TagInfo> {
self.tags_by_kind(kind).next()
}
}
pub fn parse_docblock(docblock: &str, base_span: Span) -> Option<DocblockInfo> {
let arena = Bump::new();
let content: &str = arena.alloc_str(docblock);
let document = mago_docblock::parse_phpdoc_with_span(&arena, content, base_span).ok()?;
Some(collect_tags(&document))
}
fn collect_tags(document: &mago_docblock::document::Document<'_>) -> DocblockInfo {
let mut tags = Vec::new();
let mut description_parts: Vec<String> = Vec::new();
let mut seen_tag = false;
for element in &document.elements {
match element {
Element::Tag(tag) => {
seen_tag = true;
tags.push(TagInfo {
name: tag.name.to_owned(),
kind: tag.kind,
description: tag.description.to_owned(),
span: tag.span,
description_span: tag.description_span,
});
}
Element::Text(text) if !seen_tag => {
for seg in &text.segments {
match seg {
TextSegment::Paragraph { content, .. } => {
description_parts.push((*content).to_owned());
}
TextSegment::InlineCode(code) => {
description_parts.push(format!("`{}`", code.content));
}
TextSegment::InlineTag(tag) => {
description_parts
.push(format!("{{@{} {}}}", tag.name, tag.description));
}
}
}
}
_ => {}
}
}
let description = if description_parts.is_empty() {
None
} else {
Some(description_parts.join(""))
};
DocblockInfo { description, tags }
}
pub fn collapse_newlines(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\n' {
let trimmed_len = out.trim_end().len();
out.truncate(trimmed_len);
while chars.peek().is_some_and(|&ch| ch == ' ' || ch == '\t') {
chars.next();
}
let last_ch = out.chars().last();
let next_ch = chars.peek().copied();
let skip_space = matches!(last_ch, Some('<' | '{' | '('))
|| matches!(next_ch, Some('>' | '}' | ')'));
if !out.is_empty() && !out.ends_with(' ') && !skip_space {
out.push(' ');
}
} else {
out.push(c);
}
}
out
}
pub fn parse_docblock_for_tags(docblock: &str) -> Option<DocblockInfo> {
use mago_database::file::FileId;
use mago_span::Position;
let span = Span::new(
FileId::zero(),
Position::new(0),
Position::new(docblock.len() as u32),
);
parse_docblock(docblock, span)
}
pub fn parse_docblock_for_tags_lossy(docblock: &str) -> Option<DocblockInfo> {
if let Some(info) = parse_docblock_for_tags(docblock) {
return Some(info);
}
let cleaned: String = docblock
.lines()
.filter(|line| {
let t = line.trim().trim_start_matches('*').trim();
t != "@"
})
.collect::<Vec<_>>()
.join("\n");
if let Some(info) = parse_docblock_for_tags(&cleaned) {
return Some(info);
}
let trimmed = cleaned.trim_end();
if trimmed.ends_with("*/") {
return None;
}
let fixed = format!("{}\n */", trimmed);
parse_docblock_for_tags(&fixed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_return_tag() {
let doc = "/** @return string */";
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(info.tags.len(), 1);
assert_eq!(info.tags[0].kind, TagKind::Return);
assert_eq!(info.tags[0].description, "string");
}
#[test]
fn parse_multiple_tags() {
let doc = r#"/**
* @param string $name The name
* @param int $age The age
* @return bool
*/"#;
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(info.tags.len(), 3);
assert_eq!(info.tags[0].kind, TagKind::Param);
assert_eq!(info.tags[0].description, "string $name The name");
assert_eq!(info.tags[1].kind, TagKind::Param);
assert_eq!(info.tags[1].description, "int $age The age");
assert_eq!(info.tags[2].kind, TagKind::Return);
assert_eq!(info.tags[2].description, "bool");
}
#[test]
fn parse_deprecated_tag_bare() {
let doc = "/** @deprecated */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Deprecated)
.expect("should have deprecated");
assert_eq!(tag.description, "");
}
#[test]
fn parse_deprecated_tag_with_message() {
let doc = "/** @deprecated Use newMethod() instead */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Deprecated)
.expect("should have deprecated");
assert_eq!(tag.description, "Use newMethod() instead");
}
#[test]
fn parse_mixin_tag() {
let doc = "/** @mixin \\App\\Models\\User */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Mixin)
.expect("should have mixin");
assert_eq!(tag.description, "\\App\\Models\\User");
}
#[test]
fn parse_throws_tag() {
let doc = "/** @throws \\InvalidArgumentException When input is bad */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Throws)
.expect("should have throws");
assert_eq!(
tag.description,
"\\InvalidArgumentException When input is bad"
);
}
#[test]
fn parse_var_tag() {
let doc = "/** @var array<int, string> $items */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Var)
.expect("should have var");
assert_eq!(tag.description, "array<int, string> $items");
}
#[test]
fn parse_see_tag() {
let doc = "/** @see MyClass::method() */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::See)
.expect("should have see");
assert_eq!(tag.description, "MyClass::method()");
}
#[test]
fn parse_phpstan_assert_tags() {
let doc = r#"/**
* @phpstan-assert string $value
* @phpstan-assert-if-true non-empty-string $value
*/"#;
let info = parse_docblock_for_tags(doc).expect("should parse");
let kinds: Vec<TagKind> = info.tags.iter().map(|t| t.kind).collect();
assert_eq!(
kinds,
vec![TagKind::PhpstanAssert, TagKind::PhpstanAssertIfTrue]
);
}
#[test]
fn tags_by_kind_filters_correctly() {
let doc = r#"/**
* @param string $a
* @return int
* @param bool $b
*/"#;
let info = parse_docblock_for_tags(doc).expect("should parse");
let params: Vec<_> = info.tags_by_kind(TagKind::Param).collect();
assert_eq!(params.len(), 2);
assert_eq!(params[0].description, "string $a");
assert_eq!(params[1].description, "bool $b");
let returns: Vec<_> = info.tags_by_kind(TagKind::Return).collect();
assert_eq!(returns.len(), 1);
}
#[test]
fn tags_by_kinds_filters_multiple() {
let doc = r#"/**
* @phpstan-assert int $x
* @psalm-assert string $y
* @param bool $z
*/"#;
let info = parse_docblock_for_tags(doc).expect("should parse");
let asserts: Vec<_> = info
.tags_by_kinds(&[TagKind::PhpstanAssert, TagKind::PsalmAssert])
.collect();
assert_eq!(asserts.len(), 2);
}
#[test]
fn invalid_docblock_returns_none() {
assert!(parse_docblock_for_tags("/* not a docblock */").is_none());
assert!(parse_docblock_for_tags("// not a docblock").is_none());
assert!(parse_docblock_for_tags("").is_none());
}
#[test]
fn parse_template_tags() {
let doc = r#"/**
* @template T
* @template-covariant TValue of object
*/"#;
let info = parse_docblock_for_tags(doc).expect("should parse");
let templates: Vec<_> = info
.tags_by_kinds(&[TagKind::Template, TagKind::TemplateCovariant])
.collect();
assert_eq!(templates.len(), 2);
assert_eq!(templates[0].kind, TagKind::Template);
assert_eq!(templates[0].description, "T");
assert_eq!(templates[1].kind, TagKind::TemplateCovariant);
assert_eq!(templates[1].description, "TValue of object");
}
#[test]
fn parse_property_tags() {
let doc = r#"/**
* @property string $name
* @property-read int $id
* @property-write bool $active
*/"#;
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(info.tags.len(), 3);
assert_eq!(info.tags[0].kind, TagKind::Property);
assert_eq!(info.tags[1].kind, TagKind::PropertyRead);
assert_eq!(info.tags[2].kind, TagKind::PropertyWrite);
}
#[test]
fn parse_method_tag() {
let doc = "/** @method static Builder query() */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Method)
.expect("should have method");
assert_eq!(tag.description, "static Builder query()");
}
#[test]
fn parse_multiline_param_type() {
let doc = r#"/**
* @param array{
* name: string,
* age: int
* } $data
*/"#;
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Param)
.expect("should have param");
assert!(tag.description.contains("$data"));
assert!(tag.description.contains("name: string"));
}
#[test]
fn parse_link_tag() {
let doc = "/** @link https://php.net/array_map */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Link)
.expect("should have link");
assert_eq!(tag.description, "https://php.net/array_map");
}
#[test]
fn parse_extends_tag() {
let doc = "/** @extends Collection<int, User> */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Extends)
.expect("should have extends");
assert_eq!(tag.description, "Collection<int, User>");
}
#[test]
fn parse_phpstan_type_tag() {
let doc = "/** @phpstan-type Money = array{amount: int, currency: string} */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::PhpstanType)
.expect("should have type");
assert!(tag.description.contains("Money"));
}
#[test]
fn parse_phpstan_import_type_tag() {
let doc = "/** @phpstan-import-type Money from PriceCalculator */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::PhpstanImportType)
.expect("should have import-type");
assert!(tag.description.contains("Money"));
assert!(tag.description.contains("PriceCalculator"));
}
#[test]
fn parse_param_closure_this_tag() {
let doc = "/** @param-closure-this \\App\\Route $callback */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::ParamClosureThis)
.expect("should have param-closure-this");
assert!(tag.description.contains("\\App\\Route"));
assert!(tag.description.contains("$callback"));
}
#[test]
fn phpstan_extends_tag_parsed_as_other() {
let doc = "/**\n * @phpstan-extends Collection<int, User>\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(info.tags.len(), 1);
assert_eq!(info.tags[0].kind, TagKind::Other);
assert_eq!(info.tags[0].name, "phpstan-extends");
assert_eq!(info.tags[0].description, "Collection<int, User>");
}
#[test]
fn multiline_return_description_uses_newlines() {
let doc = "/**\n * @return array an array containing all the elements of arr1\n * after applying the callback function to each one.\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Return)
.expect("should have return");
assert_eq!(
tag.description,
"array an array containing all the elements of arr1\nafter applying the callback function to each one."
);
}
#[test]
fn multiline_type_in_return_tag() {
let doc =
"/**\n * @return array{\n * name: string,\n * age: int\n * } the user data\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = info
.first_tag_by_kind(TagKind::Return)
.expect("should have return");
assert!(
tag.description.contains("name: string"),
"should contain shape fields: {:?}",
tag.description
);
assert!(
tag.description.contains("the user data"),
"should contain description after type: {:?}",
tag.description
);
}
#[test]
fn description_extracted_from_text_elements() {
let doc = "/**\n * This is a description.\n * Second line.\n *\n * @return string\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(
info.description.as_deref(),
Some("This is a description.\nSecond line.")
);
assert_eq!(info.tags.len(), 1);
assert_eq!(info.tags[0].kind, TagKind::Return);
}
#[test]
fn description_none_when_tags_only() {
let doc = "/** @return string */";
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(info.description, None);
}
#[test]
fn description_with_inline_code() {
let doc = "/**\n * Use `code` here.\n * @return void\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(info.description.as_deref(), Some("Use `code` here."));
}
#[test]
fn description_with_inline_link_tag() {
let doc = "/**\n * See {@link https://php.net} for details.\n * @return void\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(
info.description.as_deref(),
Some("See {@link https://php.net} for details.")
);
}
#[test]
fn description_with_html_tags_preserved() {
let doc = "/**\n * Use <b>bold</b> text.\n * @param string $x\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let desc = info
.description
.as_deref()
.expect("should have description");
assert!(
desc.contains("<b>bold</b>"),
"HTML tags should be preserved in raw description: {desc}"
);
}
#[test]
fn tag_spans_are_populated() {
let doc = "/** @return string The result */";
let info = parse_docblock_for_tags(doc).expect("should parse");
let tag = &info.tags[0];
assert!(
tag.span.start.offset < tag.span.end.offset,
"tag span should be non-empty"
);
assert!(
tag.description_span.start.offset < tag.description_span.end.offset,
"description span should be non-empty"
);
}
#[test]
fn description_only_docblock() {
let doc = "/**\n * Just a description, no tags.\n */";
let info = parse_docblock_for_tags(doc).expect("should parse");
assert_eq!(
info.description.as_deref(),
Some("Just a description, no tags.")
);
assert!(info.tags.is_empty());
}
#[test]
fn partial_docblock_without_closing_delimiter() {
let doc = "/**\n * @param string $name\n * @return ";
let result = parse_docblock_for_tags(doc);
if let Some(info) = result {
assert!(
!info.tags.is_empty(),
"partial parse should find at least one tag"
);
let params: Vec<_> = info.tags_by_kind(TagKind::Param).collect();
assert_eq!(params.len(), 1, "should find one @param tag");
assert!(
params[0].description.contains("$name"),
"param description should contain $name: {:?}",
params[0].description
);
}
}
#[test]
fn partial_docblock_with_trailing_at_sign() {
let doc = "/**\n * @param string $name\n * @";
let result = parse_docblock_for_tags(doc);
assert!(
result.is_none(),
"bare partial docblock with trailing @ returns None"
);
let fixed = parse_docblock_for_tags_lossy(doc);
let info = fixed.expect("fix-up should make partial docblock parseable");
let params: Vec<_> = info.tags_by_kind(TagKind::Param).collect();
assert_eq!(
params.len(),
1,
"should find @param after fix-up: tags={:?}",
info.tags.iter().map(|t| &t.name).collect::<Vec<_>>()
);
assert!(
params[0].description.contains("$name"),
"param should contain $name: {:?}",
params[0].description
);
}
#[test]
fn lossy_parse_already_complete_docblock() {
let doc = "/**\n * @param int $x\n * @return string\n */";
let info = parse_docblock_for_tags_lossy(doc).expect("should parse");
assert_eq!(info.tags.len(), 2);
assert_eq!(info.tags[0].kind, TagKind::Param);
assert_eq!(info.tags[1].kind, TagKind::Return);
}
#[test]
fn complete_docblock_with_bare_at_mid_body() {
let doc = "/**\n * @throws RuntimeException\n * @\n */";
assert!(parse_docblock_for_tags(doc).is_none());
let info = parse_docblock_for_tags_lossy(doc).expect("lossy should parse");
let throws: Vec<_> = info.tags_by_kind(TagKind::Throws).collect();
assert_eq!(
throws.len(),
1,
"should find @throws despite bare @: tags={:?}",
info.tags
.iter()
.map(|t| format!("@{}", t.name))
.collect::<Vec<_>>()
);
assert!(
throws[0].description.contains("RuntimeException"),
"throws tag should contain RuntimeException: {:?}",
throws[0].description
);
}
}