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_owned;
336 let tag_lower: &str = if tag_name.bytes().all(|b| !b.is_ascii_uppercase()) {
337 tag_name
338 } else {
339 tag_lower_owned = tag_name.to_ascii_lowercase();
340 &tag_lower_owned
341 };
342
343 let effective = tag_lower
345 .strip_prefix("psalm-")
346 .or_else(|| tag_lower.strip_prefix("phpstan-"));
347
348 match tag_lower {
350 "psalm-assert"
352 | "phpstan-assert"
353 | "psalm-assert-if-true"
354 | "phpstan-assert-if-true"
355 | "psalm-assert-if-false"
356 | "phpstan-assert-if-false" => Some(parse_assert_tag(body)),
357 "psalm-type" | "phpstan-type" => Some(parse_type_alias_tag(body)),
358 "psalm-import-type" | "phpstan-import-type" => Some(PhpDocTag::ImportType {
359 body: body.unwrap_or(""),
360 }),
361 "psalm-suppress" => Some(PhpDocTag::Suppress {
362 rules: body.unwrap_or(""),
363 }),
364 "phpstan-ignore-next-line" | "phpstan-ignore" => Some(PhpDocTag::Suppress {
365 rules: body.unwrap_or(""),
366 }),
367 "psalm-pure" | "pure" => Some(PhpDocTag::Pure),
368 "psalm-readonly" | "readonly" => Some(PhpDocTag::Readonly),
369 "psalm-immutable" | "immutable" => Some(PhpDocTag::Immutable),
370 "mixin" => Some(PhpDocTag::Mixin {
371 class: body.unwrap_or(""),
372 }),
373 "template-covariant" => {
374 let tag = parse_template_tag(body);
375 match tag {
376 PhpDocTag::Template { name, bound } => {
377 Some(PhpDocTag::TemplateCovariant { name, bound })
378 }
379 _ => Some(tag),
380 }
381 }
382 "template-contravariant" => {
383 let tag = parse_template_tag(body);
384 match tag {
385 PhpDocTag::Template { name, bound } => {
386 Some(PhpDocTag::TemplateContravariant { name, bound })
387 }
388 _ => Some(tag),
389 }
390 }
391 _ => match effective.unwrap_or(tag_lower) {
393 "param" => Some(parse_param_tag(body)),
394 "return" | "returns" => Some(parse_return_tag(body)),
395 "var" => Some(parse_var_tag(body)),
396 "throws" | "throw" => Some(parse_throws_tag(body)),
397 "deprecated" => Some(PhpDocTag::Deprecated {
398 description: body.map(Cow::Borrowed),
399 }),
400 "template" => Some(parse_template_tag(body)),
401 "extends" => Some(PhpDocTag::Extends {
402 type_str: body.unwrap_or(""),
403 }),
404 "implements" => Some(PhpDocTag::Implements {
405 type_str: body.unwrap_or(""),
406 }),
407 "method" => Some(PhpDocTag::Method {
408 signature: body.unwrap_or(""),
409 }),
410 "property" => Some(parse_property_tag(body, PropertyKind::ReadWrite)),
411 "property-read" => Some(parse_property_tag(body, PropertyKind::Read)),
412 "property-write" => Some(parse_property_tag(body, PropertyKind::Write)),
413 "see" => Some(PhpDocTag::See {
414 reference: body.unwrap_or(""),
415 }),
416 "link" => Some(PhpDocTag::Link {
417 url: body.unwrap_or(""),
418 }),
419 "since" => Some(PhpDocTag::Since {
420 version: body.unwrap_or(""),
421 }),
422 "author" => Some(PhpDocTag::Author {
423 name: body.unwrap_or(""),
424 }),
425 "internal" => Some(PhpDocTag::Internal),
426 "inheritdoc" => Some(PhpDocTag::InheritDoc),
427 _ => Some(PhpDocTag::Generic {
428 tag: tag_name,
429 body: body.map(Cow::Borrowed),
430 }),
431 },
432 }
433}
434
435fn parse_param_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
441 let Some(body) = body else {
442 return PhpDocTag::Param {
443 type_str: None,
444 name: None,
445 description: None,
446 };
447 };
448
449 if body.starts_with('$') {
451 let (name, desc) = split_first_word(body);
452 return PhpDocTag::Param {
453 type_str: None,
454 name: Some(name),
455 description: desc.map(Cow::Borrowed),
456 };
457 }
458
459 let (type_str, rest) = split_type(body);
461 let rest = rest.map(|r| r.trim_start());
462
463 match rest {
464 Some(r) if r.starts_with('$') => {
465 let (name, desc) = split_first_word(r);
466 PhpDocTag::Param {
467 type_str: Some(type_str),
468 name: Some(name),
469 description: desc.map(Cow::Borrowed),
470 }
471 }
472 _ => PhpDocTag::Param {
473 type_str: Some(type_str),
474 name: None,
475 description: rest.map(Cow::Borrowed),
476 },
477 }
478}
479
480fn parse_return_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
482 let Some(body) = body else {
483 return PhpDocTag::Return {
484 type_str: None,
485 description: None,
486 };
487 };
488
489 let (type_str, desc) = split_type(body);
490 PhpDocTag::Return {
491 type_str: Some(type_str),
492 description: desc.map(|d| Cow::Borrowed(d.trim_start())),
493 }
494}
495
496fn parse_var_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
498 let Some(body) = body else {
499 return PhpDocTag::Var {
500 type_str: None,
501 name: None,
502 description: None,
503 };
504 };
505
506 if body.starts_with('$') {
507 let (name, desc) = split_first_word(body);
508 return PhpDocTag::Var {
509 type_str: None,
510 name: Some(name),
511 description: desc.map(Cow::Borrowed),
512 };
513 }
514
515 let (type_str, rest) = split_type(body);
516 let rest = rest.map(|r| r.trim_start());
517
518 match rest {
519 Some(r) if r.starts_with('$') => {
520 let (name, desc) = split_first_word(r);
521 PhpDocTag::Var {
522 type_str: Some(type_str),
523 name: Some(name),
524 description: desc.map(Cow::Borrowed),
525 }
526 }
527 _ => PhpDocTag::Var {
528 type_str: Some(type_str),
529 name: None,
530 description: rest.map(Cow::Borrowed),
531 },
532 }
533}
534
535fn parse_throws_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
537 let Some(body) = body else {
538 return PhpDocTag::Throws {
539 type_str: None,
540 description: None,
541 };
542 };
543
544 let (type_str, desc) = split_type(body);
545 PhpDocTag::Throws {
546 type_str: Some(type_str),
547 description: desc.map(|d| Cow::Borrowed(d.trim_start())),
548 }
549}
550
551fn parse_template_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
553 let Some(body) = body else {
554 return PhpDocTag::Template {
555 name: "",
556 bound: None,
557 };
558 };
559
560 let (name, rest) = split_first_word(body);
561 let bound = rest.and_then(|r| {
562 let r = r.trim_start();
563 r.strip_prefix("of ")
565 .or_else(|| r.strip_prefix("as "))
566 .map(|b| b.trim())
567 .or(Some(r))
568 });
569
570 PhpDocTag::Template {
571 name,
572 bound: bound.filter(|b| !b.is_empty()),
573 }
574}
575
576fn parse_assert_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
578 let Some(body) = body else {
579 return PhpDocTag::Assert {
580 type_str: None,
581 name: None,
582 };
583 };
584
585 if body.starts_with('$') {
586 return PhpDocTag::Assert {
587 type_str: None,
588 name: Some(body.split_whitespace().next().unwrap_or(body)),
589 };
590 }
591
592 let (type_str, rest) = split_type(body);
593 let name = rest.and_then(|r| {
594 let r = r.trim_start();
595 if r.starts_with('$') {
596 Some(r.split_whitespace().next().unwrap_or(r))
597 } else {
598 None
599 }
600 });
601
602 PhpDocTag::Assert {
603 type_str: Some(type_str),
604 name,
605 }
606}
607
608fn parse_type_alias_tag<'src>(body: Option<&'src str>) -> PhpDocTag<'src> {
610 let Some(body) = body else {
611 return PhpDocTag::TypeAlias {
612 name: None,
613 type_str: None,
614 };
615 };
616
617 let (name, rest) = split_first_word(body);
618 let type_str = rest.and_then(|r| {
619 let r = r.trim_start();
620 let r = r.strip_prefix('=').unwrap_or(r).trim_start();
622 if r.is_empty() {
623 None
624 } else {
625 Some(r)
626 }
627 });
628
629 PhpDocTag::TypeAlias {
630 name: Some(name),
631 type_str,
632 }
633}
634
635enum PropertyKind {
636 ReadWrite,
637 Read,
638 Write,
639}
640
641fn parse_property_tag<'src>(body: Option<&'src str>, kind: PropertyKind) -> PhpDocTag<'src> {
643 let (type_str, name, description) = parse_type_name_desc(body);
644
645 match kind {
646 PropertyKind::ReadWrite => PhpDocTag::Property {
647 type_str,
648 name,
649 description,
650 },
651 PropertyKind::Read => PhpDocTag::PropertyRead {
652 type_str,
653 name,
654 description,
655 },
656 PropertyKind::Write => PhpDocTag::PropertyWrite {
657 type_str,
658 name,
659 description,
660 },
661 }
662}
663
664fn parse_type_name_desc<'src>(
666 body: Option<&'src str>,
667) -> (Option<&'src str>, Option<&'src str>, Option<Cow<'src, str>>) {
668 let Some(body) = body else {
669 return (None, None, None);
670 };
671
672 if body.starts_with('$') {
673 let (name, desc) = split_first_word(body);
674 return (None, Some(name), desc.map(Cow::Borrowed));
675 }
676
677 let (type_str, rest) = split_type(body);
678 let rest = rest.map(|r| r.trim_start());
679
680 match rest {
681 Some(r) if r.starts_with('$') => {
682 let (name, desc) = split_first_word(r);
683 (Some(type_str), Some(name), desc.map(Cow::Borrowed))
684 }
685 _ => (Some(type_str), None, rest.map(Cow::Borrowed)),
686 }
687}
688
689fn split_first_word(s: &str) -> (&str, Option<&str>) {
695 match s.find(|c: char| c.is_whitespace()) {
696 Some(pos) => {
697 let rest = s[pos..].trim_start();
698 let rest = if rest.is_empty() { None } else { Some(rest) };
699 (&s[..pos], rest)
700 }
701 None => (s, None),
702 }
703}
704
705fn split_type(s: &str) -> (&str, Option<&str>) {
710 let bytes = s.as_bytes();
711 let mut depth = 0i32;
712 let mut i = 0;
713
714 while i < bytes.len() {
715 match bytes[i] {
716 b'<' | b'(' | b'{' => depth += 1,
717 b'>' | b')' | b'}' => {
718 depth -= 1;
719 if depth < 0 {
720 depth = 0;
721 }
722 }
723 b' ' | b'\t' if depth == 0 => {
724 if i > 0 && bytes[i - 1] == b':' {
727 i += 1;
729 continue;
730 }
731 let rest = s[i..].trim_start();
732 let rest = if rest.is_empty() { None } else { Some(rest) };
733 return (&s[..i], rest);
734 }
735 _ => {}
736 }
737 i += 1;
738 }
739
740 (s, None)
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746
747 #[test]
748 fn simple_param() {
749 let doc = parse("/** @param int $x The value */");
750 assert_eq!(doc.tags.len(), 1);
751 match &doc.tags[0] {
752 PhpDocTag::Param {
753 type_str,
754 name,
755 description,
756 } => {
757 assert_eq!(*type_str, Some("int"));
758 assert_eq!(*name, Some("$x"));
759 assert_eq!(description.as_deref(), Some("The value"));
760 }
761 _ => panic!("expected Param tag"),
762 }
763 }
764
765 #[test]
766 fn summary_and_tags() {
767 let doc = parse(
768 "/**
769 * Short summary here.
770 *
771 * Longer description.
772 *
773 * @param string $name The name
774 * @return bool
775 */",
776 );
777 assert_eq!(doc.summary, Some("Short summary here."));
778 assert_eq!(doc.description, Some("Longer description."));
779 assert_eq!(doc.tags.len(), 2);
780 }
781
782 #[test]
783 fn generic_type() {
784 let doc = parse("/** @param array<string, int> $map */");
785 match &doc.tags[0] {
786 PhpDocTag::Param { type_str, name, .. } => {
787 assert_eq!(*type_str, Some("array<string, int>"));
788 assert_eq!(*name, Some("$map"));
789 }
790 _ => panic!("expected Param tag"),
791 }
792 }
793
794 #[test]
795 fn union_type() {
796 let doc = parse("/** @return string|null */");
797 match &doc.tags[0] {
798 PhpDocTag::Return { type_str, .. } => {
799 assert_eq!(*type_str, Some("string|null"));
800 }
801 _ => panic!("expected Return tag"),
802 }
803 }
804
805 #[test]
806 fn template_tag() {
807 let doc = parse("/** @template T of \\Countable */");
808 match &doc.tags[0] {
809 PhpDocTag::Template { name, bound } => {
810 assert_eq!(*name, "T");
811 assert_eq!(*bound, Some("\\Countable"));
812 }
813 _ => panic!("expected Template tag"),
814 }
815 }
816
817 #[test]
818 fn deprecated_tag() {
819 let doc = parse("/** @deprecated Use newMethod() instead */");
820 match &doc.tags[0] {
821 PhpDocTag::Deprecated { description } => {
822 assert_eq!(description.as_deref(), Some("Use newMethod() instead"));
823 }
824 _ => panic!("expected Deprecated tag"),
825 }
826 }
827
828 #[test]
829 fn inheritdoc() {
830 let doc = parse("/** @inheritdoc */");
831 assert!(matches!(doc.tags[0], PhpDocTag::InheritDoc));
832 }
833
834 #[test]
835 fn unknown_tag() {
836 let doc = parse("/** @custom-tag some body */");
837 match &doc.tags[0] {
838 PhpDocTag::Generic { tag, body } => {
839 assert_eq!(*tag, "custom-tag");
840 assert_eq!(body.as_deref(), Some("some body"));
841 }
842 _ => panic!("expected Generic tag"),
843 }
844 }
845
846 #[test]
847 fn multiple_params() {
848 let doc = parse(
849 "/**
850 * @param int $a First
851 * @param string $b Second
852 * @param bool $c
853 */",
854 );
855 assert_eq!(doc.tags.len(), 3);
856 assert!(matches!(
857 &doc.tags[0],
858 PhpDocTag::Param {
859 name: Some("$a"),
860 ..
861 }
862 ));
863 assert!(matches!(
864 &doc.tags[1],
865 PhpDocTag::Param {
866 name: Some("$b"),
867 ..
868 }
869 ));
870 assert!(matches!(
871 &doc.tags[2],
872 PhpDocTag::Param {
873 name: Some("$c"),
874 ..
875 }
876 ));
877 }
878
879 #[test]
880 fn var_tag() {
881 let doc = parse("/** @var int $count */");
882 match &doc.tags[0] {
883 PhpDocTag::Var { type_str, name, .. } => {
884 assert_eq!(*type_str, Some("int"));
885 assert_eq!(*name, Some("$count"));
886 }
887 _ => panic!("expected Var tag"),
888 }
889 }
890
891 #[test]
892 fn throws_tag() {
893 let doc = parse("/** @throws \\RuntimeException When things go wrong */");
894 match &doc.tags[0] {
895 PhpDocTag::Throws {
896 type_str,
897 description,
898 } => {
899 assert_eq!(*type_str, Some("\\RuntimeException"));
900 assert_eq!(description.as_deref(), Some("When things go wrong"));
901 }
902 _ => panic!("expected Throws tag"),
903 }
904 }
905
906 #[test]
907 fn property_tags() {
908 let doc = parse(
909 "/**
910 * @property string $name
911 * @property-read int $id
912 * @property-write bool $active
913 */",
914 );
915 assert_eq!(doc.tags.len(), 3);
916 assert!(matches!(
917 &doc.tags[0],
918 PhpDocTag::Property {
919 name: Some("$name"),
920 ..
921 }
922 ));
923 assert!(matches!(
924 &doc.tags[1],
925 PhpDocTag::PropertyRead {
926 name: Some("$id"),
927 ..
928 }
929 ));
930 assert!(matches!(
931 &doc.tags[2],
932 PhpDocTag::PropertyWrite {
933 name: Some("$active"),
934 ..
935 }
936 ));
937 }
938
939 #[test]
940 fn empty_doc_block() {
941 let doc = parse("/** */");
942 assert_eq!(doc.summary, None);
943 assert_eq!(doc.description, None);
944 assert!(doc.tags.is_empty());
945 }
946
947 #[test]
948 fn summary_only() {
949 let doc = parse("/** Does something cool. */");
950 assert_eq!(doc.summary, Some("Does something cool."));
951 assert_eq!(doc.description, None);
952 assert!(doc.tags.is_empty());
953 }
954
955 #[test]
956 fn callable_type() {
957 let doc = parse("/** @param callable(int, string): bool $fn */");
958 match &doc.tags[0] {
959 PhpDocTag::Param { type_str, name, .. } => {
960 assert_eq!(*type_str, Some("callable(int, string): bool"));
961 assert!(name.is_some());
966 }
967 _ => panic!("expected Param tag"),
968 }
969 }
970
971 #[test]
972 fn complex_generic_type() {
973 let doc = parse("/** @return array<int, list<string>> */");
974 match &doc.tags[0] {
975 PhpDocTag::Return { type_str, .. } => {
976 assert_eq!(*type_str, Some("array<int, list<string>>"));
977 }
978 _ => panic!("expected Return tag"),
979 }
980 }
981
982 #[test]
987 fn psalm_param() {
988 let doc = parse("/** @psalm-param array<string, int> $map */");
989 match &doc.tags[0] {
990 PhpDocTag::Param { type_str, name, .. } => {
991 assert_eq!(*type_str, Some("array<string, int>"));
992 assert_eq!(*name, Some("$map"));
993 }
994 _ => panic!("expected Param tag, got {:?}", doc.tags[0]),
995 }
996 }
997
998 #[test]
999 fn phpstan_return() {
1000 let doc = parse("/** @phpstan-return list<non-empty-string> */");
1001 match &doc.tags[0] {
1002 PhpDocTag::Return { type_str, .. } => {
1003 assert_eq!(*type_str, Some("list<non-empty-string>"));
1004 }
1005 _ => panic!("expected Return tag, got {:?}", doc.tags[0]),
1006 }
1007 }
1008
1009 #[test]
1010 fn psalm_assert() {
1011 let doc = parse("/** @psalm-assert int $x */");
1012 match &doc.tags[0] {
1013 PhpDocTag::Assert { type_str, name } => {
1014 assert_eq!(*type_str, Some("int"));
1015 assert_eq!(*name, Some("$x"));
1016 }
1017 _ => panic!("expected Assert tag, got {:?}", doc.tags[0]),
1018 }
1019 }
1020
1021 #[test]
1022 fn phpstan_assert() {
1023 let doc = parse("/** @phpstan-assert non-empty-string $value */");
1024 match &doc.tags[0] {
1025 PhpDocTag::Assert { type_str, name } => {
1026 assert_eq!(*type_str, Some("non-empty-string"));
1027 assert_eq!(*name, Some("$value"));
1028 }
1029 _ => panic!("expected Assert tag, got {:?}", doc.tags[0]),
1030 }
1031 }
1032
1033 #[test]
1034 fn psalm_type_alias() {
1035 let doc = parse("/** @psalm-type UserId = positive-int */");
1036 match &doc.tags[0] {
1037 PhpDocTag::TypeAlias { name, type_str } => {
1038 assert_eq!(*name, Some("UserId"));
1039 assert_eq!(*type_str, Some("positive-int"));
1040 }
1041 _ => panic!("expected TypeAlias tag, got {:?}", doc.tags[0]),
1042 }
1043 }
1044
1045 #[test]
1046 fn phpstan_type_alias() {
1047 let doc = parse("/** @phpstan-type Callback = callable(int): void */");
1048 match &doc.tags[0] {
1049 PhpDocTag::TypeAlias { name, type_str } => {
1050 assert_eq!(*name, Some("Callback"));
1051 assert_eq!(*type_str, Some("callable(int): void"));
1052 }
1053 _ => panic!("expected TypeAlias tag, got {:?}", doc.tags[0]),
1054 }
1055 }
1056
1057 #[test]
1058 fn psalm_suppress() {
1059 let doc = parse("/** @psalm-suppress InvalidReturnType */");
1060 match &doc.tags[0] {
1061 PhpDocTag::Suppress { rules } => {
1062 assert_eq!(*rules, "InvalidReturnType");
1063 }
1064 _ => panic!("expected Suppress tag, got {:?}", doc.tags[0]),
1065 }
1066 }
1067
1068 #[test]
1069 fn phpstan_ignore() {
1070 let doc = parse("/** @phpstan-ignore-next-line */");
1071 assert!(matches!(&doc.tags[0], PhpDocTag::Suppress { .. }));
1072 }
1073
1074 #[test]
1075 fn psalm_pure() {
1076 let doc = parse("/** @psalm-pure */");
1077 assert!(matches!(&doc.tags[0], PhpDocTag::Pure));
1078 }
1079
1080 #[test]
1081 fn psalm_immutable() {
1082 let doc = parse("/** @psalm-immutable */");
1083 assert!(matches!(&doc.tags[0], PhpDocTag::Immutable));
1084 }
1085
1086 #[test]
1087 fn mixin_tag() {
1088 let doc = parse("/** @mixin \\App\\Helpers\\Foo */");
1089 match &doc.tags[0] {
1090 PhpDocTag::Mixin { class } => {
1091 assert_eq!(*class, "\\App\\Helpers\\Foo");
1092 }
1093 _ => panic!("expected Mixin tag, got {:?}", doc.tags[0]),
1094 }
1095 }
1096
1097 #[test]
1098 fn template_covariant() {
1099 let doc = parse("/** @template-covariant T of object */");
1100 match &doc.tags[0] {
1101 PhpDocTag::TemplateCovariant { name, bound } => {
1102 assert_eq!(*name, "T");
1103 assert_eq!(*bound, Some("object"));
1104 }
1105 _ => panic!("expected TemplateCovariant tag, got {:?}", doc.tags[0]),
1106 }
1107 }
1108
1109 #[test]
1110 fn template_contravariant() {
1111 let doc = parse("/** @template-contravariant T */");
1112 match &doc.tags[0] {
1113 PhpDocTag::TemplateContravariant { name, bound } => {
1114 assert_eq!(*name, "T");
1115 assert_eq!(*bound, None);
1116 }
1117 _ => panic!("expected TemplateContravariant tag, got {:?}", doc.tags[0]),
1118 }
1119 }
1120
1121 #[test]
1122 fn psalm_import_type() {
1123 let doc = parse("/** @psalm-import-type UserId from UserRepository */");
1124 match &doc.tags[0] {
1125 PhpDocTag::ImportType { body } => {
1126 assert_eq!(*body, "UserId from UserRepository");
1127 }
1128 _ => panic!("expected ImportType tag, got {:?}", doc.tags[0]),
1129 }
1130 }
1131
1132 #[test]
1133 fn phpstan_var() {
1134 let doc = parse("/** @phpstan-var positive-int $count */");
1135 match &doc.tags[0] {
1136 PhpDocTag::Var { type_str, name, .. } => {
1137 assert_eq!(*type_str, Some("positive-int"));
1138 assert_eq!(*name, Some("$count"));
1139 }
1140 _ => panic!("expected Var tag, got {:?}", doc.tags[0]),
1141 }
1142 }
1143
1144 #[test]
1145 fn mixed_standard_and_psalm_tags() {
1146 let doc = parse(
1147 "/**
1148 * Create a user.
1149 *
1150 * @param string $name
1151 * @psalm-param non-empty-string $name
1152 * @return User
1153 * @psalm-assert-if-true User $result
1154 * @throws \\InvalidArgumentException
1155 */",
1156 );
1157 assert_eq!(doc.summary, Some("Create a user."));
1158 assert_eq!(doc.tags.len(), 5);
1159 assert!(matches!(&doc.tags[0], PhpDocTag::Param { .. }));
1160 assert!(matches!(&doc.tags[1], PhpDocTag::Param { .. }));
1161 assert!(matches!(&doc.tags[2], PhpDocTag::Return { .. }));
1162 assert!(matches!(&doc.tags[3], PhpDocTag::Assert { .. }));
1163 assert!(matches!(&doc.tags[4], PhpDocTag::Throws { .. }));
1164 }
1165}