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