Skip to main content

mir_analyzer/parser/
docblock.rs

1use mir_types::{Atomic, Union, Variance};
2/// Docblock parser — delegates to `php_rs_parser::phpdoc` for tag extraction,
3/// then converts `PhpDocTag`s into mir's `ParsedDocblock` with resolved types.
4use std::sync::Arc;
5
6use php_rs_parser::phpdoc::PhpDocTag;
7
8// ---------------------------------------------------------------------------
9// DocblockParser
10// ---------------------------------------------------------------------------
11
12pub struct DocblockParser;
13
14impl DocblockParser {
15    pub fn parse(text: &str) -> ParsedDocblock {
16        let doc = php_rs_parser::phpdoc::parse(text);
17        let mut result = ParsedDocblock {
18            description: extract_description(text),
19            ..Default::default()
20        };
21
22        for tag in &doc.tags {
23            match tag {
24                PhpDocTag::Param {
25                    type_str: Some(ty_s),
26                    name: Some(n),
27                    ..
28                } => {
29                    result.params.push((
30                        n.trim_start_matches('$').to_string(),
31                        parse_type_string(ty_s),
32                    ));
33                }
34                PhpDocTag::Return {
35                    type_str: Some(ty_s),
36                    ..
37                } => {
38                    result.return_type = Some(parse_type_string(ty_s));
39                }
40                PhpDocTag::Var { type_str, name, .. } => {
41                    if let Some(ty_s) = type_str {
42                        result.var_type = Some(parse_type_string(ty_s));
43                    }
44                    if let Some(n) = name {
45                        result.var_name = Some(n.trim_start_matches('$').to_string());
46                    }
47                }
48                PhpDocTag::Throws {
49                    type_str: Some(ty_s),
50                    ..
51                } => {
52                    let class = ty_s.split_whitespace().next().unwrap_or("").to_string();
53                    if !class.is_empty() {
54                        result.throws.push(class);
55                    }
56                }
57                PhpDocTag::Deprecated { description } => {
58                    result.is_deprecated = true;
59                    result.deprecated = Some(
60                        description
61                            .as_ref()
62                            .map(|d| d.to_string())
63                            .unwrap_or_default(),
64                    );
65                }
66                PhpDocTag::Template { name, bound } => {
67                    result.templates.push((
68                        name.to_string(),
69                        bound.map(parse_type_string),
70                        Variance::Invariant,
71                    ));
72                }
73                PhpDocTag::TemplateCovariant { name, bound } => {
74                    result.templates.push((
75                        name.to_string(),
76                        bound.map(parse_type_string),
77                        Variance::Covariant,
78                    ));
79                }
80                PhpDocTag::TemplateContravariant { name, bound } => {
81                    result.templates.push((
82                        name.to_string(),
83                        bound.map(parse_type_string),
84                        Variance::Contravariant,
85                    ));
86                }
87                PhpDocTag::Extends { type_str } => {
88                    result.extends = Some(parse_type_string(type_str));
89                }
90                PhpDocTag::Implements { type_str } => {
91                    result.implements.push(parse_type_string(type_str));
92                }
93                PhpDocTag::Assert {
94                    type_str: Some(ty_s),
95                    name: Some(n),
96                } => {
97                    result.assertions.push((
98                        n.trim_start_matches('$').to_string(),
99                        parse_type_string(ty_s),
100                    ));
101                }
102                PhpDocTag::Suppress { rules } => {
103                    for rule in rules.split([',', ' ']) {
104                        let rule = rule.trim().to_string();
105                        if !rule.is_empty() {
106                            result.suppressed_issues.push(rule);
107                        }
108                    }
109                }
110                PhpDocTag::See { reference } => result.see.push(reference.to_string()),
111                PhpDocTag::Link { url } => result.see.push(url.to_string()),
112                PhpDocTag::Mixin { class } => result.mixins.push(class.to_string()),
113                PhpDocTag::Property {
114                    type_str,
115                    name: Some(n),
116                    ..
117                } => result.properties.push(DocProperty {
118                    type_hint: type_str.unwrap_or("").to_string(),
119                    name: n.trim_start_matches('$').to_string(),
120                    read_only: false,
121                    write_only: false,
122                }),
123                PhpDocTag::PropertyRead {
124                    type_str,
125                    name: Some(n),
126                    ..
127                } => result.properties.push(DocProperty {
128                    type_hint: type_str.unwrap_or("").to_string(),
129                    name: n.trim_start_matches('$').to_string(),
130                    read_only: true,
131                    write_only: false,
132                }),
133                PhpDocTag::PropertyWrite {
134                    type_str,
135                    name: Some(n),
136                    ..
137                } => result.properties.push(DocProperty {
138                    type_hint: type_str.unwrap_or("").to_string(),
139                    name: n.trim_start_matches('$').to_string(),
140                    read_only: false,
141                    write_only: true,
142                }),
143                PhpDocTag::Method { signature } => {
144                    if let Some(m) = parse_method_line(signature) {
145                        result.methods.push(m);
146                    }
147                }
148                PhpDocTag::TypeAlias {
149                    name: Some(n),
150                    type_str,
151                } => result.type_aliases.push(DocTypeAlias {
152                    name: n.to_string(),
153                    type_expr: type_str.unwrap_or("").to_string(),
154                }),
155                PhpDocTag::Internal => result.is_internal = true,
156                PhpDocTag::Pure => result.is_pure = true,
157                PhpDocTag::Immutable => result.is_immutable = true,
158                PhpDocTag::Readonly => result.is_readonly = true,
159                PhpDocTag::Generic { tag, body } => match *tag {
160                    "api" | "psalm-api" => result.is_api = true,
161                    "psalm-assert" | "phpstan-assert" => {
162                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
163                            result.assertions.push((name, parse_type_string(&ty_str)));
164                        }
165                    }
166                    "psalm-assert-if-true" | "phpstan-assert-if-true" => {
167                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
168                            result
169                                .assertions_if_true
170                                .push((name, parse_type_string(&ty_str)));
171                        }
172                    }
173                    "psalm-assert-if-false" | "phpstan-assert-if-false" => {
174                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
175                            result
176                                .assertions_if_false
177                                .push((name, parse_type_string(&ty_str)));
178                        }
179                    }
180                    "psalm-property" => {
181                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
182                            result.properties.push(DocProperty {
183                                type_hint: ty_str,
184                                name,
185                                read_only: false,
186                                write_only: false,
187                            });
188                        }
189                    }
190                    "psalm-property-read" => {
191                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
192                            result.properties.push(DocProperty {
193                                type_hint: ty_str,
194                                name,
195                                read_only: true,
196                                write_only: false,
197                            });
198                        }
199                    }
200                    "psalm-property-write" => {
201                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
202                            result.properties.push(DocProperty {
203                                type_hint: ty_str,
204                                name,
205                                read_only: false,
206                                write_only: true,
207                            });
208                        }
209                    }
210                    "psalm-method" => {
211                        if let Some(method) = body.as_deref().and_then(parse_method_line) {
212                            result.methods.push(method);
213                        }
214                    }
215                    _ => {}
216                },
217                _ => {}
218            }
219        }
220
221        result
222    }
223}
224
225// ---------------------------------------------------------------------------
226// ParsedDocblock support types
227// ---------------------------------------------------------------------------
228
229#[derive(Debug, Default, Clone)]
230pub struct DocProperty {
231    pub type_hint: String,
232    pub name: String,     // without leading $
233    pub read_only: bool,  // true for @property-read
234    pub write_only: bool, // true for @property-write
235}
236
237#[derive(Debug, Default, Clone)]
238pub struct DocMethod {
239    pub return_type: String,
240    pub name: String,
241    pub is_static: bool,
242    pub params: Vec<DocMethodParam>,
243}
244
245#[derive(Debug, Default, Clone)]
246pub struct DocMethodParam {
247    pub name: String,
248    pub type_hint: String,
249    pub is_variadic: bool,
250    pub is_byref: bool,
251    pub is_optional: bool,
252}
253
254#[derive(Debug, Default, Clone)]
255pub struct DocTypeAlias {
256    pub name: String,
257    pub type_expr: String,
258}
259
260// ---------------------------------------------------------------------------
261// ParsedDocblock
262// ---------------------------------------------------------------------------
263
264#[derive(Debug, Default, Clone)]
265pub struct ParsedDocblock {
266    /// `@param Type $name`
267    pub params: Vec<(String, Union)>,
268    /// `@return Type`
269    pub return_type: Option<Union>,
270    /// `@var Type` or `@var Type $name` — type and optional variable name
271    pub var_type: Option<Union>,
272    /// Optional variable name from `@var Type $name`
273    pub var_name: Option<String>,
274    /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
275    pub templates: Vec<(String, Option<Union>, Variance)>,
276    /// `@extends ClassName<T>`
277    pub extends: Option<Union>,
278    /// `@implements InterfaceName<T>`
279    pub implements: Vec<Union>,
280    /// `@throws ClassName`
281    pub throws: Vec<String>,
282    /// `@psalm-assert Type $var`
283    pub assertions: Vec<(String, Union)>,
284    /// `@psalm-assert-if-true Type $var`
285    pub assertions_if_true: Vec<(String, Union)>,
286    /// `@psalm-assert-if-false Type $var`
287    pub assertions_if_false: Vec<(String, Union)>,
288    /// `@psalm-suppress IssueName`
289    pub suppressed_issues: Vec<String>,
290    pub is_deprecated: bool,
291    pub is_internal: bool,
292    pub is_pure: bool,
293    pub is_immutable: bool,
294    pub is_readonly: bool,
295    pub is_api: bool,
296    /// Free text before first `@` tag — used for hover display
297    pub description: String,
298    /// `@deprecated message` — Some(message) or Some("") if no message
299    pub deprecated: Option<String>,
300    /// `@see ClassName` / `@link URL`
301    pub see: Vec<String>,
302    /// `@mixin ClassName`
303    pub mixins: Vec<String>,
304    /// `@property`, `@property-read`, `@property-write`
305    pub properties: Vec<DocProperty>,
306    /// `@method [static] ReturnType name([params])`
307    pub methods: Vec<DocMethod>,
308    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
309    pub type_aliases: Vec<DocTypeAlias>,
310}
311
312impl ParsedDocblock {
313    /// Returns the type for a given parameter name (strips leading `$`).
314    pub fn get_param_type(&self, name: &str) -> Option<&Union> {
315        let name = name.trim_start_matches('$');
316        self.params
317            .iter()
318            .find(|(n, _)| n.trim_start_matches('$') == name)
319            .map(|(_, ty)| ty)
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Type string parser
325// ---------------------------------------------------------------------------
326
327/// Parse a PHPDoc type expression string into a `Union`.
328/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
329/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
330pub fn parse_type_string(s: &str) -> Union {
331    let s = s.trim();
332
333    // Nullable shorthand: `?Type`
334    if let Some(inner) = s.strip_prefix('?') {
335        let inner_ty = parse_type_string(inner);
336        let mut u = inner_ty;
337        u.add_type(Atomic::TNull);
338        return u;
339    }
340
341    // Union: `A|B|C`
342    if s.contains('|') && !is_inside_generics(s) {
343        let parts = split_union(s);
344        if parts.len() > 1 {
345            let mut u = Union::empty();
346            for part in parts {
347                for atomic in parse_type_string(&part).types {
348                    u.add_type(atomic);
349                }
350            }
351            return u;
352        }
353    }
354
355    // Intersection: `A&B&C` — PHP 8.1+ pure intersection type
356    if s.contains('&') && !is_inside_generics(s) {
357        let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
358        return Union::single(Atomic::TIntersection { parts });
359    }
360
361    // Array shorthand: `Type[]` or `Type[][]`
362    if let Some(value_str) = s.strip_suffix("[]") {
363        let value = parse_type_string(value_str);
364        return Union::single(Atomic::TArray {
365            key: Box::new(Union::single(Atomic::TInt)),
366            value: Box::new(value),
367        });
368    }
369
370    // Generic: `name<...>`
371    if let Some(open) = s.find('<') {
372        if s.ends_with('>') {
373            let name = &s[..open];
374            let inner = &s[open + 1..s.len() - 1];
375            return parse_generic(name, inner);
376        }
377    }
378
379    // Keywords
380    match s.to_lowercase().as_str() {
381        "string" => Union::single(Atomic::TString),
382        "non-empty-string" => Union::single(Atomic::TNonEmptyString),
383        "numeric-string" => Union::single(Atomic::TNumericString),
384        "class-string" => Union::single(Atomic::TClassString(None)),
385        "int" | "integer" => Union::single(Atomic::TInt),
386        "positive-int" => Union::single(Atomic::TPositiveInt),
387        "negative-int" => Union::single(Atomic::TNegativeInt),
388        "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
389        "float" | "double" => Union::single(Atomic::TFloat),
390        "bool" | "boolean" => Union::single(Atomic::TBool),
391        "true" => Union::single(Atomic::TTrue),
392        "false" => Union::single(Atomic::TFalse),
393        "null" => Union::single(Atomic::TNull),
394        "void" => Union::single(Atomic::TVoid),
395        "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
396        "mixed" => Union::single(Atomic::TMixed),
397        "object" => Union::single(Atomic::TObject),
398        "array" => Union::single(Atomic::TArray {
399            key: Box::new(Union::single(Atomic::TMixed)),
400            value: Box::new(Union::mixed()),
401        }),
402        "list" => Union::single(Atomic::TList {
403            value: Box::new(Union::mixed()),
404        }),
405        "callable" => Union::single(Atomic::TCallable {
406            params: None,
407            return_type: None,
408        }),
409        "iterable" => Union::single(Atomic::TArray {
410            key: Box::new(Union::single(Atomic::TMixed)),
411            value: Box::new(Union::mixed()),
412        }),
413        "scalar" => Union::single(Atomic::TScalar),
414        "numeric" => Union::single(Atomic::TNumeric),
415        "resource" => Union::mixed(), // treat as mixed
416        // self/static/parent: emit sentinel with empty FQCN; collector fills it in.
417        "static" => Union::single(Atomic::TStaticObject {
418            fqcn: Arc::from(""),
419        }),
420        "self" | "$this" => Union::single(Atomic::TSelf {
421            fqcn: Arc::from(""),
422        }),
423        "parent" => Union::single(Atomic::TParent {
424            fqcn: Arc::from(""),
425        }),
426
427        // Named class
428        _ if !s.is_empty()
429            && s.chars()
430                .next()
431                .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
432                .unwrap_or(false) =>
433        {
434            Union::single(Atomic::TNamedObject {
435                fqcn: normalize_fqcn(s).into(),
436                type_params: vec![],
437            })
438        }
439
440        _ => Union::mixed(),
441    }
442}
443
444fn parse_generic(name: &str, inner: &str) -> Union {
445    match name.to_lowercase().as_str() {
446        "array" => {
447            let params = split_generics(inner);
448            let (key, value) = if params.len() >= 2 {
449                (
450                    parse_type_string(params[0].trim()),
451                    parse_type_string(params[1].trim()),
452                )
453            } else {
454                (
455                    Union::single(Atomic::TInt),
456                    parse_type_string(params[0].trim()),
457                )
458            };
459            Union::single(Atomic::TArray {
460                key: Box::new(key),
461                value: Box::new(value),
462            })
463        }
464        "list" | "non-empty-list" => {
465            let value = parse_type_string(inner.trim());
466            if name.to_lowercase().starts_with("non-empty") {
467                Union::single(Atomic::TNonEmptyList {
468                    value: Box::new(value),
469                })
470            } else {
471                Union::single(Atomic::TList {
472                    value: Box::new(value),
473                })
474            }
475        }
476        "non-empty-array" => {
477            let params = split_generics(inner);
478            let (key, value) = if params.len() >= 2 {
479                (
480                    parse_type_string(params[0].trim()),
481                    parse_type_string(params[1].trim()),
482                )
483            } else {
484                (
485                    Union::single(Atomic::TInt),
486                    parse_type_string(params[0].trim()),
487                )
488            };
489            Union::single(Atomic::TNonEmptyArray {
490                key: Box::new(key),
491                value: Box::new(value),
492            })
493        }
494        "iterable" => {
495            let params = split_generics(inner);
496            let value = if params.len() >= 2 {
497                parse_type_string(params[1].trim())
498            } else {
499                parse_type_string(params[0].trim())
500            };
501            Union::single(Atomic::TArray {
502                key: Box::new(Union::single(Atomic::TMixed)),
503                value: Box::new(value),
504            })
505        }
506        "class-string" => Union::single(Atomic::TClassString(Some(
507            normalize_fqcn(inner.trim()).into(),
508        ))),
509        "int" => {
510            // int<min, max>
511            Union::single(Atomic::TIntRange {
512                min: None,
513                max: None,
514            })
515        }
516        // Named class with type params
517        _ => {
518            let params: Vec<Union> = split_generics(inner)
519                .iter()
520                .map(|p| parse_type_string(p.trim()))
521                .collect();
522            Union::single(Atomic::TNamedObject {
523                fqcn: normalize_fqcn(name).into(),
524                type_params: params,
525            })
526        }
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Helpers
532// ---------------------------------------------------------------------------
533
534/// Extract the description text (all prose before the first `@` tag) from a raw docblock.
535fn extract_description(text: &str) -> String {
536    let mut desc_lines: Vec<&str> = Vec::new();
537    for line in text.lines() {
538        let l = line.trim();
539        let l = l.trim_start_matches("/**").trim();
540        let l = l.trim_end_matches("*/").trim();
541        let l = l.trim_start_matches("*/").trim();
542        let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
543        let l = l.trim();
544        if l.starts_with('@') {
545            break;
546        }
547        if !l.is_empty() {
548            desc_lines.push(l);
549        }
550    }
551    desc_lines.join(" ")
552}
553
554fn parse_param_line(s: &str) -> Option<(String, String)> {
555    // Formats: `Type $name`, `Type $name description`
556    let mut parts = s.splitn(3, char::is_whitespace);
557    let ty = parts.next()?.trim().to_string();
558    let name = parts.next()?.trim().trim_start_matches('$').to_string();
559    if ty.is_empty() || name.is_empty() {
560        return None;
561    }
562    Some((ty, name))
563}
564
565fn split_union(s: &str) -> Vec<String> {
566    let mut parts = Vec::new();
567    let mut depth = 0;
568    let mut current = String::new();
569    for ch in s.chars() {
570        match ch {
571            '<' | '(' | '{' => {
572                depth += 1;
573                current.push(ch);
574            }
575            '>' | ')' | '}' => {
576                depth -= 1;
577                current.push(ch);
578            }
579            '|' if depth == 0 => {
580                parts.push(current.trim().to_string());
581                current = String::new();
582            }
583            _ => current.push(ch),
584        }
585    }
586    if !current.trim().is_empty() {
587        parts.push(current.trim().to_string());
588    }
589    parts
590}
591
592fn split_generics(s: &str) -> Vec<String> {
593    let mut parts = Vec::new();
594    let mut depth = 0;
595    let mut current = String::new();
596    for ch in s.chars() {
597        match ch {
598            '<' | '(' | '{' => {
599                depth += 1;
600                current.push(ch);
601            }
602            '>' | ')' | '}' => {
603                depth -= 1;
604                current.push(ch);
605            }
606            ',' if depth == 0 => {
607                parts.push(current.trim().to_string());
608                current = String::new();
609            }
610            _ => current.push(ch),
611        }
612    }
613    if !current.trim().is_empty() {
614        parts.push(current.trim().to_string());
615    }
616    parts
617}
618
619fn is_inside_generics(s: &str) -> bool {
620    let mut depth = 0i32;
621    for ch in s.chars() {
622        match ch {
623            '<' | '(' | '{' => depth += 1,
624            '>' | ')' | '}' => depth -= 1,
625            _ => {}
626        }
627    }
628    depth != 0
629}
630
631fn normalize_fqcn(s: &str) -> String {
632    // Strip leading backslash if present — we normalize all FQCNs without leading `\`
633    s.trim_start_matches('\\').to_string()
634}
635
636/// Parse `[static] [ReturnType] name(...)` for @method tags.
637fn parse_method_line(s: &str) -> Option<DocMethod> {
638    let mut rest = s.trim();
639    if rest.is_empty() {
640        return None;
641    }
642    let is_static = rest
643        .split_whitespace()
644        .next()
645        .map(|w| w.eq_ignore_ascii_case("static"))
646        .unwrap_or(false);
647    if is_static {
648        rest = rest["static".len()..].trim_start();
649    }
650
651    let open = rest.find('(').unwrap_or(rest.len());
652    let prefix = rest[..open].trim();
653    let mut parts: Vec<&str> = prefix.split_whitespace().collect();
654    let name = parts.pop()?.to_string();
655    if name.is_empty() {
656        return None;
657    }
658    let return_type = parts.join(" ");
659    Some(DocMethod {
660        return_type,
661        name,
662        is_static,
663        params: parse_method_params(rest),
664    })
665}
666
667fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
668    let Some(open) = name_part.find('(') else {
669        return vec![];
670    };
671    let Some(close) = name_part.rfind(')') else {
672        return vec![];
673    };
674    let inner = name_part[open + 1..close].trim();
675    if inner.is_empty() {
676        return vec![];
677    }
678
679    split_generics(inner)
680        .into_iter()
681        .filter_map(|param| parse_method_param(&param))
682        .collect()
683}
684
685fn parse_method_param(param: &str) -> Option<DocMethodParam> {
686    let before_default = param.split('=').next()?.trim();
687    let is_optional = param.contains('=');
688    let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
689    let raw_name = tokens.pop()?;
690    let is_variadic = raw_name.contains("...");
691    let is_byref = raw_name.contains('&');
692    let name = raw_name
693        .trim_start_matches('&')
694        .trim_start_matches("...")
695        .trim_start_matches('&')
696        .trim_start_matches('$')
697        .to_string();
698    if name.is_empty() {
699        return None;
700    }
701    Some(DocMethodParam {
702        name,
703        type_hint: tokens.join(" "),
704        is_variadic,
705        is_byref,
706        is_optional: is_optional || is_variadic,
707    })
708}
709
710// ---------------------------------------------------------------------------
711// Tests
712// ---------------------------------------------------------------------------
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use mir_types::Atomic;
718
719    #[test]
720    fn parse_string() {
721        let u = parse_type_string("string");
722        assert_eq!(u.types.len(), 1);
723        assert!(matches!(u.types[0], Atomic::TString));
724    }
725
726    #[test]
727    fn parse_nullable_string() {
728        let u = parse_type_string("?string");
729        assert!(u.is_nullable());
730        assert!(u.contains(|t| matches!(t, Atomic::TString)));
731    }
732
733    #[test]
734    fn parse_union() {
735        let u = parse_type_string("string|int|null");
736        assert!(u.contains(|t| matches!(t, Atomic::TString)));
737        assert!(u.contains(|t| matches!(t, Atomic::TInt)));
738        assert!(u.is_nullable());
739    }
740
741    #[test]
742    fn parse_array_of_string() {
743        let u = parse_type_string("array<string>");
744        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
745    }
746
747    #[test]
748    fn parse_list_of_int() {
749        let u = parse_type_string("list<int>");
750        assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
751    }
752
753    #[test]
754    fn parse_named_class() {
755        let u = parse_type_string("Foo\\Bar");
756        assert!(u.contains(
757            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
758        ));
759    }
760
761    #[test]
762    fn parse_docblock_param_return() {
763        let doc = r#"/**
764         * @param string $name
765         * @param int $age
766         * @return bool
767         */"#;
768        let parsed = DocblockParser::parse(doc);
769        assert_eq!(parsed.params.len(), 2);
770        assert!(parsed.return_type.is_some());
771        let ret = parsed.return_type.unwrap();
772        assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
773    }
774
775    #[test]
776    fn parse_template() {
777        let doc = "/** @template T of object */";
778        let parsed = DocblockParser::parse(doc);
779        assert_eq!(parsed.templates.len(), 1);
780        assert_eq!(parsed.templates[0].0, "T");
781        assert!(parsed.templates[0].1.is_some());
782        assert_eq!(parsed.templates[0].2, Variance::Invariant);
783    }
784
785    #[test]
786    fn parse_template_covariant() {
787        let doc = "/** @template-covariant T */";
788        let parsed = DocblockParser::parse(doc);
789        assert_eq!(parsed.templates.len(), 1);
790        assert_eq!(parsed.templates[0].0, "T");
791        assert_eq!(parsed.templates[0].2, Variance::Covariant);
792    }
793
794    #[test]
795    fn parse_template_contravariant() {
796        let doc = "/** @template-contravariant T */";
797        let parsed = DocblockParser::parse(doc);
798        assert_eq!(parsed.templates.len(), 1);
799        assert_eq!(parsed.templates[0].0, "T");
800        assert_eq!(parsed.templates[0].2, Variance::Contravariant);
801    }
802
803    #[test]
804    fn parse_deprecated() {
805        let doc = "/** @deprecated use newMethod() instead */";
806        let parsed = DocblockParser::parse(doc);
807        assert!(parsed.is_deprecated);
808        assert_eq!(
809            parsed.deprecated.as_deref(),
810            Some("use newMethod() instead")
811        );
812    }
813
814    #[test]
815    fn parse_description() {
816        let doc = r#"/**
817         * This is a description.
818         * Spans two lines.
819         * @param string $x
820         */"#;
821        let parsed = DocblockParser::parse(doc);
822        assert!(parsed.description.contains("This is a description"));
823        assert!(parsed.description.contains("Spans two lines"));
824    }
825
826    #[test]
827    fn parse_see_and_link() {
828        let doc = "/** @see SomeClass\n * @link https://example.com */";
829        let parsed = DocblockParser::parse(doc);
830        assert_eq!(parsed.see.len(), 2);
831        assert!(parsed.see.contains(&"SomeClass".to_string()));
832        assert!(parsed.see.contains(&"https://example.com".to_string()));
833    }
834
835    #[test]
836    fn parse_mixin() {
837        let doc = "/** @mixin SomeTrait */";
838        let parsed = DocblockParser::parse(doc);
839        assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
840    }
841
842    #[test]
843    fn parse_property_tags() {
844        let doc = r#"/**
845         * @property string $name
846         * @property-read int $id
847         * @property-write bool $active
848         */"#;
849        let parsed = DocblockParser::parse(doc);
850        assert_eq!(parsed.properties.len(), 3);
851        let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
852        assert_eq!(name_prop.type_hint, "string");
853        assert!(!name_prop.read_only);
854        assert!(!name_prop.write_only);
855        let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
856        assert!(id_prop.read_only);
857        let active_prop = parsed
858            .properties
859            .iter()
860            .find(|p| p.name == "active")
861            .unwrap();
862        assert!(active_prop.write_only);
863    }
864
865    #[test]
866    fn parse_method_tag() {
867        let doc = r#"/**
868         * @method string getName()
869         * @method static int create()
870         */"#;
871        let parsed = DocblockParser::parse(doc);
872        assert_eq!(parsed.methods.len(), 2);
873        let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
874        assert_eq!(get_name.return_type, "string");
875        assert!(!get_name.is_static);
876        let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
877        assert!(create.is_static);
878    }
879
880    #[test]
881    fn parse_type_alias_tag() {
882        let doc = "/** @psalm-type MyAlias = string|int */";
883        let parsed = DocblockParser::parse(doc);
884        assert_eq!(parsed.type_aliases.len(), 1);
885        assert_eq!(parsed.type_aliases[0].name, "MyAlias");
886        assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
887    }
888
889    #[test]
890    fn parse_intersection_two_parts() {
891        let u = parse_type_string("Iterator&Countable");
892        assert_eq!(u.types.len(), 1);
893        assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
894        if let Atomic::TIntersection { parts } = &u.types[0] {
895            assert!(parts[0].contains(
896                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
897            ));
898            assert!(parts[1].contains(
899                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
900            ));
901        }
902    }
903
904    #[test]
905    fn parse_intersection_three_parts() {
906        let u = parse_type_string("Iterator&Countable&Stringable");
907        assert_eq!(u.types.len(), 1);
908        let Atomic::TIntersection { parts } = &u.types[0] else {
909            panic!("expected TIntersection");
910        };
911        assert_eq!(parts.len(), 3);
912        assert!(parts[0].contains(
913            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
914        ));
915        assert!(parts[1].contains(
916            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
917        ));
918        assert!(parts[2].contains(
919            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
920        ));
921    }
922
923    #[test]
924    fn parse_intersection_in_union_with_null() {
925        let u = parse_type_string("Iterator&Countable|null");
926        assert!(u.is_nullable());
927        let intersection = u
928            .types
929            .iter()
930            .find_map(|t| {
931                if let Atomic::TIntersection { parts } = t {
932                    Some(parts)
933                } else {
934                    None
935                }
936            })
937            .expect("expected TIntersection");
938        assert_eq!(intersection.len(), 2);
939        assert!(intersection[0].contains(
940            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
941        ));
942        assert!(intersection[1].contains(
943            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
944        ));
945    }
946
947    #[test]
948    fn parse_intersection_in_union_with_scalar() {
949        let u = parse_type_string("Iterator&Countable|string");
950        assert!(u.contains(|t| matches!(t, Atomic::TString)));
951        let intersection = u
952            .types
953            .iter()
954            .find_map(|t| {
955                if let Atomic::TIntersection { parts } = t {
956                    Some(parts)
957                } else {
958                    None
959                }
960            })
961            .expect("expected TIntersection");
962        assert!(intersection[0].contains(
963            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
964        ));
965        assert!(intersection[1].contains(
966            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
967        ));
968    }
969}