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