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(stmts: &[php_ast::Stmt<'_, '_>], word: &str) -> 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 return f.doc_comment.as_ref().map(|c| parse_docblock(c.text));
485 }
486 StmtKind::Class(c) if c.name.is_some_and(|n| n == word) => {
487 return c.doc_comment.as_ref().map(|c| parse_docblock(c.text));
488 }
489 StmtKind::Interface(i) if i.name == word => {
490 return i.doc_comment.as_ref().map(|c| parse_docblock(c.text));
491 }
492 StmtKind::Trait(t) if t.name == word => {
493 return t.doc_comment.as_ref().map(|c| parse_docblock(c.text));
494 }
495 StmtKind::Enum(e) if e.name == word => {
496 return e.doc_comment.as_ref().map(|c| parse_docblock(c.text));
497 }
498 StmtKind::Class(c) => {
499 for member in c.body.members.iter() {
500 match &member.kind {
501 ClassMemberKind::Method(m) if m.name == word => {
502 return m.doc_comment.as_ref().map(|c| parse_docblock(c.text));
503 }
504 ClassMemberKind::ClassConst(k) if k.name == word => {
505 return k.doc_comment.as_ref().map(|c| parse_docblock(c.text));
506 }
507 _ => {}
508 }
509 }
510 }
511 StmtKind::Interface(i) => {
512 for member in i.body.members.iter() {
513 match &member.kind {
514 ClassMemberKind::Method(m) if m.name == word => {
515 return m.doc_comment.as_ref().map(|c| parse_docblock(c.text));
516 }
517 ClassMemberKind::ClassConst(k) if k.name == word => {
518 return k.doc_comment.as_ref().map(|c| parse_docblock(c.text));
519 }
520 _ => {}
521 }
522 }
523 }
524 StmtKind::Trait(t) => {
525 for member in t.body.members.iter() {
526 if let ClassMemberKind::Method(m) = &member.kind
527 && m.name == word
528 {
529 return m.doc_comment.as_ref().map(|c| parse_docblock(c.text));
530 }
531 }
532 }
533 StmtKind::Enum(e) => {
534 for member in e.body.members.iter() {
535 match &member.kind {
536 EnumMemberKind::Method(m) if m.name == word => {
537 return m.doc_comment.as_ref().map(|c| parse_docblock(c.text));
538 }
539 EnumMemberKind::Case(c) if c.name == word => {
540 return c.doc_comment.as_ref().map(|c| parse_docblock(c.text));
541 }
542 EnumMemberKind::ClassConst(k) if k.name == word => {
543 return k.doc_comment.as_ref().map(|c| parse_docblock(c.text));
544 }
545 _ => {}
546 }
547 }
548 }
549 StmtKind::Namespace(ns) => {
550 if let NamespaceBody::Braced(inner) = &ns.body
551 && let Some(db) = find_docblock(&inner.stmts, word)
552 {
553 return Some(db);
554 }
555 }
556 _ => {}
557 }
558 }
559 None
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 #[test]
567 fn parses_description() {
568 let raw = "/** Does something useful. */";
569 let db = parse_docblock(raw);
570 assert_eq!(db.description, "Does something useful.");
571 }
572
573 #[test]
574 fn parses_return_tag() {
575 let raw = "/**\n * @return string The greeting\n */";
576 let db = parse_docblock(raw);
577 let ret = db.return_type.unwrap();
578 assert_eq!(ret.type_hint, "string");
579 assert_eq!(ret.description, "The greeting");
580 }
581
582 #[test]
583 fn parses_param_tag() {
584 let raw = "/**\n * @param string $name The user name\n */";
585 let db = parse_docblock(raw);
586 assert_eq!(db.params.len(), 1);
587 assert_eq!(db.params[0].type_hint, "string");
588 assert_eq!(db.params[0].name, "$name");
589 assert_eq!(db.params[0].description, "The user name");
590 }
591
592 #[test]
593 fn parses_var_tag() {
594 let raw = "/** @var string */";
595 let db = parse_docblock(raw);
596 assert_eq!(db.var_type.as_deref(), Some("string"));
597 }
598
599 #[test]
600 fn parses_var_tag_with_description() {
601 let raw = "/** @var string The user's name */";
602 let db = parse_docblock(raw);
603 assert_eq!(db.var_type.as_deref(), Some("string"));
604 assert_eq!(db.var_description.as_deref(), Some("The user's name"));
605 }
606
607 #[test]
608 fn to_markdown_shows_var_type() {
609 let db = Docblock {
610 var_type: Some("string".to_string()),
611 ..Default::default()
612 };
613 let md = db.to_markdown();
614 assert!(
615 md.contains("@var"),
616 "expected @var in markdown, got: {}",
617 md
618 );
619 assert!(
620 md.contains("string"),
621 "expected type in markdown, got: {}",
622 md
623 );
624 }
625
626 #[test]
627 fn to_markdown_shows_var_type_with_description() {
628 let db = Docblock {
629 var_type: Some("string".to_string()),
630 var_description: Some("The user's name".to_string()),
631 ..Default::default()
632 };
633 let md = db.to_markdown();
634 assert!(
635 md.contains("@var"),
636 "expected @var in markdown, got: {}",
637 md
638 );
639 assert!(
640 md.contains("string"),
641 "expected type in markdown, got: {}",
642 md
643 );
644 assert!(
645 md.contains("The user's name"),
646 "expected description in markdown, got: {}",
647 md
648 );
649 }
650
651 #[test]
652 fn multiple_params() {
653 let raw = "/**\n * @param int $a First\n * @param int $b Second\n */";
654 let db = parse_docblock(raw);
655 assert_eq!(db.params.len(), 2);
656 assert_eq!(db.params[0].name, "$a");
657 assert_eq!(db.params[1].name, "$b");
658 }
659
660 #[test]
661 fn to_markdown_includes_description_and_return() {
662 let db = Docblock {
663 description: "Greets the user.".to_string(),
664 params: vec![],
665 return_type: Some(DocReturn {
666 type_hint: "string".to_string(),
667 description: "The greeting".to_string(),
668 }),
669 var_type: None,
670 ..Default::default()
671 };
672 let md = db.to_markdown();
673 assert!(md.contains("Greets the user."));
674 assert!(md.contains("@return"));
675 assert!(md.contains("string"));
676 }
677
678 #[test]
679 fn find_docblock_from_ast() {
680 use crate::ast::ParsedDoc;
681 let src = "<?php\n/** Greets someone. */\nfunction greet() {}";
682 let doc = ParsedDoc::parse(src.to_string());
683 let db = find_docblock(&doc.program().stmts, "greet");
684 assert!(db.is_some(), "expected docblock for greet");
685 assert!(db.unwrap().description.contains("Greets"));
686 }
687
688 #[test]
689 fn find_docblock_returns_none_without_docblock() {
690 use crate::ast::ParsedDoc;
691 let src = "<?php\nfunction greet() {}";
692 let doc = ParsedDoc::parse(src.to_string());
693 let db = find_docblock(&doc.program().stmts, "greet");
694 assert!(db.is_none());
695 }
696
697 #[test]
698 fn empty_docblock_gives_defaults() {
699 let db = parse_docblock("/** */");
700 assert_eq!(db.description, "");
701 assert!(db.return_type.is_none());
702 assert!(db.params.is_empty());
703 }
704
705 #[test]
706 fn parses_deprecated_with_message() {
707 let raw = "/**\n * @deprecated Use newMethod() instead\n */";
708 let db = parse_docblock(raw);
709 assert_eq!(db.deprecated.as_deref(), Some("Use newMethod() instead"));
710 assert!(db.is_deprecated());
711 }
712
713 #[test]
714 fn parses_deprecated_without_message() {
715 let raw = "/** @deprecated */";
716 let db = parse_docblock(raw);
717 assert_eq!(db.deprecated.as_deref(), Some(""));
718 assert!(db.is_deprecated());
719 }
720
721 #[test]
722 fn not_deprecated_when_tag_absent() {
723 let raw = "/** Does stuff. */";
724 let db = parse_docblock(raw);
725 assert!(!db.is_deprecated());
726 }
727
728 #[test]
729 fn parses_throws_tag() {
730 let raw = "/**\n * @throws RuntimeException When something fails\n */";
731 let db = parse_docblock(raw);
732 assert_eq!(db.throws.len(), 1);
733 assert_eq!(db.throws[0].class, "RuntimeException");
734 assert_eq!(db.throws[0].description, "When something fails");
735 }
736
737 #[test]
738 fn parses_multiple_throws() {
739 let raw =
740 "/**\n * @throws InvalidArgumentException\n * @throws RuntimeException Bad state\n */";
741 let db = parse_docblock(raw);
742 assert_eq!(db.throws.len(), 2);
743 assert_eq!(db.throws[0].class, "InvalidArgumentException");
744 assert_eq!(db.throws[1].class, "RuntimeException");
745 }
746
747 #[test]
748 fn parses_see_tag() {
749 let raw = "/**\n * @see OtherClass::method()\n */";
750 let db = parse_docblock(raw);
751 assert_eq!(db.see.len(), 1);
752 assert_eq!(db.see[0], "OtherClass::method()");
753 }
754
755 #[test]
756 fn parses_link_tag() {
757 let raw = "/**\n * @link https://example.com/docs\n */";
758 let db = parse_docblock(raw);
759 assert_eq!(db.see.len(), 1);
760 assert_eq!(db.see[0], "https://example.com/docs");
761 }
762
763 #[test]
764 fn to_markdown_shows_deprecated_banner() {
765 let db = Docblock {
766 deprecated: Some("Use bar() instead".to_string()),
767 description: "Does foo.".to_string(),
768 ..Default::default()
769 };
770 let md = db.to_markdown();
771 assert!(
772 md.contains("> **Deprecated**"),
773 "expected deprecated banner, got: {}",
774 md
775 );
776 assert!(
777 md.contains("Use bar() instead"),
778 "expected deprecation message, got: {}",
779 md
780 );
781 }
782
783 #[test]
784 fn to_markdown_shows_throws() {
785 let db = Docblock {
786 throws: vec![DocThrows {
787 class: "RuntimeException".to_string(),
788 description: "On failure".to_string(),
789 }],
790 ..Default::default()
791 };
792 let md = db.to_markdown();
793 assert!(
794 md.contains("@throws"),
795 "expected @throws in markdown, got: {}",
796 md
797 );
798 assert!(
799 md.contains("RuntimeException"),
800 "expected class name, got: {}",
801 md
802 );
803 }
804
805 #[test]
806 fn to_markdown_shows_see() {
807 let db = Docblock {
808 see: vec!["https://example.com".to_string()],
809 ..Default::default()
810 };
811 let md = db.to_markdown();
812 assert!(
813 md.contains("@see"),
814 "expected @see in markdown, got: {}",
815 md
816 );
817 assert!(
818 md.contains("https://example.com"),
819 "expected url, got: {}",
820 md
821 );
822 }
823
824 #[test]
825 fn parses_template_tag() {
826 let raw = "/**\n * @template T\n */";
827 let db = parse_docblock(raw);
828 assert_eq!(db.templates.len(), 1);
829 assert_eq!(db.templates[0].name, "T");
830 assert!(db.templates[0].bound.is_none());
831 }
832
833 #[test]
834 fn parses_template_with_bound() {
835 let raw = "/**\n * @template T of BaseClass\n */";
836 let db = parse_docblock(raw);
837 assert_eq!(db.templates.len(), 1);
838 assert_eq!(db.templates[0].name, "T");
839 assert_eq!(db.templates[0].bound.as_deref(), Some("BaseClass"));
840 }
841
842 #[test]
843 fn parses_mixin_tag() {
844 let raw = "/**\n * @mixin SomeTrait\n */";
845 let db = parse_docblock(raw);
846 assert_eq!(db.mixins.len(), 1);
847 assert_eq!(db.mixins[0], "SomeTrait");
848 }
849
850 #[test]
851 fn parses_callable_param() {
852 let raw = "/**\n * @param callable(int, string): void $fn The callback\n */";
853 let db = parse_docblock(raw);
854 assert_eq!(db.params.len(), 1);
855 assert_eq!(db.params[0].type_hint, "callable(int, string): void");
856 assert_eq!(db.params[0].name, "$fn");
857 assert_eq!(db.params[0].description, "The callback");
858 }
859
860 #[test]
861 fn to_markdown_shows_template() {
862 let db = Docblock {
863 templates: vec![DocTemplate {
864 name: "T".to_string(),
865 bound: Some("Base".to_string()),
866 }],
867 ..Default::default()
868 };
869 let md = db.to_markdown();
870 assert!(
871 md.contains("@template"),
872 "expected @template in markdown, got: {}",
873 md
874 );
875 assert!(md.contains("T"), "expected T in markdown");
876 assert!(md.contains("Base"), "expected Base in markdown");
877 }
878
879 #[test]
880 fn to_markdown_shows_mixin() {
881 let db = Docblock {
882 mixins: vec!["SomeTrait".to_string()],
883 ..Default::default()
884 };
885 let md = db.to_markdown();
886 assert!(
887 md.contains("@mixin"),
888 "expected @mixin in markdown, got: {}",
889 md
890 );
891 assert!(md.contains("SomeTrait"), "expected SomeTrait in markdown");
892 }
893
894 #[test]
895 fn parses_psalm_type_alias() {
896 let raw = "/**\n * @psalm-type UserId = string|int\n */";
897 let db = parse_docblock(raw);
898 assert_eq!(db.type_aliases.len(), 1);
899 assert_eq!(db.type_aliases[0].name, "UserId");
900 assert_eq!(db.type_aliases[0].type_expr, "string|int");
901 }
902
903 #[test]
904 fn parses_phpstan_type_alias() {
905 let raw = "/** @phpstan-type Row = array{id: int, name: string} */";
906 let db = parse_docblock(raw);
907 assert_eq!(db.type_aliases.len(), 1);
908 assert_eq!(db.type_aliases[0].name, "Row");
909 assert!(db.type_aliases[0].type_expr.contains("array"));
910 }
911
912 #[test]
913 fn to_markdown_shows_type_alias() {
914 let db = Docblock {
915 type_aliases: vec![DocTypeAlias {
916 name: "Status".to_string(),
917 type_expr: "string".to_string(),
918 }],
919 ..Default::default()
920 };
921 let md = db.to_markdown();
922 assert!(md.contains("Status"), "expected alias name in markdown");
923 assert!(md.contains("string"), "expected type expr in markdown");
924 }
925
926 #[test]
927 fn parses_property_tag() {
928 let src = "/** @property string $name */";
929 let db = parse_docblock(src);
930 assert_eq!(db.properties.len(), 1);
931 assert_eq!(db.properties[0].name, "name");
932 assert_eq!(db.properties[0].type_hint, "string");
933 assert!(!db.properties[0].read_only);
934 }
935
936 #[test]
937 fn parses_property_read_tag() {
938 let src = "/** @property-read Carbon $createdAt */";
939 let db = parse_docblock(src);
940 assert_eq!(db.properties[0].name, "createdAt");
941 assert!(db.properties[0].read_only);
942 }
943
944 #[test]
945 fn parses_method_tag() {
946 let src = "/** @method User find(int $id) */";
947 let db = parse_docblock(src);
948 assert_eq!(db.methods.len(), 1);
949 assert_eq!(db.methods[0].name, "find");
950 assert_eq!(db.methods[0].return_type, "User");
951 assert!(!db.methods[0].is_static);
952 }
953
954 #[test]
955 fn parses_static_method_tag() {
956 let src = "/** @method static Builder where(string $col, mixed $val) */";
957 let db = parse_docblock(src);
958 assert!(db.methods[0].is_static);
959 assert_eq!(db.methods[0].name, "where");
960 }
961
962 #[test]
963 fn psalm_param_alias_parsed_as_param() {
964 let raw = "/**\n * @psalm-param string $x The value\n */";
965 let db = parse_docblock(raw);
966 assert_eq!(db.params.len(), 1);
967 assert_eq!(db.params[0].type_hint, "string");
968 assert_eq!(db.params[0].name, "$x");
969 }
970
971 #[test]
972 fn phpstan_param_alias_parsed_as_param() {
973 let raw = "/**\n * @phpstan-param int $count\n */";
974 let db = parse_docblock(raw);
975 assert_eq!(db.params.len(), 1);
976 assert_eq!(db.params[0].type_hint, "int");
977 assert_eq!(db.params[0].name, "$count");
978 }
979
980 #[test]
981 fn psalm_return_alias_parsed_as_return() {
982 let raw = "/**\n * @psalm-return non-empty-string\n */";
983 let db = parse_docblock(raw);
984 assert_eq!(
985 db.return_type.as_ref().map(|r| r.type_hint.as_str()),
986 Some("non-empty-string")
987 );
988 }
989
990 #[test]
991 fn phpstan_return_alias_parsed_as_return() {
992 let raw = "/**\n * @phpstan-return array<int, string>\n */";
993 let db = parse_docblock(raw);
994 assert_eq!(
995 db.return_type.as_ref().map(|r| r.type_hint.as_str()),
996 Some("array<int, string>")
997 );
998 }
999
1000 #[test]
1001 fn psalm_var_alias_parsed_as_var() {
1002 let raw = "/** @psalm-var Foo $item */";
1003 let db = parse_docblock(raw);
1004 assert_eq!(db.var_type.as_deref(), Some("Foo"));
1005 assert_eq!(db.var_name.as_deref(), Some("item"));
1006 }
1007
1008 #[test]
1009 fn phpstan_var_alias_parsed_as_var() {
1010 let raw = "/** @phpstan-var string */";
1011 let db = parse_docblock(raw);
1012 assert_eq!(db.var_type.as_deref(), Some("string"));
1013 }
1014
1015 #[test]
1016 fn param_without_description_parses_correctly() {
1017 let raw = "/**\n * @param string $x\n */";
1018 let db = parse_docblock(raw);
1019 assert_eq!(db.params.len(), 1);
1020 assert_eq!(
1021 db.params[0].type_hint, "string",
1022 "type_hint should be 'string'"
1023 );
1024 assert_eq!(db.params[0].name, "$x", "name should be '$x'");
1025 assert_eq!(
1026 db.params[0].description, "",
1027 "description should be empty when absent"
1028 );
1029 }
1030
1031 #[test]
1032 fn union_type_param_parsed() {
1033 let raw = "/**\n * @param Foo|Bar $x Some value\n */";
1034 let db = parse_docblock(raw);
1035 assert_eq!(db.params.len(), 1);
1036 assert_eq!(
1037 db.params[0].type_hint, "Foo|Bar",
1038 "union type should be 'Foo|Bar', got: {}",
1039 db.params[0].type_hint
1040 );
1041 assert_eq!(db.params[0].name, "$x");
1042 }
1043
1044 #[test]
1045 fn nullable_type_param_parsed() {
1046 let raw = "/**\n * @param ?Foo $x\n */";
1048 let db = parse_docblock(raw);
1049 assert_eq!(db.params.len(), 1);
1050 assert_eq!(
1051 db.params[0].type_hint, "Foo|null",
1052 "nullable type should be 'Foo|null', got: {}",
1053 db.params[0].type_hint
1054 );
1055 assert_eq!(db.params[0].name, "$x");
1056 }
1057
1058 #[test]
1059 fn method_tag_extracts_return_type() {
1060 let raw = "/**\n * @method string getName()\n */";
1061 let db = parse_docblock(raw);
1062 assert_eq!(db.methods.len(), 1);
1063 assert_eq!(
1064 db.methods[0].return_type, "string",
1065 "return_type should be 'string', got: {}",
1066 db.methods[0].return_type
1067 );
1068 assert_eq!(
1069 db.methods[0].name, "getName",
1070 "name should be 'getName', got: {}",
1071 db.methods[0].name
1072 );
1073 assert!(!db.methods[0].is_static, "should not be static");
1074 }
1075
1076 #[test]
1077 fn advanced_type_non_empty_string() {
1078 let raw = "/**\n * @return non-empty-string\n */";
1080 let db = parse_docblock(raw);
1081 assert_eq!(
1082 db.return_type.as_ref().map(|r| r.type_hint.as_str()),
1083 Some("non-empty-string"),
1084 "non-empty-string should be preserved, got: {:?}",
1085 db.return_type
1086 );
1087 }
1088
1089 #[test]
1090 fn advanced_type_generic_array() {
1091 let raw = "/**\n * @param array<int, string> $map\n */";
1093 let db = parse_docblock(raw);
1094 assert_eq!(db.params.len(), 1);
1095 assert_eq!(
1096 db.params[0].type_hint, "array<int, string>",
1097 "generic array type should be preserved, got: {}",
1098 db.params[0].type_hint
1099 );
1100 }
1101
1102 #[test]
1103 fn param_and_return_descriptions_preserved() {
1104 let raw = "/**\n * @param string $name The user name\n * @return int The age\n */";
1107 let db = parse_docblock(raw);
1108 assert_eq!(
1109 db.params[0].description, "The user name",
1110 "param description should be preserved"
1111 );
1112 assert_eq!(
1113 db.return_type.as_ref().map(|r| r.description.as_str()),
1114 Some("The age"),
1115 "return description should be preserved"
1116 );
1117 }
1118
1119 #[test]
1120 fn throws_description_preserved() {
1121 let raw = "/**\n * @throws RuntimeException When the server is down\n */";
1123 let db = parse_docblock(raw);
1124 assert_eq!(db.throws.len(), 1);
1125 assert_eq!(db.throws[0].class, "RuntimeException");
1126 assert_eq!(
1127 db.throws[0].description, "When the server is down",
1128 "throws description should be preserved"
1129 );
1130 }
1131}