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//! let text = "/** @param int $x The value */";
10//! let doc = php_rs_parser::phpdoc::parse(text);
11//! assert_eq!(doc.tags.len(), 1);
12//! ```
13
14use std::borrow::Cow;
15
16/// A parsed PHPDoc block (`/** ... */`).
17#[derive(Debug)]
18pub struct PhpDoc<'src> {
19    /// The summary line (first line of text before a blank line or tag).
20    pub summary: Option<&'src str>,
21    /// The long description (text after the summary, before the first tag).
22    pub description: Option<&'src str>,
23    /// Parsed tags in source order.
24    pub tags: Vec<PhpDocTag<'src>>,
25}
26
27/// A single PHPDoc tag (e.g. `@param int $x The value`).
28#[derive(Debug)]
29pub enum PhpDocTag<'src> {
30    /// `@param [type] $name [description]`
31    Param {
32        type_str: Option<&'src str>,
33        name: Option<&'src str>,
34        description: Option<Cow<'src, str>>,
35    },
36    /// `@return [type] [description]`
37    Return {
38        type_str: Option<&'src str>,
39        description: Option<Cow<'src, str>>,
40    },
41    /// `@var [type] [$name] [description]`
42    Var {
43        type_str: Option<&'src str>,
44        name: Option<&'src str>,
45        description: Option<Cow<'src, str>>,
46    },
47    /// `@throws [type] [description]`
48    Throws {
49        type_str: Option<&'src str>,
50        description: Option<Cow<'src, str>>,
51    },
52    /// `@deprecated [description]`
53    Deprecated { description: Option<Cow<'src, str>> },
54    /// `@template T [of bound]`
55    Template {
56        name: &'src str,
57        bound: Option<&'src str>,
58    },
59    /// `@extends [type]`
60    Extends { type_str: &'src str },
61    /// `@implements [type]`
62    Implements { type_str: &'src str },
63    /// `@method [static] [return_type] name(params) [description]`
64    Method { signature: &'src str },
65    /// `@property [type] $name [description]`
66    Property {
67        type_str: Option<&'src str>,
68        name: Option<&'src str>,
69        description: Option<Cow<'src, str>>,
70    },
71    /// `@property-read [type] $name [description]`
72    PropertyRead {
73        type_str: Option<&'src str>,
74        name: Option<&'src str>,
75        description: Option<Cow<'src, str>>,
76    },
77    /// `@property-write [type] $name [description]`
78    PropertyWrite {
79        type_str: Option<&'src str>,
80        name: Option<&'src str>,
81        description: Option<Cow<'src, str>>,
82    },
83    /// `@see [reference] [description]`
84    See { reference: &'src str },
85    /// `@link [url] [description]`
86    Link { url: &'src str },
87    /// `@since [version] [description]`
88    Since { version: &'src str },
89    /// `@author name [<email>]`
90    Author { name: &'src str },
91    /// `@internal`
92    Internal,
93    /// `@inheritdoc` / `{@inheritdoc}`
94    InheritDoc,
95    /// `@psalm-assert`, `@phpstan-assert` — assert that a parameter has a type after the call.
96    Assert {
97        type_str: Option<&'src str>,
98        name: Option<&'src str>,
99    },
100    /// `@psalm-type`, `@phpstan-type` — local type alias (`@type Foo = int|string`).
101    TypeAlias {
102        name: Option<&'src str>,
103        type_str: Option<&'src str>,
104    },
105    /// `@psalm-import-type`, `@phpstan-import-type` — import a type alias from another class.
106    ImportType { body: &'src str },
107    /// `@psalm-suppress`, `@phpstan-ignore-next-line`, `@phpstan-ignore` — suppress diagnostics.
108    Suppress { rules: &'src str },
109    /// `@psalm-pure`, `@psalm-immutable`, `@psalm-readonly` — purity/immutability markers.
110    Pure,
111    /// `@psalm-readonly`, `@readonly` — marks a property as read-only.
112    Readonly,
113    /// `@psalm-immutable` — marks a class as immutable.
114    Immutable,
115    /// `@mixin [class]` — indicates the class delegates calls to another.
116    Mixin { class: &'src str },
117    /// `@template-covariant T [of bound]`
118    TemplateCovariant {
119        name: &'src str,
120        bound: Option<&'src str>,
121    },
122    /// `@template-contravariant T [of bound]`
123    TemplateContravariant {
124        name: &'src str,
125        bound: Option<&'src str>,
126    },
127    /// Any tag not specifically recognized: `@tagname [body]`
128    Generic {
129        tag: &'src str,
130        body: Option<Cow<'src, str>>,
131    },
132}
133
134/// Parse a raw doc-comment string into a [`PhpDoc`].
135///
136/// The input should be the full comment text including `/**` and `*/` delimiters.
137/// If the delimiters are missing, the text is parsed as-is.
138pub fn parse<'src>(text: &'src str) -> PhpDoc<'src> {
139    // Strip /** and */ delimiters
140    let inner = strip_delimiters(text);
141
142    // Clean lines: strip leading ` * ` prefixes
143    let lines = clean_lines(inner);
144
145    // Split into prose (summary + description) and tags
146    let (summary, description, tag_start) = extract_prose(&lines);
147
148    // Parse tags
149    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
162/// Strip `/**` prefix and `*/` suffix, returning the inner content.
163fn 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
169/// Represents a cleaned line with its source slice.
170struct CleanLine<'src> {
171    text: &'src str,
172}
173
174/// Clean doc-comment lines by stripping leading `*` decoration.
175fn clean_lines(inner: &str) -> Vec<CleanLine<'_>> {
176    inner
177        .lines()
178        .map(|line| {
179            let trimmed = line.trim();
180            // Strip leading `*` (with optional space after)
181            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
193/// Extract summary and description from the prose portion (before any tags).
194/// Returns (summary, description, index of first tag line).
195fn extract_prose<'src>(lines: &[CleanLine<'src>]) -> (Option<&'src str>, Option<&'src str>, usize) {
196    // Find the first tag line
197    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    // Skip leading empty lines
205    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    // Find the summary: text up to the first blank line or end of prose
211    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    // Description: everything after the blank line following summary
224    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            // Find the last non-empty line
231            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                // Return the first description line as the description
238                // (for multi-line, return the first line — consumers typically
239                // want summary + first paragraph)
240                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
254/// Parse tag lines into PhpDocTag values.
255/// Tag blocks can span multiple lines; continuation lines are accumulated into
256/// the tag's description/body field.
257fn 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            // Accumulate continuation lines (non-empty lines that don't start a new tag)
271            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
287/// Append a continuation line to the description/body field of a tag.
288fn 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        // Tags with no prose description field: continuation is silently ignored
317        _ => {}
318    }
319}
320
321/// Parse a single tag line like `@param int $x The value`.
322fn parse_single_tag<'src>(line: &'src str) -> Option<PhpDocTag<'src>> {
323    let line = line.strip_prefix('@')?;
324
325    // Split tag name from body
326    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    // Handle psalm-*/phpstan-* prefixed tags that map to standard tags
344    let effective = tag_lower
345        .strip_prefix("psalm-")
346        .or_else(|| tag_lower.strip_prefix("phpstan-"));
347
348    // Check for tool-specific tags first, then fall through to standard tags
349    match tag_lower {
350        // Psalm/PHPStan-specific tags (no standard equivalent)
351        "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        // Standard tags (also matched via psalm-*/phpstan-* prefix)
392        _ => 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
435// =============================================================================
436// Tag-specific parsers
437// =============================================================================
438
439/// Parse `@param [type] $name [description]`
440fn 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 `$`, there's no type
450    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    // Otherwise: type [$name] [description]
460    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
480/// Parse `@return [type] [description]`
481fn 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
496/// Parse `@var [type] [$name] [description]`
497fn 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
535/// Parse `@throws [type] [description]`
536fn 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
551/// Parse `@template T [of Bound]`
552fn 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        // `of Bound` or `as Bound`
564        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
576/// Parse `@psalm-assert Type $name` / `@phpstan-assert Type $name`
577fn 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
608/// Parse `@psalm-type Name = Type` / `@phpstan-type Name = Type`
609fn 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        // Strip optional `=`
621        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
641/// Parse `@property[-read|-write] [type] $name [description]`
642fn 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
664/// Common parser for `[type] $name [description]` pattern.
665fn 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
689// =============================================================================
690// Utilities
691// =============================================================================
692
693/// Split a string at the first whitespace, returning (word, rest).
694fn 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
705/// Split a PHPDoc type from the rest of the text.
706///
707/// PHPDoc types can contain `<`, `>`, `(`, `)`, `{`, `}`, `|`, `&`, `[]`
708/// so we track nesting depth to find where the type ends.
709fn 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                // Check if this space follows a colon (callable return type notation)
725                // e.g. `callable(int): bool` — the space after `:` is within the type
726                if i > 0 && bytes[i - 1] == b':' {
727                    // Skip this space, continue to include the return type
728                    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                // The `: bool` is part of the callable type notation but our
962                // simple split_type stops at the space after `)`. That's fine —
963                // the colon syntax `callable(): T` has a space before the return
964                // type only in some notations. Let's just verify we got the name.
965                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    // =========================================================================
983    // Psalm / PHPStan annotations
984    // =========================================================================
985
986    #[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}