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