1use php_ast::{PhpDoc, PhpDocTag};
17
18pub fn parse<'src>(text: &'src str) -> PhpDoc<'src> {
23 let inner = strip_delimiters(text);
25
26 let lines = clean_lines(inner);
28
29 let (summary, description, tag_start) = extract_prose(&lines);
31
32 let tags = if tag_start < lines.len() {
34 parse_tags(&lines[tag_start..])
35 } else {
36 Vec::new()
37 };
38
39 PhpDoc {
40 summary,
41 description,
42 tags,
43 }
44}
45
46fn strip_delimiters(text: &str) -> &str {
48 let s = text.strip_prefix("/**").unwrap_or(text);
49 let s = s.strip_suffix("*/").unwrap_or(s);
50 s
51}
52
53struct CleanLine<'src> {
55 text: &'src str,
56}
57
58fn clean_lines(inner: &str) -> Vec<CleanLine<'_>> {
60 inner
61 .lines()
62 .map(|line| {
63 let trimmed = line.trim();
64 let cleaned = if let Some(rest) = trimmed.strip_prefix("* ") {
66 rest
67 } else if let Some(rest) = trimmed.strip_prefix('*') {
68 rest
69 } else {
70 trimmed
71 };
72 CleanLine { text: cleaned }
73 })
74 .collect()
75}
76
77fn extract_prose<'src>(lines: &[CleanLine<'src>]) -> (Option<&'src str>, Option<&'src str>, usize) {
80 let tag_start = lines
82 .iter()
83 .position(|l| l.text.starts_with('@'))
84 .unwrap_or(lines.len());
85
86 let prose_lines = &lines[..tag_start];
87
88 let first_non_empty = prose_lines.iter().position(|l| !l.text.is_empty());
90 let Some(start) = first_non_empty else {
91 return (None, None, tag_start);
92 };
93
94 let blank_after_summary = prose_lines[start..]
96 .iter()
97 .position(|l| l.text.is_empty())
98 .map(|i| i + start);
99
100 let summary_text = prose_lines[start].text;
101 let summary = if summary_text.is_empty() {
102 None
103 } else {
104 Some(summary_text)
105 };
106
107 let description = if let Some(blank) = blank_after_summary {
109 let desc_start = prose_lines[blank..]
110 .iter()
111 .position(|l| !l.text.is_empty())
112 .map(|i| i + blank);
113 if let Some(ds) = desc_start {
114 let desc_end = prose_lines
116 .iter()
117 .rposition(|l| !l.text.is_empty())
118 .map(|i| i + 1)
119 .unwrap_or(ds);
120 if ds < desc_end {
121 Some(prose_lines[ds].text)
125 } else {
126 None
127 }
128 } else {
129 None
130 }
131 } else {
132 None
133 };
134
135 (summary, description, tag_start)
136}
137
138fn parse_tags<'src>(lines: &[CleanLine<'src>]) -> Vec<PhpDocTag<'src>> {
141 let mut tags = Vec::new();
142 let mut i = 0;
143
144 while i < lines.len() {
145 let line = lines[i].text;
146 if !line.starts_with('@') {
147 i += 1;
148 continue;
149 }
150
151 if let Some(tag) = parse_single_tag(line) {
154 tags.push(tag);
155 }
156 i += 1;
157 }
158
159 tags
160}
161
162fn parse_single_tag<'src>(line: &'src str) -> Option<PhpDocTag<'src>> {
164 let line = line.strip_prefix('@')?;
165
166 let (tag_name, body) = match line.find(|c: char| c.is_whitespace()) {
168 Some(pos) => {
169 let body = line[pos..].trim();
170 let body = if body.is_empty() { None } else { Some(body) };
171 (&line[..pos], body)
172 }
173 None => (line, None),
174 };
175
176 let tag_lower = tag_name.to_ascii_lowercase();
177
178 let effective = tag_lower
180 .strip_prefix("psalm-")
181 .or_else(|| tag_lower.strip_prefix("phpstan-"));
182
183 match tag_lower.as_str() {
185 "psalm-assert"
187 | "phpstan-assert"
188 | "psalm-assert-if-true"
189 | "phpstan-assert-if-true"
190 | "psalm-assert-if-false"
191 | "phpstan-assert-if-false" => Some(parse_assert_tag(body)),
192 "psalm-type" | "phpstan-type" => Some(parse_type_alias_tag(body)),
193 "psalm-import-type" | "phpstan-import-type" => Some(PhpDocTag::ImportType {
194 body: body.unwrap_or(""),
195 }),
196 "psalm-suppress" => Some(PhpDocTag::Suppress {
197 rules: body.unwrap_or(""),
198 }),
199 "phpstan-ignore-next-line" | "phpstan-ignore" => Some(PhpDocTag::Suppress {
200 rules: body.unwrap_or(""),
201 }),
202 "psalm-pure" | "pure" => Some(PhpDocTag::Pure),
203 "psalm-readonly" | "readonly" => Some(PhpDocTag::Readonly),
204 "psalm-immutable" | "immutable" => Some(PhpDocTag::Immutable),
205 "mixin" => Some(PhpDocTag::Mixin {
206 class: body.unwrap_or(""),
207 }),
208 "template-covariant" => {
209 let tag = parse_template_tag(body);
210 match tag {
211 PhpDocTag::Template { name, bound } => {
212 Some(PhpDocTag::TemplateCovariant { name, bound })
213 }
214 _ => Some(tag),
215 }
216 }
217 "template-contravariant" => {
218 let tag = parse_template_tag(body);
219 match tag {
220 PhpDocTag::Template { name, bound } => {
221 Some(PhpDocTag::TemplateContravariant { name, bound })
222 }
223 _ => Some(tag),
224 }
225 }
226 _ => match effective.unwrap_or(tag_lower.as_str()) {
228 "param" => Some(parse_param_tag(body)),
229 "return" | "returns" => Some(parse_return_tag(body)),
230 "var" => Some(parse_var_tag(body)),
231 "throws" | "throw" => Some(parse_throws_tag(body)),
232 "deprecated" => Some(PhpDocTag::Deprecated { description: body }),
233 "template" => Some(parse_template_tag(body)),
234 "extends" => Some(PhpDocTag::Extends {
235 type_str: body.unwrap_or(""),
236 }),
237 "implements" => Some(PhpDocTag::Implements {
238 type_str: body.unwrap_or(""),
239 }),
240 "method" => Some(PhpDocTag::Method {
241 signature: body.unwrap_or(""),
242 }),
243 "property" => Some(parse_property_tag(body, PropertyKind::ReadWrite)),
244 "property-read" => Some(parse_property_tag(body, PropertyKind::Read)),
245 "property-write" => Some(parse_property_tag(body, PropertyKind::Write)),
246 "see" => Some(PhpDocTag::See {
247 reference: body.unwrap_or(""),
248 }),
249 "link" => Some(PhpDocTag::Link {
250 url: body.unwrap_or(""),
251 }),
252 "since" => Some(PhpDocTag::Since {
253 version: body.unwrap_or(""),
254 }),
255 "author" => Some(PhpDocTag::Author {
256 name: body.unwrap_or(""),
257 }),
258 "internal" => Some(PhpDocTag::Internal),
259 "inheritdoc" => Some(PhpDocTag::InheritDoc),
260 _ => Some(PhpDocTag::Generic {
261 tag: tag_name,
262 body,
263 }),
264 },
265 }
266}
267
268fn parse_param_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
274 let Some(body) = body else {
275 return PhpDocTag::Param {
276 type_str: None,
277 name: None,
278 description: None,
279 };
280 };
281
282 if body.starts_with('$') {
284 let (name, desc) = split_first_word(body);
285 return PhpDocTag::Param {
286 type_str: None,
287 name: Some(name),
288 description: desc,
289 };
290 }
291
292 let (type_str, rest) = split_type(body);
294 let rest = rest.map(|r| r.trim_start());
295
296 match rest {
297 Some(r) if r.starts_with('$') => {
298 let (name, desc) = split_first_word(r);
299 PhpDocTag::Param {
300 type_str: Some(type_str),
301 name: Some(name),
302 description: desc,
303 }
304 }
305 _ => PhpDocTag::Param {
306 type_str: Some(type_str),
307 name: None,
308 description: rest,
309 },
310 }
311}
312
313fn parse_return_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
315 let Some(body) = body else {
316 return PhpDocTag::Return {
317 type_str: None,
318 description: None,
319 };
320 };
321
322 let (type_str, desc) = split_type(body);
323 PhpDocTag::Return {
324 type_str: Some(type_str),
325 description: desc.map(|d| d.trim_start()),
326 }
327}
328
329fn parse_var_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
331 let Some(body) = body else {
332 return PhpDocTag::Var {
333 type_str: None,
334 name: None,
335 description: None,
336 };
337 };
338
339 if body.starts_with('$') {
340 let (name, desc) = split_first_word(body);
341 return PhpDocTag::Var {
342 type_str: None,
343 name: Some(name),
344 description: desc,
345 };
346 }
347
348 let (type_str, rest) = split_type(body);
349 let rest = rest.map(|r| r.trim_start());
350
351 match rest {
352 Some(r) if r.starts_with('$') => {
353 let (name, desc) = split_first_word(r);
354 PhpDocTag::Var {
355 type_str: Some(type_str),
356 name: Some(name),
357 description: desc,
358 }
359 }
360 _ => PhpDocTag::Var {
361 type_str: Some(type_str),
362 name: None,
363 description: rest,
364 },
365 }
366}
367
368fn parse_throws_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
370 let Some(body) = body else {
371 return PhpDocTag::Throws {
372 type_str: None,
373 description: None,
374 };
375 };
376
377 let (type_str, desc) = split_type(body);
378 PhpDocTag::Throws {
379 type_str: Some(type_str),
380 description: desc.map(|d| d.trim_start()),
381 }
382}
383
384fn parse_template_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
386 let Some(body) = body else {
387 return PhpDocTag::Template {
388 name: "",
389 bound: None,
390 };
391 };
392
393 let (name, rest) = split_first_word(body);
394 let bound = rest.and_then(|r| {
395 let r = r.trim_start();
396 r.strip_prefix("of ")
398 .or_else(|| r.strip_prefix("as "))
399 .map(|b| b.trim())
400 .or(Some(r))
401 });
402
403 PhpDocTag::Template {
404 name,
405 bound: bound.filter(|b| !b.is_empty()),
406 }
407}
408
409fn parse_assert_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
411 let Some(body) = body else {
412 return PhpDocTag::Assert {
413 type_str: None,
414 name: None,
415 };
416 };
417
418 if body.starts_with('$') {
419 return PhpDocTag::Assert {
420 type_str: None,
421 name: Some(body.split_whitespace().next().unwrap_or(body)),
422 };
423 }
424
425 let (type_str, rest) = split_type(body);
426 let name = rest.and_then(|r| {
427 let r = r.trim_start();
428 if r.starts_with('$') {
429 Some(r.split_whitespace().next().unwrap_or(r))
430 } else {
431 None
432 }
433 });
434
435 PhpDocTag::Assert {
436 type_str: Some(type_str),
437 name,
438 }
439}
440
441fn parse_type_alias_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
443 let Some(body) = body else {
444 return PhpDocTag::TypeAlias {
445 name: None,
446 type_str: None,
447 };
448 };
449
450 let (name, rest) = split_first_word(body);
451 let type_str = rest.and_then(|r| {
452 let r = r.trim_start();
453 let r = r.strip_prefix('=').unwrap_or(r).trim_start();
455 if r.is_empty() {
456 None
457 } else {
458 Some(r)
459 }
460 });
461
462 PhpDocTag::TypeAlias {
463 name: Some(name),
464 type_str,
465 }
466}
467
468enum PropertyKind {
469 ReadWrite,
470 Read,
471 Write,
472}
473
474fn parse_property_tag<'src>(body: Option<&'src str>, kind: PropertyKind) -> PhpDocTag<'src> {
476 let (type_str, name, description) = parse_type_name_desc(body);
477
478 match kind {
479 PropertyKind::ReadWrite => PhpDocTag::Property {
480 type_str,
481 name,
482 description,
483 },
484 PropertyKind::Read => PhpDocTag::PropertyRead {
485 type_str,
486 name,
487 description,
488 },
489 PropertyKind::Write => PhpDocTag::PropertyWrite {
490 type_str,
491 name,
492 description,
493 },
494 }
495}
496
497fn parse_type_name_desc(body: Option<&str>) -> (Option<&str>, Option<&str>, Option<&str>) {
499 let Some(body) = body else {
500 return (None, None, None);
501 };
502
503 if body.starts_with('$') {
504 let (name, desc) = split_first_word(body);
505 return (None, Some(name), desc);
506 }
507
508 let (type_str, rest) = split_type(body);
509 let rest = rest.map(|r| r.trim_start());
510
511 match rest {
512 Some(r) if r.starts_with('$') => {
513 let (name, desc) = split_first_word(r);
514 (Some(type_str), Some(name), desc)
515 }
516 _ => (Some(type_str), None, rest),
517 }
518}
519
520fn split_first_word(s: &str) -> (&str, Option<&str>) {
526 match s.find(|c: char| c.is_whitespace()) {
527 Some(pos) => {
528 let rest = s[pos..].trim_start();
529 let rest = if rest.is_empty() { None } else { Some(rest) };
530 (&s[..pos], rest)
531 }
532 None => (s, None),
533 }
534}
535
536fn split_type(s: &str) -> (&str, Option<&str>) {
541 let bytes = s.as_bytes();
542 let mut depth = 0i32;
543 let mut i = 0;
544
545 while i < bytes.len() {
546 match bytes[i] {
547 b'<' | b'(' | b'{' => depth += 1,
548 b'>' | b')' | b'}' => {
549 depth -= 1;
550 if depth < 0 {
551 depth = 0;
552 }
553 }
554 b' ' | b'\t' if depth == 0 => {
555 if i > 0 && bytes[i - 1] == b':' {
558 i += 1;
560 continue;
561 }
562 let rest = s[i..].trim_start();
563 let rest = if rest.is_empty() { None } else { Some(rest) };
564 return (&s[..i], rest);
565 }
566 _ => {}
567 }
568 i += 1;
569 }
570
571 (s, None)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn simple_param() {
580 let doc = parse("/** @param int $x The value */");
581 assert_eq!(doc.tags.len(), 1);
582 match &doc.tags[0] {
583 PhpDocTag::Param {
584 type_str,
585 name,
586 description,
587 } => {
588 assert_eq!(*type_str, Some("int"));
589 assert_eq!(*name, Some("$x"));
590 assert_eq!(*description, Some("The value"));
591 }
592 _ => panic!("expected Param tag"),
593 }
594 }
595
596 #[test]
597 fn summary_and_tags() {
598 let doc = parse(
599 "/**
600 * Short summary here.
601 *
602 * Longer description.
603 *
604 * @param string $name The name
605 * @return bool
606 */",
607 );
608 assert_eq!(doc.summary, Some("Short summary here."));
609 assert_eq!(doc.description, Some("Longer description."));
610 assert_eq!(doc.tags.len(), 2);
611 }
612
613 #[test]
614 fn generic_type() {
615 let doc = parse("/** @param array<string, int> $map */");
616 match &doc.tags[0] {
617 PhpDocTag::Param { type_str, name, .. } => {
618 assert_eq!(*type_str, Some("array<string, int>"));
619 assert_eq!(*name, Some("$map"));
620 }
621 _ => panic!("expected Param tag"),
622 }
623 }
624
625 #[test]
626 fn union_type() {
627 let doc = parse("/** @return string|null */");
628 match &doc.tags[0] {
629 PhpDocTag::Return { type_str, .. } => {
630 assert_eq!(*type_str, Some("string|null"));
631 }
632 _ => panic!("expected Return tag"),
633 }
634 }
635
636 #[test]
637 fn template_tag() {
638 let doc = parse("/** @template T of \\Countable */");
639 match &doc.tags[0] {
640 PhpDocTag::Template { name, bound } => {
641 assert_eq!(*name, "T");
642 assert_eq!(*bound, Some("\\Countable"));
643 }
644 _ => panic!("expected Template tag"),
645 }
646 }
647
648 #[test]
649 fn deprecated_tag() {
650 let doc = parse("/** @deprecated Use newMethod() instead */");
651 match &doc.tags[0] {
652 PhpDocTag::Deprecated { description } => {
653 assert_eq!(*description, Some("Use newMethod() instead"));
654 }
655 _ => panic!("expected Deprecated tag"),
656 }
657 }
658
659 #[test]
660 fn inheritdoc() {
661 let doc = parse("/** @inheritdoc */");
662 assert!(matches!(doc.tags[0], PhpDocTag::InheritDoc));
663 }
664
665 #[test]
666 fn unknown_tag() {
667 let doc = parse("/** @custom-tag some body */");
668 match &doc.tags[0] {
669 PhpDocTag::Generic { tag, body } => {
670 assert_eq!(*tag, "custom-tag");
671 assert_eq!(*body, Some("some body"));
672 }
673 _ => panic!("expected Generic tag"),
674 }
675 }
676
677 #[test]
678 fn multiple_params() {
679 let doc = parse(
680 "/**
681 * @param int $a First
682 * @param string $b Second
683 * @param bool $c
684 */",
685 );
686 assert_eq!(doc.tags.len(), 3);
687 assert!(matches!(
688 &doc.tags[0],
689 PhpDocTag::Param {
690 name: Some("$a"),
691 ..
692 }
693 ));
694 assert!(matches!(
695 &doc.tags[1],
696 PhpDocTag::Param {
697 name: Some("$b"),
698 ..
699 }
700 ));
701 assert!(matches!(
702 &doc.tags[2],
703 PhpDocTag::Param {
704 name: Some("$c"),
705 ..
706 }
707 ));
708 }
709
710 #[test]
711 fn var_tag() {
712 let doc = parse("/** @var int $count */");
713 match &doc.tags[0] {
714 PhpDocTag::Var { type_str, name, .. } => {
715 assert_eq!(*type_str, Some("int"));
716 assert_eq!(*name, Some("$count"));
717 }
718 _ => panic!("expected Var tag"),
719 }
720 }
721
722 #[test]
723 fn throws_tag() {
724 let doc = parse("/** @throws \\RuntimeException When things go wrong */");
725 match &doc.tags[0] {
726 PhpDocTag::Throws {
727 type_str,
728 description,
729 } => {
730 assert_eq!(*type_str, Some("\\RuntimeException"));
731 assert_eq!(*description, Some("When things go wrong"));
732 }
733 _ => panic!("expected Throws tag"),
734 }
735 }
736
737 #[test]
738 fn property_tags() {
739 let doc = parse(
740 "/**
741 * @property string $name
742 * @property-read int $id
743 * @property-write bool $active
744 */",
745 );
746 assert_eq!(doc.tags.len(), 3);
747 assert!(matches!(
748 &doc.tags[0],
749 PhpDocTag::Property {
750 name: Some("$name"),
751 ..
752 }
753 ));
754 assert!(matches!(
755 &doc.tags[1],
756 PhpDocTag::PropertyRead {
757 name: Some("$id"),
758 ..
759 }
760 ));
761 assert!(matches!(
762 &doc.tags[2],
763 PhpDocTag::PropertyWrite {
764 name: Some("$active"),
765 ..
766 }
767 ));
768 }
769
770 #[test]
771 fn empty_doc_block() {
772 let doc = parse("/** */");
773 assert_eq!(doc.summary, None);
774 assert_eq!(doc.description, None);
775 assert!(doc.tags.is_empty());
776 }
777
778 #[test]
779 fn summary_only() {
780 let doc = parse("/** Does something cool. */");
781 assert_eq!(doc.summary, Some("Does something cool."));
782 assert_eq!(doc.description, None);
783 assert!(doc.tags.is_empty());
784 }
785
786 #[test]
787 fn callable_type() {
788 let doc = parse("/** @param callable(int, string): bool $fn */");
789 match &doc.tags[0] {
790 PhpDocTag::Param { type_str, name, .. } => {
791 assert_eq!(*type_str, Some("callable(int, string): bool"));
792 assert!(name.is_some());
797 }
798 _ => panic!("expected Param tag"),
799 }
800 }
801
802 #[test]
803 fn complex_generic_type() {
804 let doc = parse("/** @return array<int, list<string>> */");
805 match &doc.tags[0] {
806 PhpDocTag::Return { type_str, .. } => {
807 assert_eq!(*type_str, Some("array<int, list<string>>"));
808 }
809 _ => panic!("expected Return tag"),
810 }
811 }
812
813 #[test]
818 fn psalm_param() {
819 let doc = parse("/** @psalm-param array<string, int> $map */");
820 match &doc.tags[0] {
821 PhpDocTag::Param { type_str, name, .. } => {
822 assert_eq!(*type_str, Some("array<string, int>"));
823 assert_eq!(*name, Some("$map"));
824 }
825 _ => panic!("expected Param tag, got {:?}", doc.tags[0]),
826 }
827 }
828
829 #[test]
830 fn phpstan_return() {
831 let doc = parse("/** @phpstan-return list<non-empty-string> */");
832 match &doc.tags[0] {
833 PhpDocTag::Return { type_str, .. } => {
834 assert_eq!(*type_str, Some("list<non-empty-string>"));
835 }
836 _ => panic!("expected Return tag, got {:?}", doc.tags[0]),
837 }
838 }
839
840 #[test]
841 fn psalm_assert() {
842 let doc = parse("/** @psalm-assert int $x */");
843 match &doc.tags[0] {
844 PhpDocTag::Assert { type_str, name } => {
845 assert_eq!(*type_str, Some("int"));
846 assert_eq!(*name, Some("$x"));
847 }
848 _ => panic!("expected Assert tag, got {:?}", doc.tags[0]),
849 }
850 }
851
852 #[test]
853 fn phpstan_assert() {
854 let doc = parse("/** @phpstan-assert non-empty-string $value */");
855 match &doc.tags[0] {
856 PhpDocTag::Assert { type_str, name } => {
857 assert_eq!(*type_str, Some("non-empty-string"));
858 assert_eq!(*name, Some("$value"));
859 }
860 _ => panic!("expected Assert tag, got {:?}", doc.tags[0]),
861 }
862 }
863
864 #[test]
865 fn psalm_type_alias() {
866 let doc = parse("/** @psalm-type UserId = positive-int */");
867 match &doc.tags[0] {
868 PhpDocTag::TypeAlias { name, type_str } => {
869 assert_eq!(*name, Some("UserId"));
870 assert_eq!(*type_str, Some("positive-int"));
871 }
872 _ => panic!("expected TypeAlias tag, got {:?}", doc.tags[0]),
873 }
874 }
875
876 #[test]
877 fn phpstan_type_alias() {
878 let doc = parse("/** @phpstan-type Callback = callable(int): void */");
879 match &doc.tags[0] {
880 PhpDocTag::TypeAlias { name, type_str } => {
881 assert_eq!(*name, Some("Callback"));
882 assert_eq!(*type_str, Some("callable(int): void"));
883 }
884 _ => panic!("expected TypeAlias tag, got {:?}", doc.tags[0]),
885 }
886 }
887
888 #[test]
889 fn psalm_suppress() {
890 let doc = parse("/** @psalm-suppress InvalidReturnType */");
891 match &doc.tags[0] {
892 PhpDocTag::Suppress { rules } => {
893 assert_eq!(*rules, "InvalidReturnType");
894 }
895 _ => panic!("expected Suppress tag, got {:?}", doc.tags[0]),
896 }
897 }
898
899 #[test]
900 fn phpstan_ignore() {
901 let doc = parse("/** @phpstan-ignore-next-line */");
902 assert!(matches!(&doc.tags[0], PhpDocTag::Suppress { .. }));
903 }
904
905 #[test]
906 fn psalm_pure() {
907 let doc = parse("/** @psalm-pure */");
908 assert!(matches!(&doc.tags[0], PhpDocTag::Pure));
909 }
910
911 #[test]
912 fn psalm_immutable() {
913 let doc = parse("/** @psalm-immutable */");
914 assert!(matches!(&doc.tags[0], PhpDocTag::Immutable));
915 }
916
917 #[test]
918 fn mixin_tag() {
919 let doc = parse("/** @mixin \\App\\Helpers\\Foo */");
920 match &doc.tags[0] {
921 PhpDocTag::Mixin { class } => {
922 assert_eq!(*class, "\\App\\Helpers\\Foo");
923 }
924 _ => panic!("expected Mixin tag, got {:?}", doc.tags[0]),
925 }
926 }
927
928 #[test]
929 fn template_covariant() {
930 let doc = parse("/** @template-covariant T of object */");
931 match &doc.tags[0] {
932 PhpDocTag::TemplateCovariant { name, bound } => {
933 assert_eq!(*name, "T");
934 assert_eq!(*bound, Some("object"));
935 }
936 _ => panic!("expected TemplateCovariant tag, got {:?}", doc.tags[0]),
937 }
938 }
939
940 #[test]
941 fn template_contravariant() {
942 let doc = parse("/** @template-contravariant T */");
943 match &doc.tags[0] {
944 PhpDocTag::TemplateContravariant { name, bound } => {
945 assert_eq!(*name, "T");
946 assert_eq!(*bound, None);
947 }
948 _ => panic!("expected TemplateContravariant tag, got {:?}", doc.tags[0]),
949 }
950 }
951
952 #[test]
953 fn psalm_import_type() {
954 let doc = parse("/** @psalm-import-type UserId from UserRepository */");
955 match &doc.tags[0] {
956 PhpDocTag::ImportType { body } => {
957 assert_eq!(*body, "UserId from UserRepository");
958 }
959 _ => panic!("expected ImportType tag, got {:?}", doc.tags[0]),
960 }
961 }
962
963 #[test]
964 fn phpstan_var() {
965 let doc = parse("/** @phpstan-var positive-int $count */");
966 match &doc.tags[0] {
967 PhpDocTag::Var { type_str, name, .. } => {
968 assert_eq!(*type_str, Some("positive-int"));
969 assert_eq!(*name, Some("$count"));
970 }
971 _ => panic!("expected Var tag, got {:?}", doc.tags[0]),
972 }
973 }
974
975 #[test]
976 fn mixed_standard_and_psalm_tags() {
977 let doc = parse(
978 "/**
979 * Create a user.
980 *
981 * @param string $name
982 * @psalm-param non-empty-string $name
983 * @return User
984 * @psalm-assert-if-true User $result
985 * @throws \\InvalidArgumentException
986 */",
987 );
988 assert_eq!(doc.summary, Some("Create a user."));
989 assert_eq!(doc.tags.len(), 5);
990 assert!(matches!(&doc.tags[0], PhpDocTag::Param { .. }));
991 assert!(matches!(&doc.tags[1], PhpDocTag::Param { .. }));
992 assert!(matches!(&doc.tags[2], PhpDocTag::Return { .. }));
993 assert!(matches!(&doc.tags[3], PhpDocTag::Assert { .. }));
994 assert!(matches!(&doc.tags[4], PhpDocTag::Throws { .. }));
995 }
996}