Skip to main content

php_rs_parser/
phpdoc.rs

1//! PHPDoc comment parser.
2//!
3//! Parses `/** ... */` doc-block comments into a structured [`PhpDoc`]
4//! representation with summary, description, and typed tags.
5//!
6//! # Usage
7//!
8//! ```
9//! use php_ast::{CommentKind, PhpDoc};
10//!
11//! let text = "/** @param int $x The value */";
12//! let doc = php_rs_parser::phpdoc::parse(text);
13//! assert_eq!(doc.tags.len(), 1);
14//! ```
15
16use php_ast::{PhpDoc, PhpDocTag};
17
18/// Parse a raw doc-comment string into a [`PhpDoc`].
19///
20/// The input should be the full comment text including `/**` and `*/` delimiters.
21/// If the delimiters are missing, the text is parsed as-is.
22pub fn parse<'src>(text: &'src str) -> PhpDoc<'src> {
23    // Strip /** and */ delimiters
24    let inner = strip_delimiters(text);
25
26    // Clean lines: strip leading ` * ` prefixes
27    let lines = clean_lines(inner);
28
29    // Split into prose (summary + description) and tags
30    let (summary, description, tag_start) = extract_prose(&lines);
31
32    // Parse tags
33    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
46/// Strip `/**` prefix and `*/` suffix, returning the inner content.
47fn 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
53/// Represents a cleaned line with its source slice.
54struct CleanLine<'src> {
55    text: &'src str,
56}
57
58/// Clean doc-comment lines by stripping leading `*` decoration.
59fn clean_lines(inner: &str) -> Vec<CleanLine<'_>> {
60    inner
61        .lines()
62        .map(|line| {
63            let trimmed = line.trim();
64            // Strip leading `*` (with optional space after)
65            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
77/// Extract summary and description from the prose portion (before any tags).
78/// Returns (summary, description, index of first tag line).
79fn extract_prose<'src>(lines: &[CleanLine<'src>]) -> (Option<&'src str>, Option<&'src str>, usize) {
80    // Find the first tag line
81    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    // Skip leading empty lines
89    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    // Find the summary: text up to the first blank line or end of prose
95    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    // Description: everything after the blank line following summary
108    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            // Find the last non-empty line
115            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                // Return the first description line as the description
122                // (for multi-line, return the first line — consumers typically
123                // want summary + first paragraph)
124                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
138/// Parse tag lines into PhpDocTag values.
139/// Tag blocks can span multiple lines (continuation lines don't start with `@`).
140fn 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        // This is a tag line — use just this line for now
152        // (continuation lines are a future enhancement)
153        if let Some(tag) = parse_single_tag(line) {
154            tags.push(tag);
155        }
156        i += 1;
157    }
158
159    tags
160}
161
162/// Parse a single tag line like `@param int $x The value`.
163fn parse_single_tag<'src>(line: &'src str) -> Option<PhpDocTag<'src>> {
164    let line = line.strip_prefix('@')?;
165
166    // Split tag name from body
167    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    // Handle psalm-*/phpstan-* prefixed tags that map to standard tags
179    let effective = tag_lower
180        .strip_prefix("psalm-")
181        .or_else(|| tag_lower.strip_prefix("phpstan-"));
182
183    // Check for tool-specific tags first, then fall through to standard tags
184    match tag_lower.as_str() {
185        // Psalm/PHPStan-specific tags (no standard equivalent)
186        "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        // Standard tags (also matched via psalm-*/phpstan-* prefix)
227        _ => 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
268// =============================================================================
269// Tag-specific parsers
270// =============================================================================
271
272/// Parse `@param [type] $name [description]`
273fn 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 `$`, there's no type
283    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    // Otherwise: type [$name] [description]
293    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
313/// Parse `@return [type] [description]`
314fn 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
329/// Parse `@var [type] [$name] [description]`
330fn 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
368/// Parse `@throws [type] [description]`
369fn 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
384/// Parse `@template T [of Bound]`
385fn 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        // `of Bound` or `as Bound`
397        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
409/// Parse `@psalm-assert Type $name` / `@phpstan-assert Type $name`
410fn 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
441/// Parse `@psalm-type Name = Type` / `@phpstan-type Name = Type`
442fn 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        // Strip optional `=`
454        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
474/// Parse `@property[-read|-write] [type] $name [description]`
475fn 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
497/// Common parser for `[type] $name [description]` pattern.
498fn 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
520// =============================================================================
521// Utilities
522// =============================================================================
523
524/// Split a string at the first whitespace, returning (word, rest).
525fn 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
536/// Split a PHPDoc type from the rest of the text.
537///
538/// PHPDoc types can contain `<`, `>`, `(`, `)`, `{`, `}`, `|`, `&`, `[]`
539/// so we track nesting depth to find where the type ends.
540fn 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                // Check if this space follows a colon (callable return type notation)
556                // e.g. `callable(int): bool` — the space after `:` is within the type
557                if i > 0 && bytes[i - 1] == b':' {
558                    // Skip this space, continue to include the return type
559                    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                // The `: bool` is part of the callable type notation but our
793                // simple split_type stops at the space after `)`. That's fine —
794                // the colon syntax `callable(): T` has a space before the return
795                // type only in some notations. Let's just verify we got the name.
796                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    // =========================================================================
814    // Psalm / PHPStan annotations
815    // =========================================================================
816
817    #[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}