1use std::collections::HashMap;
6
7use mir_analyzer::DocblockParser;
8use php_rs_parser::phpdoc;
9
10fn flatten_phpdoc_text(t: &phpdoc::PhpDocText) -> String {
13 let mut s = String::new();
14 for seg in &t.segments {
15 match seg {
16 phpdoc::TextSegment::Text(txt) => s.push_str(txt),
17 phpdoc::TextSegment::InlineTag(it) => {
18 s.push('{');
19 s.push('@');
20 s.push_str(&it.name);
21 if let Some(b) = &it.body {
22 s.push(' ');
23 s.push_str(b);
24 }
25 s.push('}');
26 }
27 }
28 }
29 s
30}
31
32fn parse_param_body(s: &str) -> Option<(String, String)> {
35 let mut iter = s.split_whitespace();
36 let mut name = None;
37 for tok in iter.by_ref() {
38 if let Some(n) = tok.strip_prefix('$') {
39 name = Some(n.to_string());
40 break;
41 }
42 }
43 let desc: Vec<&str> = iter.collect();
44 name.map(|n| (n, desc.join(" ").trim().to_string()))
45}
46
47fn body_after_type_hint(s: &str) -> Option<String> {
50 let trimmed = s.trim_start();
51 let mut split = trimmed.splitn(2, char::is_whitespace);
52 let _type = split.next()?;
53 Some(split.next().unwrap_or("").trim().to_string())
54}
55
56fn body_after_type_and_var(s: &str) -> Option<String> {
59 let mut iter = s.split_whitespace();
60 let _type = iter.next()?;
61 let next = iter.next();
62 let rest: Vec<&str> = if let Some(tok) = next {
63 if tok.starts_with('$') {
64 iter.collect()
65 } else {
66 std::iter::once(tok).chain(iter).collect()
67 }
68 } else {
69 Vec::new()
70 };
71 Some(rest.join(" ").trim().to_string())
72}
73
74#[derive(Debug, Default, PartialEq)]
75pub struct Docblock {
76 pub description: String,
78 pub params: Vec<DocParam>,
80 pub return_type: Option<DocReturn>,
82 pub var_type: Option<String>,
84 pub var_name: Option<String>,
86 pub var_description: Option<String>,
88 pub deprecated: Option<String>,
90 pub throws: Vec<DocThrows>,
92 pub see: Vec<String>,
94 pub templates: Vec<DocTemplate>,
96 pub mixins: Vec<String>,
98 pub is_inherit_doc: bool,
100 pub type_aliases: Vec<DocTypeAlias>,
102 pub properties: Vec<DocProperty>,
104 pub methods: Vec<DocMethod>,
106}
107
108#[derive(Debug, PartialEq)]
109pub struct DocProperty {
110 pub type_hint: String,
111 pub name: String, pub read_only: bool, }
114
115#[derive(Debug, PartialEq)]
116pub struct DocMethod {
117 pub return_type: String,
118 pub name: String,
119 pub is_static: bool,
120}
121
122#[derive(Debug, PartialEq)]
123pub struct DocTypeAlias {
124 pub name: String,
126 pub type_expr: String,
128}
129
130#[derive(Debug, PartialEq)]
131pub struct DocTemplate {
132 pub name: String,
134 pub bound: Option<String>,
136}
137
138#[derive(Debug, PartialEq)]
139pub struct DocParam {
140 pub type_hint: String,
141 pub name: String,
142 pub description: String,
143}
144
145#[derive(Debug, PartialEq)]
146pub struct DocReturn {
147 pub type_hint: String,
148 pub description: String,
149}
150
151#[derive(Debug, PartialEq)]
152pub struct DocThrows {
153 pub class: String,
154 pub description: String,
155}
156
157impl Docblock {
158 pub fn is_deprecated(&self) -> bool {
160 self.deprecated.is_some()
161 }
162
163 pub fn to_markdown(&self) -> String {
165 let mut out = String::new();
166
167 if let Some(msg) = &self.deprecated {
168 if msg.is_empty() {
169 out.push_str("> **Deprecated**\n\n");
170 } else {
171 out.push_str(&format!("> **Deprecated**: {}\n\n", msg));
172 }
173 }
174
175 if !self.description.is_empty() {
176 out.push_str(&self.description);
177 out.push_str("\n\n");
178 }
179 if let Some(vt) = &self.var_type {
180 out.push_str(&format!("**@var** `{}`", vt));
181 if let Some(vd) = &self.var_description
182 && !vd.is_empty()
183 {
184 out.push_str(&format!(" — {}", vd));
185 }
186 out.push('\n');
187 }
188 if let Some(ret) = &self.return_type {
189 out.push_str(&format!("**@return** `{}`", ret.type_hint));
190 if !ret.description.is_empty() {
191 out.push_str(&format!(" — {}", ret.description));
192 }
193 out.push('\n');
194 }
195 for p in &self.params {
196 out.push_str(&format!(
197 "**@param** `{}` `{}`",
198 p.type_hint,
199 &p.name.to_string()
200 ));
201 if !p.description.is_empty() {
202 out.push_str(&format!(" — {}", p.description));
203 }
204 out.push('\n');
205 }
206 for t in &self.throws {
207 out.push_str(&format!("**@throws** `{}`", t.class));
208 if !t.description.is_empty() {
209 out.push_str(&format!(" — {}", t.description));
210 }
211 out.push('\n');
212 }
213 for s in &self.see {
214 out.push_str(&format!("**@see** {}\n", s));
215 }
216 for t in &self.templates {
217 if let Some(bound) = &t.bound {
218 out.push_str(&format!("**@template** `{}` of `{}`\n", t.name, bound));
219 } else {
220 out.push_str(&format!("**@template** `{}`\n", &t.name.to_string()));
221 }
222 }
223 for m in &self.mixins {
224 out.push_str(&format!("**@mixin** `{}`\n", m));
225 }
226 for ta in &self.type_aliases {
227 if ta.type_expr.is_empty() {
228 out.push_str(&format!("**@type** `{}`\n", &ta.name.to_string()));
229 } else {
230 out.push_str(&format!("**@type** `{}` = `{}`\n", ta.name, ta.type_expr));
231 }
232 }
233 out.trim_end().to_string()
234 }
235}
236
237pub fn parse_docblock(raw: &str) -> Docblock {
243 let is_inherit_doc = {
244 let stripped = raw
245 .trim_start_matches("/**")
246 .trim_end_matches("*/")
247 .replace('*', "")
248 .replace(['{', '}'], "")
249 .trim()
250 .to_lowercase();
251 stripped == "@inheritdoc"
252 };
253
254 let mir = DocblockParser::parse(raw);
255 let raw_doc = phpdoc::parse(raw);
256
257 let mut param_descs: HashMap<String, String> = HashMap::new();
259 let mut return_desc = String::new();
260 let mut throws_descs: Vec<String> = Vec::new();
261 let mut var_desc: Option<String> = None;
262
263 for tag in &raw_doc.tags {
264 let body = tag
265 .body
266 .as_ref()
267 .map(flatten_phpdoc_text)
268 .unwrap_or_default();
269 match tag.name.as_str() {
270 "param" => {
271 if let Some((name, desc)) = parse_param_body(&body)
274 && !desc.is_empty()
275 {
276 param_descs.insert(name, desc);
277 }
278 }
279 "return" => {
280 if let Some(d) = body_after_type_hint(&body)
282 && !d.is_empty()
283 {
284 return_desc = d;
285 }
286 }
287 "throws" => {
288 let mut parts = body.split_whitespace();
290 if let Some(class) = parts.next()
291 && !class.is_empty()
292 {
293 let desc = parts.collect::<Vec<_>>().join(" ");
294 throws_descs.push(desc);
295 }
296 }
297 "var" => {
298 if let Some(d) = body_after_type_and_var(&body)
300 && !d.is_empty()
301 {
302 var_desc = Some(d);
303 }
304 }
305 _ => {}
306 }
307 }
308
309 let params: Vec<DocParam> = mir
310 .params
311 .iter()
312 .map(|(name, union)| {
313 let description = param_descs.get(name.as_str()).cloned().unwrap_or_default();
314 DocParam {
315 type_hint: union.to_string(),
316 name: format!("${}", name),
317 description,
318 }
319 })
320 .collect();
321
322 let return_type = mir.return_type.as_ref().map(|union| DocReturn {
323 type_hint: union.to_string(),
324 description: return_desc,
325 });
326
327 let throws: Vec<DocThrows> = mir
328 .throws
329 .iter()
330 .enumerate()
331 .map(|(i, class)| DocThrows {
332 class: class.clone(),
333 description: throws_descs.get(i).cloned().unwrap_or_default(),
334 })
335 .collect();
336
337 let deprecated = if mir.is_deprecated {
338 Some(mir.deprecated.as_deref().unwrap_or("").to_string())
339 } else {
340 None
341 };
342
343 let templates: Vec<DocTemplate> = raw_doc
348 .tags
349 .iter()
350 .filter(|t| {
351 t.name == "template"
352 || t.name == "template-covariant"
353 || t.name == "template-contravariant"
354 || t.name == "psalm-template"
355 || t.name == "phpstan-template"
356 })
357 .filter_map(|t| {
358 let body_full = t.body.as_ref().map(flatten_phpdoc_text).unwrap_or_default();
363 let body: String = body_full
364 .split_whitespace()
365 .take_while(|tok| !tok.starts_with('@'))
366 .collect::<Vec<_>>()
367 .join(" ");
368 let mut iter = body.split_whitespace();
369 let name = iter.next()?.to_string();
370 let bound = match iter.next() {
374 Some("of" | "as") => iter.next().map(|s| s.to_string()),
375 Some(other) => Some(other.to_string()),
376 None => None,
377 };
378 Some(DocTemplate { name, bound })
379 })
380 .collect();
381
382 let properties: Vec<DocProperty> = mir
383 .properties
384 .iter()
385 .map(|p| DocProperty {
386 type_hint: p.type_hint.clone(),
387 name: p.name.clone(),
388 read_only: p.read_only,
389 })
390 .collect();
391
392 let methods: Vec<DocMethod> = mir
393 .methods
394 .iter()
395 .map(|m| DocMethod {
396 return_type: m.return_type.clone(),
397 name: m.name.clone(),
398 is_static: m.is_static,
399 })
400 .collect();
401
402 let type_aliases: Vec<DocTypeAlias> = mir
403 .type_aliases
404 .iter()
405 .map(|ta| DocTypeAlias {
406 name: ta.name.clone(),
407 type_expr: ta.type_expr.clone(),
408 })
409 .collect();
410
411 let (var_type_from_body, var_name_from_body) = raw_doc
415 .tags
416 .iter()
417 .find(|t| t.name == "var" || t.name == "psalm-var" || t.name == "phpstan-var")
418 .and_then(|t| t.body.as_ref())
419 .map(flatten_phpdoc_text)
420 .map(|body| {
421 let mut ty = None;
422 let mut name = None;
423 for tok in body.split_whitespace() {
424 if let Some(n) = tok.strip_prefix('$') {
425 if name.is_none() {
426 name = Some(n.to_string());
427 }
428 } else if ty.is_none() {
429 ty = Some(tok.to_string());
430 }
431 if ty.is_some() && name.is_some() {
432 break;
433 }
434 }
435 (ty, name)
436 })
437 .unwrap_or((None, None));
438
439 Docblock {
440 description: mir.description.clone(),
441 params,
442 return_type,
443 var_type: var_type_from_body.or_else(|| mir.var_type.as_ref().map(|u| u.to_string())),
444 var_name: var_name_from_body.or_else(|| mir.var_name.clone()),
445 var_description: var_desc,
446 deprecated,
447 throws,
448 see: mir.see.clone(),
449 templates,
450 mixins: mir.mixins.clone(),
451 type_aliases,
452 properties,
453 methods,
454 is_inherit_doc,
455 }
456}
457
458pub fn docblock_before(source: &str, node_start: u32) -> Option<String> {
462 let prefix = source.get(..node_start as usize)?;
465 let trimmed_end = prefix.trim_end();
466 let close = trimmed_end.strip_suffix("*/")?;
467 let open_idx = close.rfind("/**")?;
468 Some(format!(
469 "{}*/",
470 &trimmed_end[open_idx..trimmed_end.len() - 2]
471 ))
472}
473
474pub fn find_docblock(
476 source: &str,
477 stmts: &[php_ast::Stmt<'_, '_>],
478 word: &str,
479) -> Option<Docblock> {
480 use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, StmtKind};
481 for stmt in stmts {
482 match &stmt.kind {
483 StmtKind::Function(f) if f.name == word => {
484 let raw = docblock_before(source, stmt.span.start)?;
485 return Some(parse_docblock(&raw));
486 }
487 StmtKind::Class(c)
488 if c.name.as_ref().map(|n| n.to_string()) == Some(word.to_string()) =>
489 {
490 let raw = docblock_before(source, stmt.span.start)?;
491 return Some(parse_docblock(&raw));
492 }
493 StmtKind::Interface(i) if i.name == word => {
494 let raw = docblock_before(source, stmt.span.start)?;
495 return Some(parse_docblock(&raw));
496 }
497 StmtKind::Trait(t) if t.name == word => {
498 let raw = docblock_before(source, stmt.span.start)?;
499 return Some(parse_docblock(&raw));
500 }
501 StmtKind::Enum(e) if e.name == word => {
502 let raw = docblock_before(source, stmt.span.start)?;
503 return Some(parse_docblock(&raw));
504 }
505 StmtKind::Class(c) => {
506 for member in c.members.iter() {
507 match &member.kind {
508 ClassMemberKind::Method(m) if m.name == word => {
509 let raw = docblock_before(source, member.span.start)?;
510 return Some(parse_docblock(&raw));
511 }
512 ClassMemberKind::ClassConst(k) if k.name == word => {
513 let raw = docblock_before(source, member.span.start)?;
514 return Some(parse_docblock(&raw));
515 }
516 _ => {}
517 }
518 }
519 }
520 StmtKind::Interface(i) => {
521 for member in i.members.iter() {
522 match &member.kind {
523 ClassMemberKind::Method(m) if m.name == word => {
524 let raw = docblock_before(source, member.span.start)?;
525 return Some(parse_docblock(&raw));
526 }
527 ClassMemberKind::ClassConst(k) if k.name == word => {
528 let raw = docblock_before(source, member.span.start)?;
529 return Some(parse_docblock(&raw));
530 }
531 _ => {}
532 }
533 }
534 }
535 StmtKind::Trait(t) => {
536 for member in t.members.iter() {
537 if let ClassMemberKind::Method(m) = &member.kind
538 && m.name == word
539 {
540 let raw = docblock_before(source, member.span.start)?;
541 return Some(parse_docblock(&raw));
542 }
543 }
544 }
545 StmtKind::Enum(e) => {
546 for member in e.members.iter() {
547 match &member.kind {
548 EnumMemberKind::Method(m) if m.name == word => {
549 let raw = docblock_before(source, member.span.start)?;
550 return Some(parse_docblock(&raw));
551 }
552 EnumMemberKind::Case(c) if c.name == word => {
553 let raw = docblock_before(source, member.span.start)?;
554 return Some(parse_docblock(&raw));
555 }
556 EnumMemberKind::ClassConst(k) if k.name == word => {
557 let raw = docblock_before(source, member.span.start)?;
558 return Some(parse_docblock(&raw));
559 }
560 _ => {}
561 }
562 }
563 }
564 StmtKind::Namespace(ns) => {
565 if let NamespaceBody::Braced(inner) = &ns.body
566 && let Some(db) = find_docblock(source, inner, word)
567 {
568 return Some(db);
569 }
570 }
571 _ => {}
572 }
573 }
574 None
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn parses_description() {
583 let raw = "/** Does something useful. */";
584 let db = parse_docblock(raw);
585 assert_eq!(db.description, "Does something useful.");
586 }
587
588 #[test]
589 fn parses_return_tag() {
590 let raw = "/**\n * @return string The greeting\n */";
591 let db = parse_docblock(raw);
592 let ret = db.return_type.unwrap();
593 assert_eq!(ret.type_hint, "string");
594 assert_eq!(ret.description, "The greeting");
595 }
596
597 #[test]
598 fn parses_param_tag() {
599 let raw = "/**\n * @param string $name The user name\n */";
600 let db = parse_docblock(raw);
601 assert_eq!(db.params.len(), 1);
602 assert_eq!(db.params[0].type_hint, "string");
603 assert_eq!(db.params[0].name, "$name");
604 assert_eq!(db.params[0].description, "The user name");
605 }
606
607 #[test]
608 fn parses_var_tag() {
609 let raw = "/** @var string */";
610 let db = parse_docblock(raw);
611 assert_eq!(db.var_type.as_deref(), Some("string"));
612 }
613
614 #[test]
615 fn parses_var_tag_with_description() {
616 let raw = "/** @var string The user's name */";
617 let db = parse_docblock(raw);
618 assert_eq!(db.var_type.as_deref(), Some("string"));
619 assert_eq!(db.var_description.as_deref(), Some("The user's name"));
620 }
621
622 #[test]
623 fn to_markdown_shows_var_type() {
624 let db = Docblock {
625 var_type: Some("string".to_string()),
626 ..Default::default()
627 };
628 let md = db.to_markdown();
629 assert!(
630 md.contains("@var"),
631 "expected @var in markdown, got: {}",
632 md
633 );
634 assert!(
635 md.contains("string"),
636 "expected type in markdown, got: {}",
637 md
638 );
639 }
640
641 #[test]
642 fn to_markdown_shows_var_type_with_description() {
643 let db = Docblock {
644 var_type: Some("string".to_string()),
645 var_description: Some("The user's name".to_string()),
646 ..Default::default()
647 };
648 let md = db.to_markdown();
649 assert!(
650 md.contains("@var"),
651 "expected @var in markdown, got: {}",
652 md
653 );
654 assert!(
655 md.contains("string"),
656 "expected type in markdown, got: {}",
657 md
658 );
659 assert!(
660 md.contains("The user's name"),
661 "expected description in markdown, got: {}",
662 md
663 );
664 }
665
666 #[test]
667 fn multiple_params() {
668 let raw = "/**\n * @param int $a First\n * @param int $b Second\n */";
669 let db = parse_docblock(raw);
670 assert_eq!(db.params.len(), 2);
671 assert_eq!(db.params[0].name, "$a");
672 assert_eq!(db.params[1].name, "$b");
673 }
674
675 #[test]
676 fn to_markdown_includes_description_and_return() {
677 let db = Docblock {
678 description: "Greets the user.".to_string(),
679 params: vec![],
680 return_type: Some(DocReturn {
681 type_hint: "string".to_string(),
682 description: "The greeting".to_string(),
683 }),
684 var_type: None,
685 ..Default::default()
686 };
687 let md = db.to_markdown();
688 assert!(md.contains("Greets the user."));
689 assert!(md.contains("@return"));
690 assert!(md.contains("string"));
691 }
692
693 #[test]
694 fn find_docblock_from_ast() {
695 use crate::ast::ParsedDoc;
696 let src = "<?php\n/** Greets someone. */\nfunction greet() {}";
697 let doc = ParsedDoc::parse(src.to_string());
698 let db = find_docblock(src, &doc.program().stmts, "greet");
699 assert!(db.is_some(), "expected docblock for greet");
700 assert!(db.unwrap().description.contains("Greets"));
701 }
702
703 #[test]
704 fn find_docblock_returns_none_without_docblock() {
705 use crate::ast::ParsedDoc;
706 let src = "<?php\nfunction greet() {}";
707 let doc = ParsedDoc::parse(src.to_string());
708 let db = find_docblock(src, &doc.program().stmts, "greet");
709 assert!(db.is_none());
710 }
711
712 #[test]
713 fn empty_docblock_gives_defaults() {
714 let db = parse_docblock("/** */");
715 assert_eq!(db.description, "");
716 assert!(db.return_type.is_none());
717 assert!(db.params.is_empty());
718 }
719
720 #[test]
721 fn parses_deprecated_with_message() {
722 let raw = "/**\n * @deprecated Use newMethod() instead\n */";
723 let db = parse_docblock(raw);
724 assert_eq!(db.deprecated.as_deref(), Some("Use newMethod() instead"));
725 assert!(db.is_deprecated());
726 }
727
728 #[test]
729 fn parses_deprecated_without_message() {
730 let raw = "/** @deprecated */";
731 let db = parse_docblock(raw);
732 assert_eq!(db.deprecated.as_deref(), Some(""));
733 assert!(db.is_deprecated());
734 }
735
736 #[test]
737 fn not_deprecated_when_tag_absent() {
738 let raw = "/** Does stuff. */";
739 let db = parse_docblock(raw);
740 assert!(!db.is_deprecated());
741 }
742
743 #[test]
744 fn parses_throws_tag() {
745 let raw = "/**\n * @throws RuntimeException When something fails\n */";
746 let db = parse_docblock(raw);
747 assert_eq!(db.throws.len(), 1);
748 assert_eq!(db.throws[0].class, "RuntimeException");
749 assert_eq!(db.throws[0].description, "When something fails");
750 }
751
752 #[test]
753 fn parses_multiple_throws() {
754 let raw =
755 "/**\n * @throws InvalidArgumentException\n * @throws RuntimeException Bad state\n */";
756 let db = parse_docblock(raw);
757 assert_eq!(db.throws.len(), 2);
758 assert_eq!(db.throws[0].class, "InvalidArgumentException");
759 assert_eq!(db.throws[1].class, "RuntimeException");
760 }
761
762 #[test]
763 fn parses_see_tag() {
764 let raw = "/**\n * @see OtherClass::method()\n */";
765 let db = parse_docblock(raw);
766 assert_eq!(db.see.len(), 1);
767 assert_eq!(db.see[0], "OtherClass::method()");
768 }
769
770 #[test]
771 fn parses_link_tag() {
772 let raw = "/**\n * @link https://example.com/docs\n */";
773 let db = parse_docblock(raw);
774 assert_eq!(db.see.len(), 1);
775 assert_eq!(db.see[0], "https://example.com/docs");
776 }
777
778 #[test]
779 fn to_markdown_shows_deprecated_banner() {
780 let db = Docblock {
781 deprecated: Some("Use bar() instead".to_string()),
782 description: "Does foo.".to_string(),
783 ..Default::default()
784 };
785 let md = db.to_markdown();
786 assert!(
787 md.contains("> **Deprecated**"),
788 "expected deprecated banner, got: {}",
789 md
790 );
791 assert!(
792 md.contains("Use bar() instead"),
793 "expected deprecation message, got: {}",
794 md
795 );
796 }
797
798 #[test]
799 fn to_markdown_shows_throws() {
800 let db = Docblock {
801 throws: vec![DocThrows {
802 class: "RuntimeException".to_string(),
803 description: "On failure".to_string(),
804 }],
805 ..Default::default()
806 };
807 let md = db.to_markdown();
808 assert!(
809 md.contains("@throws"),
810 "expected @throws in markdown, got: {}",
811 md
812 );
813 assert!(
814 md.contains("RuntimeException"),
815 "expected class name, got: {}",
816 md
817 );
818 }
819
820 #[test]
821 fn to_markdown_shows_see() {
822 let db = Docblock {
823 see: vec!["https://example.com".to_string()],
824 ..Default::default()
825 };
826 let md = db.to_markdown();
827 assert!(
828 md.contains("@see"),
829 "expected @see in markdown, got: {}",
830 md
831 );
832 assert!(
833 md.contains("https://example.com"),
834 "expected url, got: {}",
835 md
836 );
837 }
838
839 #[test]
840 fn parses_template_tag() {
841 let raw = "/**\n * @template T\n */";
842 let db = parse_docblock(raw);
843 assert_eq!(db.templates.len(), 1);
844 assert_eq!(db.templates[0].name, "T");
845 assert!(db.templates[0].bound.is_none());
846 }
847
848 #[test]
849 fn parses_template_with_bound() {
850 let raw = "/**\n * @template T of BaseClass\n */";
851 let db = parse_docblock(raw);
852 assert_eq!(db.templates.len(), 1);
853 assert_eq!(db.templates[0].name, "T");
854 assert_eq!(db.templates[0].bound.as_deref(), Some("BaseClass"));
855 }
856
857 #[test]
858 fn parses_mixin_tag() {
859 let raw = "/**\n * @mixin SomeTrait\n */";
860 let db = parse_docblock(raw);
861 assert_eq!(db.mixins.len(), 1);
862 assert_eq!(db.mixins[0], "SomeTrait");
863 }
864
865 #[test]
866 fn parses_callable_param() {
867 let raw = "/**\n * @param callable(int, string): void $fn The callback\n */";
868 let db = parse_docblock(raw);
869 assert_eq!(db.params.len(), 1);
870 assert_eq!(db.params[0].type_hint, "callable(int, string): void");
871 assert_eq!(db.params[0].name, "$fn");
872 assert_eq!(db.params[0].description, "The callback");
873 }
874
875 #[test]
876 fn to_markdown_shows_template() {
877 let db = Docblock {
878 templates: vec![DocTemplate {
879 name: "T".to_string(),
880 bound: Some("Base".to_string()),
881 }],
882 ..Default::default()
883 };
884 let md = db.to_markdown();
885 assert!(
886 md.contains("@template"),
887 "expected @template in markdown, got: {}",
888 md
889 );
890 assert!(md.contains("T"), "expected T in markdown");
891 assert!(md.contains("Base"), "expected Base in markdown");
892 }
893
894 #[test]
895 fn to_markdown_shows_mixin() {
896 let db = Docblock {
897 mixins: vec!["SomeTrait".to_string()],
898 ..Default::default()
899 };
900 let md = db.to_markdown();
901 assert!(
902 md.contains("@mixin"),
903 "expected @mixin in markdown, got: {}",
904 md
905 );
906 assert!(md.contains("SomeTrait"), "expected SomeTrait in markdown");
907 }
908
909 #[test]
910 fn parses_psalm_type_alias() {
911 let raw = "/**\n * @psalm-type UserId = string|int\n */";
912 let db = parse_docblock(raw);
913 assert_eq!(db.type_aliases.len(), 1);
914 assert_eq!(db.type_aliases[0].name, "UserId");
915 assert_eq!(db.type_aliases[0].type_expr, "string|int");
916 }
917
918 #[test]
919 fn parses_phpstan_type_alias() {
920 let raw = "/** @phpstan-type Row = array{id: int, name: string} */";
921 let db = parse_docblock(raw);
922 assert_eq!(db.type_aliases.len(), 1);
923 assert_eq!(db.type_aliases[0].name, "Row");
924 assert!(db.type_aliases[0].type_expr.contains("array"));
925 }
926
927 #[test]
928 fn to_markdown_shows_type_alias() {
929 let db = Docblock {
930 type_aliases: vec![DocTypeAlias {
931 name: "Status".to_string(),
932 type_expr: "string".to_string(),
933 }],
934 ..Default::default()
935 };
936 let md = db.to_markdown();
937 assert!(md.contains("Status"), "expected alias name in markdown");
938 assert!(md.contains("string"), "expected type expr in markdown");
939 }
940
941 #[test]
942 fn parses_property_tag() {
943 let src = "/** @property string $name */";
944 let db = parse_docblock(src);
945 assert_eq!(db.properties.len(), 1);
946 assert_eq!(db.properties[0].name, "name");
947 assert_eq!(db.properties[0].type_hint, "string");
948 assert!(!db.properties[0].read_only);
949 }
950
951 #[test]
952 fn parses_property_read_tag() {
953 let src = "/** @property-read Carbon $createdAt */";
954 let db = parse_docblock(src);
955 assert_eq!(db.properties[0].name, "createdAt");
956 assert!(db.properties[0].read_only);
957 }
958
959 #[test]
960 fn parses_method_tag() {
961 let src = "/** @method User find(int $id) */";
962 let db = parse_docblock(src);
963 assert_eq!(db.methods.len(), 1);
964 assert_eq!(db.methods[0].name, "find");
965 assert_eq!(db.methods[0].return_type, "User");
966 assert!(!db.methods[0].is_static);
967 }
968
969 #[test]
970 fn parses_static_method_tag() {
971 let src = "/** @method static Builder where(string $col, mixed $val) */";
972 let db = parse_docblock(src);
973 assert!(db.methods[0].is_static);
974 assert_eq!(db.methods[0].name, "where");
975 }
976
977 #[test]
978 fn psalm_param_alias_parsed_as_param() {
979 let raw = "/**\n * @psalm-param string $x The value\n */";
980 let db = parse_docblock(raw);
981 assert_eq!(db.params.len(), 1);
982 assert_eq!(db.params[0].type_hint, "string");
983 assert_eq!(db.params[0].name, "$x");
984 }
985
986 #[test]
987 fn phpstan_param_alias_parsed_as_param() {
988 let raw = "/**\n * @phpstan-param int $count\n */";
989 let db = parse_docblock(raw);
990 assert_eq!(db.params.len(), 1);
991 assert_eq!(db.params[0].type_hint, "int");
992 assert_eq!(db.params[0].name, "$count");
993 }
994
995 #[test]
996 fn psalm_return_alias_parsed_as_return() {
997 let raw = "/**\n * @psalm-return non-empty-string\n */";
998 let db = parse_docblock(raw);
999 assert_eq!(
1000 db.return_type.as_ref().map(|r| r.type_hint.as_str()),
1001 Some("non-empty-string")
1002 );
1003 }
1004
1005 #[test]
1006 fn phpstan_return_alias_parsed_as_return() {
1007 let raw = "/**\n * @phpstan-return array<int, string>\n */";
1008 let db = parse_docblock(raw);
1009 assert_eq!(
1010 db.return_type.as_ref().map(|r| r.type_hint.as_str()),
1011 Some("array<int, string>")
1012 );
1013 }
1014
1015 #[test]
1016 fn psalm_var_alias_parsed_as_var() {
1017 let raw = "/** @psalm-var Foo $item */";
1018 let db = parse_docblock(raw);
1019 assert_eq!(db.var_type.as_deref(), Some("Foo"));
1020 assert_eq!(db.var_name.as_deref(), Some("item"));
1021 }
1022
1023 #[test]
1024 fn phpstan_var_alias_parsed_as_var() {
1025 let raw = "/** @phpstan-var string */";
1026 let db = parse_docblock(raw);
1027 assert_eq!(db.var_type.as_deref(), Some("string"));
1028 }
1029
1030 #[test]
1031 fn param_without_description_parses_correctly() {
1032 let raw = "/**\n * @param string $x\n */";
1033 let db = parse_docblock(raw);
1034 assert_eq!(db.params.len(), 1);
1035 assert_eq!(
1036 db.params[0].type_hint, "string",
1037 "type_hint should be 'string'"
1038 );
1039 assert_eq!(db.params[0].name, "$x", "name should be '$x'");
1040 assert_eq!(
1041 db.params[0].description, "",
1042 "description should be empty when absent"
1043 );
1044 }
1045
1046 #[test]
1047 fn union_type_param_parsed() {
1048 let raw = "/**\n * @param Foo|Bar $x Some value\n */";
1049 let db = parse_docblock(raw);
1050 assert_eq!(db.params.len(), 1);
1051 assert_eq!(
1052 db.params[0].type_hint, "Foo|Bar",
1053 "union type should be 'Foo|Bar', got: {}",
1054 db.params[0].type_hint
1055 );
1056 assert_eq!(db.params[0].name, "$x");
1057 }
1058
1059 #[test]
1060 fn nullable_type_param_parsed() {
1061 let raw = "/**\n * @param ?Foo $x\n */";
1063 let db = parse_docblock(raw);
1064 assert_eq!(db.params.len(), 1);
1065 assert_eq!(
1066 db.params[0].type_hint, "Foo|null",
1067 "nullable type should be 'Foo|null', got: {}",
1068 db.params[0].type_hint
1069 );
1070 assert_eq!(db.params[0].name, "$x");
1071 }
1072
1073 #[test]
1074 fn method_tag_extracts_return_type() {
1075 let raw = "/**\n * @method string getName()\n */";
1076 let db = parse_docblock(raw);
1077 assert_eq!(db.methods.len(), 1);
1078 assert_eq!(
1079 db.methods[0].return_type, "string",
1080 "return_type should be 'string', got: {}",
1081 db.methods[0].return_type
1082 );
1083 assert_eq!(
1084 db.methods[0].name, "getName",
1085 "name should be 'getName', got: {}",
1086 db.methods[0].name
1087 );
1088 assert!(!db.methods[0].is_static, "should not be static");
1089 }
1090
1091 #[test]
1092 fn advanced_type_non_empty_string() {
1093 let raw = "/**\n * @return non-empty-string\n */";
1095 let db = parse_docblock(raw);
1096 assert_eq!(
1097 db.return_type.as_ref().map(|r| r.type_hint.as_str()),
1098 Some("non-empty-string"),
1099 "non-empty-string should be preserved, got: {:?}",
1100 db.return_type
1101 );
1102 }
1103
1104 #[test]
1105 fn advanced_type_generic_array() {
1106 let raw = "/**\n * @param array<int, string> $map\n */";
1108 let db = parse_docblock(raw);
1109 assert_eq!(db.params.len(), 1);
1110 assert_eq!(
1111 db.params[0].type_hint, "array<int, string>",
1112 "generic array type should be preserved, got: {}",
1113 db.params[0].type_hint
1114 );
1115 }
1116
1117 #[test]
1118 fn param_and_return_descriptions_preserved() {
1119 let raw = "/**\n * @param string $name The user name\n * @return int The age\n */";
1122 let db = parse_docblock(raw);
1123 assert_eq!(
1124 db.params[0].description, "The user name",
1125 "param description should be preserved"
1126 );
1127 assert_eq!(
1128 db.return_type.as_ref().map(|r| r.description.as_str()),
1129 Some("The age"),
1130 "return description should be preserved"
1131 );
1132 }
1133
1134 #[test]
1135 fn throws_description_preserved() {
1136 let raw = "/**\n * @throws RuntimeException When the server is down\n */";
1138 let db = parse_docblock(raw);
1139 assert_eq!(db.throws.len(), 1);
1140 assert_eq!(db.throws[0].class, "RuntimeException");
1141 assert_eq!(
1142 db.throws[0].description, "When the server is down",
1143 "throws description should be preserved"
1144 );
1145 }
1146}