1use std::borrow::Cow;
15
16#[derive(Debug)]
18pub struct PhpDoc<'src> {
19 pub summary: Option<&'src str>,
21 pub description: Option<&'src str>,
23 pub tags: Vec<PhpDocTag<'src>>,
25}
26
27#[derive(Debug)]
29pub enum PhpDocTag<'src> {
30 Param {
32 type_str: Option<&'src str>,
33 name: Option<&'src str>,
34 description: Option<Cow<'src, str>>,
35 },
36 Return {
38 type_str: Option<&'src str>,
39 description: Option<Cow<'src, str>>,
40 },
41 Var {
43 type_str: Option<&'src str>,
44 name: Option<&'src str>,
45 description: Option<Cow<'src, str>>,
46 },
47 Throws {
49 type_str: Option<&'src str>,
50 description: Option<Cow<'src, str>>,
51 },
52 Deprecated { description: Option<Cow<'src, str>> },
54 Template {
56 name: &'src str,
57 bound: Option<&'src str>,
58 },
59 Extends { type_str: &'src str },
61 Implements { type_str: &'src str },
63 Method { signature: &'src str },
65 Property {
67 type_str: Option<&'src str>,
68 name: Option<&'src str>,
69 description: Option<Cow<'src, str>>,
70 },
71 PropertyRead {
73 type_str: Option<&'src str>,
74 name: Option<&'src str>,
75 description: Option<Cow<'src, str>>,
76 },
77 PropertyWrite {
79 type_str: Option<&'src str>,
80 name: Option<&'src str>,
81 description: Option<Cow<'src, str>>,
82 },
83 See { reference: &'src str },
85 Link { url: &'src str },
87 Since { version: &'src str },
89 Author { name: &'src str },
91 Internal,
93 InheritDoc,
95 Assert {
97 type_str: Option<&'src str>,
98 name: Option<&'src str>,
99 },
100 TypeAlias {
102 name: Option<&'src str>,
103 type_str: Option<&'src str>,
104 },
105 ImportType { body: &'src str },
107 Suppress { rules: &'src str },
109 Pure,
111 Readonly,
113 Immutable,
115 Mixin { class: &'src str },
117 TemplateCovariant {
119 name: &'src str,
120 bound: Option<&'src str>,
121 },
122 TemplateContravariant {
124 name: &'src str,
125 bound: Option<&'src str>,
126 },
127 Generic {
129 tag: &'src str,
130 body: Option<Cow<'src, str>>,
131 },
132}
133
134pub fn parse<'src>(text: &'src str) -> PhpDoc<'src> {
139 let inner = strip_delimiters(text);
141
142 let lines = clean_lines(inner);
144
145 let (summary, description, tag_start) = extract_prose(&lines);
147
148 let tags = if tag_start < lines.len() {
150 parse_tags(&lines[tag_start..])
151 } else {
152 Vec::new()
153 };
154
155 PhpDoc {
156 summary,
157 description,
158 tags,
159 }
160}
161
162fn strip_delimiters(text: &str) -> &str {
164 let s = text.strip_prefix("/**").unwrap_or(text);
165 let s = s.strip_suffix("*/").unwrap_or(s);
166 s
167}
168
169struct CleanLine<'src> {
171 text: &'src str,
172}
173
174fn clean_lines(inner: &str) -> Vec<CleanLine<'_>> {
176 inner
177 .lines()
178 .map(|line| {
179 let trimmed = line.trim();
180 let cleaned = if let Some(rest) = trimmed.strip_prefix("* ") {
182 rest
183 } else if let Some(rest) = trimmed.strip_prefix('*') {
184 rest
185 } else {
186 trimmed
187 };
188 CleanLine { text: cleaned }
189 })
190 .collect()
191}
192
193fn extract_prose<'src>(lines: &[CleanLine<'src>]) -> (Option<&'src str>, Option<&'src str>, usize) {
196 let tag_start = lines
198 .iter()
199 .position(|l| l.text.starts_with('@'))
200 .unwrap_or(lines.len());
201
202 let prose_lines = &lines[..tag_start];
203
204 let first_non_empty = prose_lines.iter().position(|l| !l.text.is_empty());
206 let Some(start) = first_non_empty else {
207 return (None, None, tag_start);
208 };
209
210 let blank_after_summary = prose_lines[start..]
212 .iter()
213 .position(|l| l.text.is_empty())
214 .map(|i| i + start);
215
216 let summary_text = prose_lines[start].text;
217 let summary = if summary_text.is_empty() {
218 None
219 } else {
220 Some(summary_text)
221 };
222
223 let description = if let Some(blank) = blank_after_summary {
225 let desc_start = prose_lines[blank..]
226 .iter()
227 .position(|l| !l.text.is_empty())
228 .map(|i| i + blank);
229 if let Some(ds) = desc_start {
230 let desc_end = prose_lines
232 .iter()
233 .rposition(|l| !l.text.is_empty())
234 .map(|i| i + 1)
235 .unwrap_or(ds);
236 if ds < desc_end {
237 Some(prose_lines[ds].text)
241 } else {
242 None
243 }
244 } else {
245 None
246 }
247 } else {
248 None
249 };
250
251 (summary, description, tag_start)
252}
253
254fn parse_tags<'src>(lines: &[CleanLine<'src>]) -> Vec<PhpDocTag<'src>> {
258 let mut tags = Vec::new();
259 let mut i = 0;
260
261 while i < lines.len() {
262 let line = lines[i].text;
263 if !line.starts_with('@') {
264 i += 1;
265 continue;
266 }
267
268 if let Some(mut tag) = parse_single_tag(line) {
269 i += 1;
270 while i < lines.len() && !lines[i].text.starts_with('@') {
272 let cont = lines[i].text.trim();
273 if !cont.is_empty() {
274 append_to_description(&mut tag, cont);
275 }
276 i += 1;
277 }
278 tags.push(tag);
279 } else {
280 i += 1;
281 }
282 }
283
284 tags
285}
286
287fn append_to_description<'src>(tag: &mut PhpDocTag<'src>, cont: &str) {
289 fn append(field: &mut Option<Cow<'_, str>>, cont: &str) {
290 match field {
291 None => *field = Some(Cow::Owned(cont.to_owned())),
292 Some(Cow::Borrowed(s)) => {
293 let mut owned = String::with_capacity(s.len() + 1 + cont.len());
294 owned.push_str(s);
295 owned.push(' ');
296 owned.push_str(cont);
297 *field = Some(Cow::Owned(owned));
298 }
299 Some(Cow::Owned(s)) => {
300 s.push(' ');
301 s.push_str(cont);
302 }
303 }
304 }
305
306 match tag {
307 PhpDocTag::Param { description, .. } => append(description, cont),
308 PhpDocTag::Return { description, .. } => append(description, cont),
309 PhpDocTag::Var { description, .. } => append(description, cont),
310 PhpDocTag::Throws { description, .. } => append(description, cont),
311 PhpDocTag::Deprecated { description } => append(description, cont),
312 PhpDocTag::Property { description, .. } => append(description, cont),
313 PhpDocTag::PropertyRead { description, .. } => append(description, cont),
314 PhpDocTag::PropertyWrite { description, .. } => append(description, cont),
315 PhpDocTag::Generic { body, .. } => append(body, cont),
316 _ => {}
318 }
319}
320
321fn parse_single_tag<'src>(line: &'src str) -> Option<PhpDocTag<'src>> {
323 let line = line.strip_prefix('@')?;
324
325 let (tag_name, body) = match line.find(|c: char| c.is_whitespace()) {
327 Some(pos) => {
328 let body = line[pos..].trim();
329 let body = if body.is_empty() { None } else { Some(body) };
330 (&line[..pos], body)
331 }
332 None => (line, None),
333 };
334
335 let tag_lower = tag_name.to_ascii_lowercase();
336
337 let effective = tag_lower
339 .strip_prefix("psalm-")
340 .or_else(|| tag_lower.strip_prefix("phpstan-"));
341
342 match tag_lower.as_str() {
344 "psalm-assert"
346 | "phpstan-assert"
347 | "psalm-assert-if-true"
348 | "phpstan-assert-if-true"
349 | "psalm-assert-if-false"
350 | "phpstan-assert-if-false" => Some(parse_assert_tag(body)),
351 "psalm-type" | "phpstan-type" => Some(parse_type_alias_tag(body)),
352 "psalm-import-type" | "phpstan-import-type" => Some(PhpDocTag::ImportType {
353 body: body.unwrap_or(""),
354 }),
355 "psalm-suppress" => Some(PhpDocTag::Suppress {
356 rules: body.unwrap_or(""),
357 }),
358 "phpstan-ignore-next-line" | "phpstan-ignore" => Some(PhpDocTag::Suppress {
359 rules: body.unwrap_or(""),
360 }),
361 "psalm-pure" | "pure" => Some(PhpDocTag::Pure),
362 "psalm-readonly" | "readonly" => Some(PhpDocTag::Readonly),
363 "psalm-immutable" | "immutable" => Some(PhpDocTag::Immutable),
364 "mixin" => Some(PhpDocTag::Mixin {
365 class: body.unwrap_or(""),
366 }),
367 "template-covariant" => {
368 let tag = parse_template_tag(body);
369 match tag {
370 PhpDocTag::Template { name, bound } => {
371 Some(PhpDocTag::TemplateCovariant { name, bound })
372 }
373 _ => Some(tag),
374 }
375 }
376 "template-contravariant" => {
377 let tag = parse_template_tag(body);
378 match tag {
379 PhpDocTag::Template { name, bound } => {
380 Some(PhpDocTag::TemplateContravariant { name, bound })
381 }
382 _ => Some(tag),
383 }
384 }
385 _ => match effective.unwrap_or(tag_lower.as_str()) {
387 "param" => Some(parse_param_tag(body)),
388 "return" | "returns" => Some(parse_return_tag(body)),
389 "var" => Some(parse_var_tag(body)),
390 "throws" | "throw" => Some(parse_throws_tag(body)),
391 "deprecated" => Some(PhpDocTag::Deprecated {
392 description: body.map(Cow::Borrowed),
393 }),
394 "template" => Some(parse_template_tag(body)),
395 "extends" => Some(PhpDocTag::Extends {
396 type_str: body.unwrap_or(""),
397 }),
398 "implements" => Some(PhpDocTag::Implements {
399 type_str: body.unwrap_or(""),
400 }),
401 "method" => Some(PhpDocTag::Method {
402 signature: body.unwrap_or(""),
403 }),
404 "property" => Some(parse_property_tag(body, PropertyKind::ReadWrite)),
405 "property-read" => Some(parse_property_tag(body, PropertyKind::Read)),
406 "property-write" => Some(parse_property_tag(body, PropertyKind::Write)),
407 "see" => Some(PhpDocTag::See {
408 reference: body.unwrap_or(""),
409 }),
410 "link" => Some(PhpDocTag::Link {
411 url: body.unwrap_or(""),
412 }),
413 "since" => Some(PhpDocTag::Since {
414 version: body.unwrap_or(""),
415 }),
416 "author" => Some(PhpDocTag::Author {
417 name: body.unwrap_or(""),
418 }),
419 "internal" => Some(PhpDocTag::Internal),
420 "inheritdoc" => Some(PhpDocTag::InheritDoc),
421 _ => Some(PhpDocTag::Generic {
422 tag: tag_name,
423 body: body.map(Cow::Borrowed),
424 }),
425 },
426 }
427}
428
429fn parse_param_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
435 let Some(body) = body else {
436 return PhpDocTag::Param {
437 type_str: None,
438 name: None,
439 description: None,
440 };
441 };
442
443 if body.starts_with('$') {
445 let (name, desc) = split_first_word(body);
446 return PhpDocTag::Param {
447 type_str: None,
448 name: Some(name),
449 description: desc.map(Cow::Borrowed),
450 };
451 }
452
453 let (type_str, rest) = split_type(body);
455 let rest = rest.map(|r| r.trim_start());
456
457 match rest {
458 Some(r) if r.starts_with('$') => {
459 let (name, desc) = split_first_word(r);
460 PhpDocTag::Param {
461 type_str: Some(type_str),
462 name: Some(name),
463 description: desc.map(Cow::Borrowed),
464 }
465 }
466 _ => PhpDocTag::Param {
467 type_str: Some(type_str),
468 name: None,
469 description: rest.map(Cow::Borrowed),
470 },
471 }
472}
473
474fn parse_return_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
476 let Some(body) = body else {
477 return PhpDocTag::Return {
478 type_str: None,
479 description: None,
480 };
481 };
482
483 let (type_str, desc) = split_type(body);
484 PhpDocTag::Return {
485 type_str: Some(type_str),
486 description: desc.map(|d| Cow::Borrowed(d.trim_start())),
487 }
488}
489
490fn parse_var_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
492 let Some(body) = body else {
493 return PhpDocTag::Var {
494 type_str: None,
495 name: None,
496 description: None,
497 };
498 };
499
500 if body.starts_with('$') {
501 let (name, desc) = split_first_word(body);
502 return PhpDocTag::Var {
503 type_str: None,
504 name: Some(name),
505 description: desc.map(Cow::Borrowed),
506 };
507 }
508
509 let (type_str, rest) = split_type(body);
510 let rest = rest.map(|r| r.trim_start());
511
512 match rest {
513 Some(r) if r.starts_with('$') => {
514 let (name, desc) = split_first_word(r);
515 PhpDocTag::Var {
516 type_str: Some(type_str),
517 name: Some(name),
518 description: desc.map(Cow::Borrowed),
519 }
520 }
521 _ => PhpDocTag::Var {
522 type_str: Some(type_str),
523 name: None,
524 description: rest.map(Cow::Borrowed),
525 },
526 }
527}
528
529fn parse_throws_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
531 let Some(body) = body else {
532 return PhpDocTag::Throws {
533 type_str: None,
534 description: None,
535 };
536 };
537
538 let (type_str, desc) = split_type(body);
539 PhpDocTag::Throws {
540 type_str: Some(type_str),
541 description: desc.map(|d| Cow::Borrowed(d.trim_start())),
542 }
543}
544
545fn parse_template_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
547 let Some(body) = body else {
548 return PhpDocTag::Template {
549 name: "",
550 bound: None,
551 };
552 };
553
554 let (name, rest) = split_first_word(body);
555 let bound = rest.and_then(|r| {
556 let r = r.trim_start();
557 r.strip_prefix("of ")
559 .or_else(|| r.strip_prefix("as "))
560 .map(|b| b.trim())
561 .or(Some(r))
562 });
563
564 PhpDocTag::Template {
565 name,
566 bound: bound.filter(|b| !b.is_empty()),
567 }
568}
569
570fn parse_assert_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
572 let Some(body) = body else {
573 return PhpDocTag::Assert {
574 type_str: None,
575 name: None,
576 };
577 };
578
579 if body.starts_with('$') {
580 return PhpDocTag::Assert {
581 type_str: None,
582 name: Some(body.split_whitespace().next().unwrap_or(body)),
583 };
584 }
585
586 let (type_str, rest) = split_type(body);
587 let name = rest.and_then(|r| {
588 let r = r.trim_start();
589 if r.starts_with('$') {
590 Some(r.split_whitespace().next().unwrap_or(r))
591 } else {
592 None
593 }
594 });
595
596 PhpDocTag::Assert {
597 type_str: Some(type_str),
598 name,
599 }
600}
601
602fn parse_type_alias_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
604 let Some(body) = body else {
605 return PhpDocTag::TypeAlias {
606 name: None,
607 type_str: None,
608 };
609 };
610
611 let (name, rest) = split_first_word(body);
612 let type_str = rest.and_then(|r| {
613 let r = r.trim_start();
614 let r = r.strip_prefix('=').unwrap_or(r).trim_start();
616 if r.is_empty() {
617 None
618 } else {
619 Some(r)
620 }
621 });
622
623 PhpDocTag::TypeAlias {
624 name: Some(name),
625 type_str,
626 }
627}
628
629enum PropertyKind {
630 ReadWrite,
631 Read,
632 Write,
633}
634
635fn parse_property_tag<'src>(body: Option<&'src str>, kind: PropertyKind) -> PhpDocTag<'src> {
637 let (type_str, name, description) = parse_type_name_desc(body);
638
639 match kind {
640 PropertyKind::ReadWrite => PhpDocTag::Property {
641 type_str,
642 name,
643 description,
644 },
645 PropertyKind::Read => PhpDocTag::PropertyRead {
646 type_str,
647 name,
648 description,
649 },
650 PropertyKind::Write => PhpDocTag::PropertyWrite {
651 type_str,
652 name,
653 description,
654 },
655 }
656}
657
658fn parse_type_name_desc<'src>(
660 body: Option<&'src str>,
661) -> (Option<&'src str>, Option<&'src str>, Option<Cow<'src, str>>) {
662 let Some(body) = body else {
663 return (None, None, None);
664 };
665
666 if body.starts_with('$') {
667 let (name, desc) = split_first_word(body);
668 return (None, Some(name), desc.map(Cow::Borrowed));
669 }
670
671 let (type_str, rest) = split_type(body);
672 let rest = rest.map(|r| r.trim_start());
673
674 match rest {
675 Some(r) if r.starts_with('$') => {
676 let (name, desc) = split_first_word(r);
677 (Some(type_str), Some(name), desc.map(Cow::Borrowed))
678 }
679 _ => (Some(type_str), None, rest.map(Cow::Borrowed)),
680 }
681}
682
683fn split_first_word(s: &str) -> (&str, Option<&str>) {
689 match s.find(|c: char| c.is_whitespace()) {
690 Some(pos) => {
691 let rest = s[pos..].trim_start();
692 let rest = if rest.is_empty() { None } else { Some(rest) };
693 (&s[..pos], rest)
694 }
695 None => (s, None),
696 }
697}
698
699fn split_type(s: &str) -> (&str, Option<&str>) {
704 let bytes = s.as_bytes();
705 let mut depth = 0i32;
706 let mut i = 0;
707
708 while i < bytes.len() {
709 match bytes[i] {
710 b'<' | b'(' | b'{' => depth += 1,
711 b'>' | b')' | b'}' => {
712 depth -= 1;
713 if depth < 0 {
714 depth = 0;
715 }
716 }
717 b' ' | b'\t' if depth == 0 => {
718 if i > 0 && bytes[i - 1] == b':' {
721 i += 1;
723 continue;
724 }
725 let rest = s[i..].trim_start();
726 let rest = if rest.is_empty() { None } else { Some(rest) };
727 return (&s[..i], rest);
728 }
729 _ => {}
730 }
731 i += 1;
732 }
733
734 (s, None)
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 #[test]
742 fn simple_param() {
743 let doc = parse("/** @param int $x The value */");
744 assert_eq!(doc.tags.len(), 1);
745 match &doc.tags[0] {
746 PhpDocTag::Param {
747 type_str,
748 name,
749 description,
750 } => {
751 assert_eq!(*type_str, Some("int"));
752 assert_eq!(*name, Some("$x"));
753 assert_eq!(description.as_deref(), Some("The value"));
754 }
755 _ => panic!("expected Param tag"),
756 }
757 }
758
759 #[test]
760 fn summary_and_tags() {
761 let doc = parse(
762 "/**
763 * Short summary here.
764 *
765 * Longer description.
766 *
767 * @param string $name The name
768 * @return bool
769 */",
770 );
771 assert_eq!(doc.summary, Some("Short summary here."));
772 assert_eq!(doc.description, Some("Longer description."));
773 assert_eq!(doc.tags.len(), 2);
774 }
775
776 #[test]
777 fn generic_type() {
778 let doc = parse("/** @param array<string, int> $map */");
779 match &doc.tags[0] {
780 PhpDocTag::Param { type_str, name, .. } => {
781 assert_eq!(*type_str, Some("array<string, int>"));
782 assert_eq!(*name, Some("$map"));
783 }
784 _ => panic!("expected Param tag"),
785 }
786 }
787
788 #[test]
789 fn union_type() {
790 let doc = parse("/** @return string|null */");
791 match &doc.tags[0] {
792 PhpDocTag::Return { type_str, .. } => {
793 assert_eq!(*type_str, Some("string|null"));
794 }
795 _ => panic!("expected Return tag"),
796 }
797 }
798
799 #[test]
800 fn template_tag() {
801 let doc = parse("/** @template T of \\Countable */");
802 match &doc.tags[0] {
803 PhpDocTag::Template { name, bound } => {
804 assert_eq!(*name, "T");
805 assert_eq!(*bound, Some("\\Countable"));
806 }
807 _ => panic!("expected Template tag"),
808 }
809 }
810
811 #[test]
812 fn deprecated_tag() {
813 let doc = parse("/** @deprecated Use newMethod() instead */");
814 match &doc.tags[0] {
815 PhpDocTag::Deprecated { description } => {
816 assert_eq!(description.as_deref(), Some("Use newMethod() instead"));
817 }
818 _ => panic!("expected Deprecated tag"),
819 }
820 }
821
822 #[test]
823 fn inheritdoc() {
824 let doc = parse("/** @inheritdoc */");
825 assert!(matches!(doc.tags[0], PhpDocTag::InheritDoc));
826 }
827
828 #[test]
829 fn unknown_tag() {
830 let doc = parse("/** @custom-tag some body */");
831 match &doc.tags[0] {
832 PhpDocTag::Generic { tag, body } => {
833 assert_eq!(*tag, "custom-tag");
834 assert_eq!(body.as_deref(), Some("some body"));
835 }
836 _ => panic!("expected Generic tag"),
837 }
838 }
839
840 #[test]
841 fn multiple_params() {
842 let doc = parse(
843 "/**
844 * @param int $a First
845 * @param string $b Second
846 * @param bool $c
847 */",
848 );
849 assert_eq!(doc.tags.len(), 3);
850 assert!(matches!(
851 &doc.tags[0],
852 PhpDocTag::Param {
853 name: Some("$a"),
854 ..
855 }
856 ));
857 assert!(matches!(
858 &doc.tags[1],
859 PhpDocTag::Param {
860 name: Some("$b"),
861 ..
862 }
863 ));
864 assert!(matches!(
865 &doc.tags[2],
866 PhpDocTag::Param {
867 name: Some("$c"),
868 ..
869 }
870 ));
871 }
872
873 #[test]
874 fn var_tag() {
875 let doc = parse("/** @var int $count */");
876 match &doc.tags[0] {
877 PhpDocTag::Var { type_str, name, .. } => {
878 assert_eq!(*type_str, Some("int"));
879 assert_eq!(*name, Some("$count"));
880 }
881 _ => panic!("expected Var tag"),
882 }
883 }
884
885 #[test]
886 fn throws_tag() {
887 let doc = parse("/** @throws \\RuntimeException When things go wrong */");
888 match &doc.tags[0] {
889 PhpDocTag::Throws {
890 type_str,
891 description,
892 } => {
893 assert_eq!(*type_str, Some("\\RuntimeException"));
894 assert_eq!(description.as_deref(), Some("When things go wrong"));
895 }
896 _ => panic!("expected Throws tag"),
897 }
898 }
899
900 #[test]
901 fn property_tags() {
902 let doc = parse(
903 "/**
904 * @property string $name
905 * @property-read int $id
906 * @property-write bool $active
907 */",
908 );
909 assert_eq!(doc.tags.len(), 3);
910 assert!(matches!(
911 &doc.tags[0],
912 PhpDocTag::Property {
913 name: Some("$name"),
914 ..
915 }
916 ));
917 assert!(matches!(
918 &doc.tags[1],
919 PhpDocTag::PropertyRead {
920 name: Some("$id"),
921 ..
922 }
923 ));
924 assert!(matches!(
925 &doc.tags[2],
926 PhpDocTag::PropertyWrite {
927 name: Some("$active"),
928 ..
929 }
930 ));
931 }
932
933 #[test]
934 fn empty_doc_block() {
935 let doc = parse("/** */");
936 assert_eq!(doc.summary, None);
937 assert_eq!(doc.description, None);
938 assert!(doc.tags.is_empty());
939 }
940
941 #[test]
942 fn summary_only() {
943 let doc = parse("/** Does something cool. */");
944 assert_eq!(doc.summary, Some("Does something cool."));
945 assert_eq!(doc.description, None);
946 assert!(doc.tags.is_empty());
947 }
948
949 #[test]
950 fn callable_type() {
951 let doc = parse("/** @param callable(int, string): bool $fn */");
952 match &doc.tags[0] {
953 PhpDocTag::Param { type_str, name, .. } => {
954 assert_eq!(*type_str, Some("callable(int, string): bool"));
955 assert!(name.is_some());
960 }
961 _ => panic!("expected Param tag"),
962 }
963 }
964
965 #[test]
966 fn complex_generic_type() {
967 let doc = parse("/** @return array<int, list<string>> */");
968 match &doc.tags[0] {
969 PhpDocTag::Return { type_str, .. } => {
970 assert_eq!(*type_str, Some("array<int, list<string>>"));
971 }
972 _ => panic!("expected Return tag"),
973 }
974 }
975
976 #[test]
981 fn psalm_param() {
982 let doc = parse("/** @psalm-param array<string, int> $map */");
983 match &doc.tags[0] {
984 PhpDocTag::Param { type_str, name, .. } => {
985 assert_eq!(*type_str, Some("array<string, int>"));
986 assert_eq!(*name, Some("$map"));
987 }
988 _ => panic!("expected Param tag, got {:?}", doc.tags[0]),
989 }
990 }
991
992 #[test]
993 fn phpstan_return() {
994 let doc = parse("/** @phpstan-return list<non-empty-string> */");
995 match &doc.tags[0] {
996 PhpDocTag::Return { type_str, .. } => {
997 assert_eq!(*type_str, Some("list<non-empty-string>"));
998 }
999 _ => panic!("expected Return tag, got {:?}", doc.tags[0]),
1000 }
1001 }
1002
1003 #[test]
1004 fn psalm_assert() {
1005 let doc = parse("/** @psalm-assert int $x */");
1006 match &doc.tags[0] {
1007 PhpDocTag::Assert { type_str, name } => {
1008 assert_eq!(*type_str, Some("int"));
1009 assert_eq!(*name, Some("$x"));
1010 }
1011 _ => panic!("expected Assert tag, got {:?}", doc.tags[0]),
1012 }
1013 }
1014
1015 #[test]
1016 fn phpstan_assert() {
1017 let doc = parse("/** @phpstan-assert non-empty-string $value */");
1018 match &doc.tags[0] {
1019 PhpDocTag::Assert { type_str, name } => {
1020 assert_eq!(*type_str, Some("non-empty-string"));
1021 assert_eq!(*name, Some("$value"));
1022 }
1023 _ => panic!("expected Assert tag, got {:?}", doc.tags[0]),
1024 }
1025 }
1026
1027 #[test]
1028 fn psalm_type_alias() {
1029 let doc = parse("/** @psalm-type UserId = positive-int */");
1030 match &doc.tags[0] {
1031 PhpDocTag::TypeAlias { name, type_str } => {
1032 assert_eq!(*name, Some("UserId"));
1033 assert_eq!(*type_str, Some("positive-int"));
1034 }
1035 _ => panic!("expected TypeAlias tag, got {:?}", doc.tags[0]),
1036 }
1037 }
1038
1039 #[test]
1040 fn phpstan_type_alias() {
1041 let doc = parse("/** @phpstan-type Callback = callable(int): void */");
1042 match &doc.tags[0] {
1043 PhpDocTag::TypeAlias { name, type_str } => {
1044 assert_eq!(*name, Some("Callback"));
1045 assert_eq!(*type_str, Some("callable(int): void"));
1046 }
1047 _ => panic!("expected TypeAlias tag, got {:?}", doc.tags[0]),
1048 }
1049 }
1050
1051 #[test]
1052 fn psalm_suppress() {
1053 let doc = parse("/** @psalm-suppress InvalidReturnType */");
1054 match &doc.tags[0] {
1055 PhpDocTag::Suppress { rules } => {
1056 assert_eq!(*rules, "InvalidReturnType");
1057 }
1058 _ => panic!("expected Suppress tag, got {:?}", doc.tags[0]),
1059 }
1060 }
1061
1062 #[test]
1063 fn phpstan_ignore() {
1064 let doc = parse("/** @phpstan-ignore-next-line */");
1065 assert!(matches!(&doc.tags[0], PhpDocTag::Suppress { .. }));
1066 }
1067
1068 #[test]
1069 fn psalm_pure() {
1070 let doc = parse("/** @psalm-pure */");
1071 assert!(matches!(&doc.tags[0], PhpDocTag::Pure));
1072 }
1073
1074 #[test]
1075 fn psalm_immutable() {
1076 let doc = parse("/** @psalm-immutable */");
1077 assert!(matches!(&doc.tags[0], PhpDocTag::Immutable));
1078 }
1079
1080 #[test]
1081 fn mixin_tag() {
1082 let doc = parse("/** @mixin \\App\\Helpers\\Foo */");
1083 match &doc.tags[0] {
1084 PhpDocTag::Mixin { class } => {
1085 assert_eq!(*class, "\\App\\Helpers\\Foo");
1086 }
1087 _ => panic!("expected Mixin tag, got {:?}", doc.tags[0]),
1088 }
1089 }
1090
1091 #[test]
1092 fn template_covariant() {
1093 let doc = parse("/** @template-covariant T of object */");
1094 match &doc.tags[0] {
1095 PhpDocTag::TemplateCovariant { name, bound } => {
1096 assert_eq!(*name, "T");
1097 assert_eq!(*bound, Some("object"));
1098 }
1099 _ => panic!("expected TemplateCovariant tag, got {:?}", doc.tags[0]),
1100 }
1101 }
1102
1103 #[test]
1104 fn template_contravariant() {
1105 let doc = parse("/** @template-contravariant T */");
1106 match &doc.tags[0] {
1107 PhpDocTag::TemplateContravariant { name, bound } => {
1108 assert_eq!(*name, "T");
1109 assert_eq!(*bound, None);
1110 }
1111 _ => panic!("expected TemplateContravariant tag, got {:?}", doc.tags[0]),
1112 }
1113 }
1114
1115 #[test]
1116 fn psalm_import_type() {
1117 let doc = parse("/** @psalm-import-type UserId from UserRepository */");
1118 match &doc.tags[0] {
1119 PhpDocTag::ImportType { body } => {
1120 assert_eq!(*body, "UserId from UserRepository");
1121 }
1122 _ => panic!("expected ImportType tag, got {:?}", doc.tags[0]),
1123 }
1124 }
1125
1126 #[test]
1127 fn phpstan_var() {
1128 let doc = parse("/** @phpstan-var positive-int $count */");
1129 match &doc.tags[0] {
1130 PhpDocTag::Var { type_str, name, .. } => {
1131 assert_eq!(*type_str, Some("positive-int"));
1132 assert_eq!(*name, Some("$count"));
1133 }
1134 _ => panic!("expected Var tag, got {:?}", doc.tags[0]),
1135 }
1136 }
1137
1138 #[test]
1139 fn mixed_standard_and_psalm_tags() {
1140 let doc = parse(
1141 "/**
1142 * Create a user.
1143 *
1144 * @param string $name
1145 * @psalm-param non-empty-string $name
1146 * @return User
1147 * @psalm-assert-if-true User $result
1148 * @throws \\InvalidArgumentException
1149 */",
1150 );
1151 assert_eq!(doc.summary, Some("Create a user."));
1152 assert_eq!(doc.tags.len(), 5);
1153 assert!(matches!(&doc.tags[0], PhpDocTag::Param { .. }));
1154 assert!(matches!(&doc.tags[1], PhpDocTag::Param { .. }));
1155 assert!(matches!(&doc.tags[2], PhpDocTag::Return { .. }));
1156 assert!(matches!(&doc.tags[3], PhpDocTag::Assert { .. }));
1157 assert!(matches!(&doc.tags[4], PhpDocTag::Throws { .. }));
1158 }
1159}