Skip to main content

mir_analyzer/parser/
docblock.rs

1use mir_types::{ArrayKey, Atomic, Union, Variance};
2/// Docblock parser — delegates to `phpdoc_parser` for tag extraction,
3/// then converts tags into mir's `ParsedDocblock` with resolved types.
4use std::sync::Arc;
5
6use indexmap::IndexMap;
7use phpdoc_parser::{body_text, parse as parse_phpdoc};
8
9// ---------------------------------------------------------------------------
10// DocblockParser
11// ---------------------------------------------------------------------------
12
13pub struct DocblockParser;
14
15impl DocblockParser {
16    pub fn parse(text: &str) -> ParsedDocblock {
17        let doc = parse_phpdoc(text);
18        let mut result = ParsedDocblock {
19            description: extract_description(text),
20            ..Default::default()
21        };
22
23        for tag in &doc.tags {
24            match tag.name.as_str() {
25                "param" | "psalm-param" | "phpstan-param" => {
26                    if let Some(body_str) = body_text(&tag.body) {
27                        if let Some((ty_s, name)) = parse_param_line(&body_str) {
28                            // Check if the parsed type is valid
29                            if is_inside_generics(&ty_s) {
30                                // For unclosed generics, report the full body for context
31                                if let Some(msg) = validate_type_str(&body_str, "param") {
32                                    result.invalid_annotations.push(msg);
33                                }
34                            } else if let Some(msg) = validate_type_str(&ty_s, "param") {
35                                // For other errors, report the parsed type
36                                result.invalid_annotations.push(msg);
37                            } else {
38                                result.params.push((
39                                    name.trim_start_matches('$').to_string(),
40                                    parse_type_string(&ty_s),
41                                ));
42                            }
43                        } else if let Some(msg) = validate_type_str(&body_str, "param") {
44                            // If parsing failed, validate the full body to provide better error context
45                            result.invalid_annotations.push(msg);
46                        }
47                    }
48                }
49                "return" | "psalm-return" | "phpstan-return" => {
50                    if let Some(body_str) = body_text(&tag.body) {
51                        let ty_s = extract_return_type(&body_str);
52                        if let Some(msg) = validate_type_str(&ty_s, "return") {
53                            result.invalid_annotations.push(msg);
54                        }
55                        result.return_type = Some(parse_type_string(&ty_s));
56                    }
57                }
58                "var" => {
59                    if let Some(body_str) = body_text(&tag.body) {
60                        if let Some((ty_s, name)) = parse_param_line(&body_str) {
61                            if let Some(msg) = validate_type_str(&ty_s, "var") {
62                                result.invalid_annotations.push(msg);
63                            }
64                            result.var_type = Some(parse_type_string(&ty_s));
65                            result.var_name = Some(name.trim_start_matches('$').to_string());
66                        } else {
67                            let ty_s = body_str.trim();
68                            if let Some(msg) = validate_type_str(ty_s, "var") {
69                                result.invalid_annotations.push(msg);
70                            }
71                            result.var_type = Some(parse_type_string(ty_s));
72                        }
73                    }
74                }
75                "throws" => {
76                    if let Some(body_str) = body_text(&tag.body) {
77                        let class = body_str.split_whitespace().next().unwrap_or("").to_string();
78                        if !class.is_empty() {
79                            result.throws.push(class);
80                        }
81                    }
82                }
83                "deprecated" => {
84                    result.is_deprecated = true;
85                    result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
86                }
87                "template" => {
88                    if let Some((name, bound)) =
89                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
90                    {
91                        if let Some(b) = &bound {
92                            if let Some(msg) = validate_type_str(b, "template") {
93                                result.invalid_annotations.push(msg);
94                            }
95                        }
96                        result.templates.push((
97                            name,
98                            bound.map(|b| parse_type_string(&b)),
99                            Variance::Invariant,
100                        ));
101                    }
102                }
103                "template-covariant" => {
104                    if let Some((name, bound)) =
105                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
106                    {
107                        if let Some(b) = &bound {
108                            if let Some(msg) = validate_type_str(b, "template-covariant") {
109                                result.invalid_annotations.push(msg);
110                            }
111                        }
112                        result.templates.push((
113                            name,
114                            bound.map(|b| parse_type_string(&b)),
115                            Variance::Covariant,
116                        ));
117                    }
118                }
119                "template-contravariant" => {
120                    if let Some((name, bound)) =
121                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
122                    {
123                        if let Some(b) = &bound {
124                            if let Some(msg) = validate_type_str(b, "template-contravariant") {
125                                result.invalid_annotations.push(msg);
126                            }
127                        }
128                        result.templates.push((
129                            name,
130                            bound.map(|b| parse_type_string(&b)),
131                            Variance::Contravariant,
132                        ));
133                    }
134                }
135                "extends" => {
136                    if let Some(body_str) = body_text(&tag.body) {
137                        result.extends = Some(parse_type_string(body_str.trim()));
138                    }
139                }
140                "implements" => {
141                    if let Some(body_str) = body_text(&tag.body) {
142                        result.implements.push(parse_type_string(body_str.trim()));
143                    }
144                }
145                "assert" | "psalm-assert" | "phpstan-assert" => {
146                    if let Some(body_str) = body_text(&tag.body) {
147                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
148                            result.assertions.push((name, parse_type_string(&ty_str)));
149                        }
150                    }
151                }
152                "suppress" | "psalm-suppress" => {
153                    if let Some(body_str) = body_text(&tag.body) {
154                        for rule in body_str.split([',', ' ']) {
155                            let rule = rule.trim().to_string();
156                            if !rule.is_empty() {
157                                result.suppressed_issues.push(rule);
158                            }
159                        }
160                    }
161                }
162                "see" => {
163                    if let Some(body_str) = body_text(&tag.body) {
164                        result.see.push(body_str.to_string());
165                    }
166                }
167                "link" => {
168                    if let Some(body_str) = body_text(&tag.body) {
169                        result.see.push(body_str.to_string());
170                    }
171                }
172                "mixin" => {
173                    if let Some(body_str) = body_text(&tag.body) {
174                        let base_class =
175                            body_str.split('<').next().unwrap_or(&body_str).to_string();
176                        result.mixins.push(base_class);
177                    }
178                }
179                "property" => {
180                    if let Some(body_str) = body_text(&tag.body) {
181                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
182                            result.properties.push(DocProperty {
183                                type_hint: ty_str,
184                                name: name.trim_start_matches('$').to_string(),
185                                read_only: false,
186                                write_only: false,
187                            });
188                        }
189                    }
190                }
191                "property-read" => {
192                    if let Some(body_str) = body_text(&tag.body) {
193                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
194                            result.properties.push(DocProperty {
195                                type_hint: ty_str,
196                                name: name.trim_start_matches('$').to_string(),
197                                read_only: true,
198                                write_only: false,
199                            });
200                        }
201                    }
202                }
203                "property-write" => {
204                    if let Some(body_str) = body_text(&tag.body) {
205                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
206                            result.properties.push(DocProperty {
207                                type_hint: ty_str,
208                                name: name.trim_start_matches('$').to_string(),
209                                read_only: false,
210                                write_only: true,
211                            });
212                        }
213                    }
214                }
215                "method" | "psalm-method" => {
216                    if let Some(body_str) = body_text(&tag.body) {
217                        if let Some(m) = parse_method_line(&body_str) {
218                            result.methods.push(m);
219                        }
220                    }
221                }
222                "psalm-type" | "phpstan-type" => {
223                    if let Some(body_str) = body_text(&tag.body) {
224                        if let Some((name, type_expr)) = body_str.split_once('=') {
225                            result.type_aliases.push(DocTypeAlias {
226                                name: name.trim().to_string(),
227                                type_expr: type_expr.trim().to_string(),
228                            });
229                        }
230                    }
231                }
232                "psalm-import-type" | "phpstan-import-type" => {
233                    if let Some(body_str) = body_text(&tag.body) {
234                        if let Some(import) = parse_import_type(&body_str) {
235                            result.import_types.push(import);
236                        }
237                    }
238                }
239                "since" if result.since.is_none() => {
240                    if let Some(body_str) = body_text(&tag.body) {
241                        let v = body_str.split_whitespace().next().unwrap_or("");
242                        if !v.is_empty() {
243                            result.since = Some(v.to_string());
244                        }
245                    }
246                }
247                "removed" if result.removed.is_none() => {
248                    if let Some(body_str) = body_text(&tag.body) {
249                        let v = body_str.split_whitespace().next().unwrap_or("");
250                        if !v.is_empty() {
251                            result.removed = Some(v.to_string());
252                        }
253                    }
254                }
255                "internal" => result.is_internal = true,
256                "pure" => result.is_pure = true,
257                "immutable" => result.is_immutable = true,
258                "readonly" => result.is_readonly = true,
259                "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
260                "api" | "psalm-api" => result.is_api = true,
261                "psalm-assert-if-true" | "phpstan-assert-if-true" => {
262                    if let Some(body_str) = body_text(&tag.body) {
263                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
264                            result
265                                .assertions_if_true
266                                .push((name, parse_type_string(&ty_str)));
267                        }
268                    }
269                }
270                "psalm-assert-if-false" | "phpstan-assert-if-false" => {
271                    if let Some(body_str) = body_text(&tag.body) {
272                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
273                            result
274                                .assertions_if_false
275                                .push((name, parse_type_string(&ty_str)));
276                        }
277                    }
278                }
279                "psalm-property" => {
280                    if let Some(body_str) = body_text(&tag.body) {
281                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
282                            result.properties.push(DocProperty {
283                                type_hint: ty_str,
284                                name,
285                                read_only: false,
286                                write_only: false,
287                            });
288                        }
289                    }
290                }
291                "psalm-property-read" => {
292                    if let Some(body_str) = body_text(&tag.body) {
293                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
294                            result.properties.push(DocProperty {
295                                type_hint: ty_str,
296                                name,
297                                read_only: true,
298                                write_only: false,
299                            });
300                        }
301                    }
302                }
303                "psalm-property-write" => {
304                    if let Some(body_str) = body_text(&tag.body) {
305                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
306                            result.properties.push(DocProperty {
307                                type_hint: ty_str,
308                                name,
309                                read_only: false,
310                                write_only: true,
311                            });
312                        }
313                    }
314                }
315                "psalm-require-extends" | "phpstan-require-extends" => {
316                    if let Some(body_str) = body_text(&tag.body) {
317                        let cls = body_str
318                            .split_whitespace()
319                            .next()
320                            .unwrap_or("")
321                            .trim()
322                            .to_string();
323                        if !cls.is_empty() {
324                            result.require_extends.push(cls);
325                        }
326                    }
327                }
328                "psalm-require-implements" | "phpstan-require-implements" => {
329                    if let Some(body_str) = body_text(&tag.body) {
330                        let cls = body_str
331                            .split_whitespace()
332                            .next()
333                            .unwrap_or("")
334                            .trim()
335                            .to_string();
336                        if !cls.is_empty() {
337                            result.require_implements.push(cls);
338                        }
339                    }
340                }
341                _ => {}
342            }
343        }
344
345        if text.to_ascii_lowercase().contains("{@inheritdoc}") {
346            result.is_inherit_doc = true;
347        }
348
349        result
350    }
351}
352
353// ---------------------------------------------------------------------------
354// ParsedDocblock support types
355// ---------------------------------------------------------------------------
356
357#[derive(Debug, Default, Clone)]
358pub struct DocProperty {
359    pub type_hint: String,
360    pub name: String,     // without leading $
361    pub read_only: bool,  // true for @property-read
362    pub write_only: bool, // true for @property-write
363}
364
365#[derive(Debug, Default, Clone)]
366pub struct DocMethod {
367    pub return_type: String,
368    pub name: String,
369    pub is_static: bool,
370    pub params: Vec<DocMethodParam>,
371}
372
373#[derive(Debug, Default, Clone)]
374pub struct DocMethodParam {
375    pub name: String,
376    pub type_hint: String,
377    pub is_variadic: bool,
378    pub is_byref: bool,
379    pub is_optional: bool,
380}
381
382#[derive(Debug, Default, Clone)]
383pub struct DocTypeAlias {
384    pub name: String,
385    pub type_expr: String,
386}
387
388#[derive(Debug, Default, Clone)]
389pub struct DocImportType {
390    /// The name exported by the source class (the original alias name).
391    pub original: String,
392    /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
393    pub local: String,
394    /// The FQCN of the class to import the type from.
395    pub from_class: String,
396}
397
398// ---------------------------------------------------------------------------
399// ParsedDocblock
400// ---------------------------------------------------------------------------
401
402#[derive(Debug, Default, Clone)]
403pub struct ParsedDocblock {
404    /// `@param Type $name`
405    pub params: Vec<(String, Union)>,
406    /// `@return Type`
407    pub return_type: Option<Union>,
408    /// `@var Type` or `@var Type $name` — type and optional variable name
409    pub var_type: Option<Union>,
410    /// Optional variable name from `@var Type $name`
411    pub var_name: Option<String>,
412    /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
413    pub templates: Vec<(String, Option<Union>, Variance)>,
414    /// `@extends ClassName<T>`
415    pub extends: Option<Union>,
416    /// `@implements InterfaceName<T>`
417    pub implements: Vec<Union>,
418    /// `@throws ClassName`
419    pub throws: Vec<String>,
420    /// `@psalm-assert Type $var`
421    pub assertions: Vec<(String, Union)>,
422    /// `@psalm-assert-if-true Type $var`
423    pub assertions_if_true: Vec<(String, Union)>,
424    /// `@psalm-assert-if-false Type $var`
425    pub assertions_if_false: Vec<(String, Union)>,
426    /// `@psalm-suppress IssueName`
427    pub suppressed_issues: Vec<String>,
428    pub is_deprecated: bool,
429    pub is_internal: bool,
430    pub is_pure: bool,
431    pub is_immutable: bool,
432    pub is_readonly: bool,
433    pub is_api: bool,
434    /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
435    /// inherited from the nearest ancestor that has a real docblock.
436    pub is_inherit_doc: bool,
437    /// Free text before first `@` tag — used for hover display
438    pub description: String,
439    /// `@deprecated message` — Some(message) or Some("") if no message
440    pub deprecated: Option<String>,
441    /// `@see ClassName` / `@link URL`
442    pub see: Vec<String>,
443    /// `@mixin ClassName`
444    pub mixins: Vec<String>,
445    /// `@property`, `@property-read`, `@property-write`
446    pub properties: Vec<DocProperty>,
447    /// `@method [static] ReturnType name([params])`
448    pub methods: Vec<DocMethod>,
449    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
450    pub type_aliases: Vec<DocTypeAlias>,
451    /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
452    pub import_types: Vec<DocImportType>,
453    /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
454    pub require_extends: Vec<String>,
455    /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
456    pub require_implements: Vec<String>,
457    /// `@since X.Y` — first PHP version this symbol exists in.
458    pub since: Option<String>,
459    /// `@removed X.Y` — first PHP version this symbol no longer exists in.
460    pub removed: Option<String>,
461    /// Malformed type annotations detected during parsing.
462    pub invalid_annotations: Vec<String>,
463}
464
465impl ParsedDocblock {
466    /// Returns the type for a given parameter name (strips leading `$`).
467    ///
468    /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
469    /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
470    /// preceding plain `@param` annotation.
471    pub fn get_param_type(&self, name: &str) -> Option<&Union> {
472        let name = name.trim_start_matches('$');
473        self.params
474            .iter()
475            .rfind(|(n, _)| n.trim_start_matches('$') == name)
476            .map(|(_, ty)| ty)
477    }
478}
479
480// ---------------------------------------------------------------------------
481// Type string parser
482// ---------------------------------------------------------------------------
483
484/// Parse a PHPDoc type expression string into a `Union`.
485/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
486/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
487pub fn parse_type_string(s: &str) -> Union {
488    let s = s.trim();
489
490    // Nullable shorthand: `?Type`
491    if let Some(inner) = s.strip_prefix('?') {
492        let inner_ty = parse_type_string(inner);
493        let mut u = inner_ty;
494        u.add_type(Atomic::TNull);
495        return u;
496    }
497
498    // Conditional type: `($param is TypeName ? TrueType : FalseType)`
499    if s.starts_with('(') && s.ends_with(')') {
500        let inner = s[1..s.len() - 1].trim();
501        if let Some(conditional) = parse_conditional_type(inner) {
502            return conditional;
503        }
504    }
505
506    // Union: `A|B|C`
507    if s.contains('|') && !is_inside_generics(s) {
508        let parts = split_union(s);
509        if parts.len() > 1 {
510            let mut u = Union::empty();
511            for part in parts {
512                for atomic in parse_type_string(&part).types {
513                    u.add_type(atomic);
514                }
515            }
516            return u;
517        }
518    }
519
520    // Intersection: `A&B&C` — PHP 8.1+ pure intersection type
521    if s.contains('&') && !is_inside_generics(s) {
522        let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
523        return Union::single(Atomic::TIntersection { parts });
524    }
525
526    // Array shorthand: `Type[]` or `Type[][]`
527    if let Some(value_str) = s.strip_suffix("[]") {
528        let value = parse_type_string(value_str);
529        return Union::single(Atomic::TArray {
530            key: Box::new(Union::single(Atomic::TInt)),
531            value: Box::new(value),
532        });
533    }
534
535    // Callable/closure syntax: `Closure(T): R` or `callable(T): R`
536    if let Some(call_ty) = parse_callable_syntax(s) {
537        return call_ty;
538    }
539
540    // Array shape: `array{key: Type, ...}` or `list{Type, ...}`
541    if s.ends_with('}') {
542        if let Some(open) = s.find('{') {
543            let prefix = s[..open].to_lowercase();
544            let inner = &s[open + 1..s.len() - 1];
545            if prefix == "array" {
546                return parse_keyed_array(inner, false);
547            } else if prefix == "list" {
548                return parse_keyed_array(inner, true);
549            }
550        }
551    }
552
553    // Generic: `name<...>`
554    if let Some(open) = s.find('<') {
555        if s.ends_with('>') {
556            let name = &s[..open];
557            let inner = &s[open + 1..s.len() - 1];
558            return parse_generic(name, inner);
559        }
560    }
561
562    // Keywords
563    match s.to_lowercase().as_str() {
564        "string" => Union::single(Atomic::TString),
565        "non-empty-string" => Union::single(Atomic::TNonEmptyString),
566        "numeric-string" => Union::single(Atomic::TNumericString),
567        "class-string" => Union::single(Atomic::TClassString(None)),
568        "int" | "integer" => Union::single(Atomic::TInt),
569        "positive-int" => Union::single(Atomic::TPositiveInt),
570        "negative-int" => Union::single(Atomic::TNegativeInt),
571        "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
572        "float" | "double" => Union::single(Atomic::TFloat),
573        "bool" | "boolean" => Union::single(Atomic::TBool),
574        "true" => Union::single(Atomic::TTrue),
575        "false" => Union::single(Atomic::TFalse),
576        "null" => Union::single(Atomic::TNull),
577        "void" => Union::single(Atomic::TVoid),
578        "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
579        "mixed" => Union::single(Atomic::TMixed),
580        "object" => Union::single(Atomic::TObject),
581        "array" => Union::single(Atomic::TArray {
582            key: Box::new(Union::single(Atomic::TMixed)),
583            value: Box::new(Union::mixed()),
584        }),
585        "list" => Union::single(Atomic::TList {
586            value: Box::new(Union::mixed()),
587        }),
588        "callable" => Union::single(Atomic::TCallable {
589            params: None,
590            return_type: None,
591        }),
592        "iterable" => Union::single(Atomic::TArray {
593            key: Box::new(Union::single(Atomic::TMixed)),
594            value: Box::new(Union::mixed()),
595        }),
596        "scalar" => Union::single(Atomic::TScalar),
597        "numeric" => Union::single(Atomic::TNumeric),
598        "resource" => Union::mixed(), // treat as mixed
599        // self/static/parent: emit sentinel with empty FQCN; collector fills it in.
600        "static" => Union::single(Atomic::TStaticObject {
601            fqcn: Arc::from(""),
602        }),
603        "self" | "$this" => Union::single(Atomic::TSelf {
604            fqcn: Arc::from(""),
605        }),
606        "parent" => Union::single(Atomic::TParent {
607            fqcn: Arc::from(""),
608        }),
609
610        // Named class
611        _ if !s.is_empty()
612            && s.chars()
613                .next()
614                .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
615                .unwrap_or(false) =>
616        {
617            // Integer literal: `1`, `-42`, `0` etc.
618            if let Ok(n) = s.parse::<i64>() {
619                return Union::single(Atomic::TLiteralInt(n));
620            }
621            Union::single(Atomic::TNamedObject {
622                fqcn: normalize_fqcn(s).into(),
623                type_params: vec![],
624            })
625        }
626
627        // Negative integer literal: `-1`, `-42` — starts with `-`, not caught by alphanumeric check
628        _ if s.starts_with('-') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) => {
629            if let Ok(n) = s.parse::<i64>() {
630                Union::single(Atomic::TLiteralInt(n))
631            } else {
632                Union::mixed()
633            }
634        }
635
636        // String literal: `'foo'` or `"bar"`
637        _ if (s.starts_with('\'') && s.ends_with('\''))
638            || (s.starts_with('"') && s.ends_with('"')) =>
639        {
640            let inner = &s[1..s.len() - 1];
641            Union::single(Atomic::TLiteralString(Arc::from(inner)))
642        }
643
644        _ => Union::mixed(),
645    }
646}
647
648fn parse_generic(name: &str, inner: &str) -> Union {
649    match name.to_lowercase().as_str() {
650        "array" => {
651            let params = split_generics(inner);
652            let (key, value) = match params.len() {
653                n if n >= 2 => (
654                    parse_type_string(params[0].trim()),
655                    parse_type_string(params[1].trim()),
656                ),
657                1 => (
658                    Union::single(Atomic::TInt),
659                    parse_type_string(params[0].trim()),
660                ),
661                _ => (Union::single(Atomic::TInt), Union::mixed()),
662            };
663            Union::single(Atomic::TArray {
664                key: Box::new(key),
665                value: Box::new(value),
666            })
667        }
668        "list" | "non-empty-list" => {
669            let value = parse_type_string(inner.trim());
670            if name.to_lowercase().starts_with("non-empty") {
671                Union::single(Atomic::TNonEmptyList {
672                    value: Box::new(value),
673                })
674            } else {
675                Union::single(Atomic::TList {
676                    value: Box::new(value),
677                })
678            }
679        }
680        "non-empty-array" => {
681            let params = split_generics(inner);
682            let (key, value) = match params.len() {
683                n if n >= 2 => (
684                    parse_type_string(params[0].trim()),
685                    parse_type_string(params[1].trim()),
686                ),
687                1 => (
688                    Union::single(Atomic::TInt),
689                    parse_type_string(params[0].trim()),
690                ),
691                _ => (Union::single(Atomic::TInt), Union::mixed()),
692            };
693            Union::single(Atomic::TNonEmptyArray {
694                key: Box::new(key),
695                value: Box::new(value),
696            })
697        }
698        "iterable" => {
699            let params = split_generics(inner);
700            let value = match params.len() {
701                n if n >= 2 => parse_type_string(params[1].trim()),
702                1 => parse_type_string(params[0].trim()),
703                _ => Union::mixed(),
704            };
705            Union::single(Atomic::TArray {
706                key: Box::new(Union::single(Atomic::TMixed)),
707                value: Box::new(value),
708            })
709        }
710        "class-string" => Union::single(Atomic::TClassString(Some(
711            normalize_fqcn(inner.trim()).into(),
712        ))),
713        "int" => {
714            // int<min, max>
715            Union::single(Atomic::TIntRange {
716                min: None,
717                max: None,
718            })
719        }
720        // Named class with type params
721        _ => {
722            let params: Vec<Union> = split_generics(inner)
723                .iter()
724                .map(|p| parse_type_string(p.trim()))
725                .collect();
726            Union::single(Atomic::TNamedObject {
727                fqcn: normalize_fqcn(name).into(),
728                type_params: params,
729            })
730        }
731    }
732}
733
734fn parse_keyed_array(inner: &str, is_list: bool) -> Union {
735    use mir_types::atomic::KeyedProperty;
736    let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
737    let mut is_open = false;
738    let mut auto_index = 0i64;
739
740    for item in split_generics(inner) {
741        let item = item.trim();
742        if item.is_empty() {
743            continue;
744        }
745        if item == "..." {
746            is_open = true;
747            continue;
748        }
749        // Find a colon that is not inside nested generics/braces
750        let colon_pos = {
751            let mut depth = 0i32;
752            let mut found = None;
753            for (i, ch) in item.char_indices() {
754                match ch {
755                    '<' | '(' | '{' => depth += 1,
756                    '>' | ')' | '}' => depth -= 1,
757                    ':' if depth == 0 => {
758                        found = Some(i);
759                        break;
760                    }
761                    _ => {}
762                }
763            }
764            found
765        };
766        if let Some(colon) = colon_pos {
767            let key_part = item[..colon].trim();
768            let ty_part = item[colon + 1..].trim();
769            let optional = key_part.ends_with('?');
770            let key_str = key_part.trim_end_matches('?').trim();
771            let key = if let Ok(n) = key_str.parse::<i64>() {
772                ArrayKey::Int(n)
773            } else {
774                ArrayKey::String(Arc::from(key_str))
775            };
776            properties.insert(
777                key,
778                KeyedProperty {
779                    ty: parse_type_string(ty_part),
780                    optional,
781                },
782            );
783        } else {
784            properties.insert(
785                ArrayKey::Int(auto_index),
786                KeyedProperty {
787                    ty: parse_type_string(item),
788                    optional: false,
789                },
790            );
791            auto_index += 1;
792        }
793    }
794
795    Union::single(Atomic::TKeyedArray {
796        properties,
797        is_open,
798        is_list,
799    })
800}
801
802fn parse_callable_syntax(s: &str) -> Option<Union> {
803    let s = s.trim_start_matches('\\');
804    let lower = s.to_lowercase();
805    let is_closure = lower.starts_with("closure");
806    let is_callable = lower.starts_with("callable");
807    if !is_closure && !is_callable {
808        return None;
809    }
810    let prefix_len = if is_closure {
811        "closure".len()
812    } else {
813        "callable".len()
814    };
815    let rest = s[prefix_len..].trim_start();
816    if !rest.starts_with('(') {
817        return None;
818    }
819    let close = find_matching_paren(rest)?;
820    let params_str = &rest[1..close];
821    let after = rest[close + 1..].trim();
822    let return_type = after
823        .strip_prefix(':')
824        .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
825    let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
826        .into_iter()
827        .enumerate()
828        .filter(|(_, p)| !p.trim().is_empty())
829        .map(|(i, p)| {
830            let p = p.trim();
831            let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
832                (p[..dollar].trim(), p[dollar + 1..].to_string())
833            } else {
834                (p, format!("arg{i}"))
835            };
836            mir_types::atomic::FnParam {
837                name: name.into(),
838                ty: Some(mir_types::SimpleType::from_union(parse_type_string(ty_str))),
839                default: None,
840                is_variadic: false,
841                is_byref: false,
842                is_optional: false,
843            }
844        })
845        .collect();
846    if is_closure {
847        Some(Union::single(Atomic::TClosure {
848            params,
849            return_type: return_type.unwrap_or_else(|| Box::new(Union::single(Atomic::TVoid))),
850            this_type: None,
851        }))
852    } else {
853        Some(Union::single(Atomic::TCallable {
854            params: Some(params),
855            return_type,
856        }))
857    }
858}
859
860fn find_matching_paren(s: &str) -> Option<usize> {
861    if !s.starts_with('(') {
862        return None;
863    }
864    let mut depth = 0i32;
865    for (i, ch) in s.char_indices() {
866        match ch {
867            '(' | '<' | '{' => depth += 1,
868            ')' | '>' | '}' => {
869                depth -= 1;
870                if depth == 0 {
871                    return Some(i);
872                }
873            }
874            _ => {}
875        }
876    }
877    None
878}
879
880// ---------------------------------------------------------------------------
881// Helpers
882// ---------------------------------------------------------------------------
883
884/// Parse template tag format: `T`, `T of Bound`, or `T as Bound`
885fn parse_template_line(_tag_name: &str, body: Option<String>) -> Option<(String, Option<String>)> {
886    let body = body?;
887    if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
888        Some((name.trim().to_string(), Some(bound.trim().to_string())))
889    } else {
890        Some((body.trim().to_string(), None))
891    }
892}
893
894/// Extract the description text (all prose before the first `@` tag) from a raw docblock.
895fn extract_description(text: &str) -> String {
896    let mut desc_lines: Vec<&str> = Vec::new();
897    for line in text.lines() {
898        let l = line.trim();
899        let l = l.trim_start_matches("/**").trim();
900        let l = l.trim_end_matches("*/").trim();
901        let l = l.trim_start_matches("*/").trim();
902        let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
903        let l = l.trim();
904        if l.starts_with('@') {
905            break;
906        }
907        if !l.is_empty() {
908            desc_lines.push(l);
909        }
910    }
911    desc_lines.join(" ")
912}
913
914/// Parse `@psalm-import-type` body.
915///
916/// Formats:
917/// - `AliasName from SourceClass`
918/// - `AliasName as LocalAlias from SourceClass`
919fn parse_import_type(body: &str) -> Option<DocImportType> {
920    // Split on " from " (with spaces to avoid matching partial words)
921    let (before_from, from_class_raw) = body.split_once(" from ")?;
922    let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
923    if from_class.is_empty() {
924        return None;
925    }
926    // Check for " as " in before_from
927    let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
928        (orig.trim().to_string(), loc.trim().to_string())
929    } else {
930        let name = before_from.trim().to_string();
931        (name.clone(), name)
932    };
933    if original.is_empty() || local.is_empty() {
934        return None;
935    }
936    Some(DocImportType {
937        original,
938        local,
939        from_class,
940    })
941}
942
943fn parse_param_line(s: &str) -> Option<(String, String)> {
944    // Formats: `Type $name`, `Type $name description`
945    // Types can contain spaces (e.g., `array<string, int>`), so we need to find the variable name.
946    // The variable name is the `$identifier` that comes after whitespace (not part of type syntax).
947
948    // Strategy: find the last sequence of whitespace followed by `$identifier`
949    // This handles both simple types and types with generics/spaces.
950    let mut best_split: Option<(String, String)> = None;
951
952    for (i, ch) in s.char_indices() {
953        if ch.is_whitespace() {
954            // Found whitespace; check what comes after it
955            let after = &s[i..].trim_start();
956            if after.starts_with('$') {
957                // Found a `$` after whitespace
958                let mut var_parts = after.split(char::is_whitespace);
959                if let Some(name_with_dollar) = var_parts.next() {
960                    let name = name_with_dollar.trim_start_matches('$').to_string();
961                    if !name.is_empty() {
962                        let type_part = s[..i].trim().to_string();
963                        if !type_part.is_empty() {
964                            // Keep this as a candidate; if there are more, the last one wins
965                            best_split = Some((type_part, name));
966                        }
967                    }
968                }
969            }
970        }
971    }
972
973    best_split
974}
975
976fn extract_return_type(s: &str) -> String {
977    // Extract just the type portion from a @return tag body.
978    // Format: `Type [description...]`
979    // Types can contain generics <>, unions |, intersections &, but descriptions are
980    // separated by whitespace after the type token.
981    // Example: `bool true if var is of type string` -> `bool`
982    // Example: `array<string, int> an associative array` -> `array<string, int>`
983    // Example: `\Closure(): T description` -> `\Closure(): T`
984
985    let mut depth: i32 = 0;
986    let mut current_token = String::new();
987
988    for ch in s.chars() {
989        match ch {
990            '<' | '(' | '{' => {
991                depth += 1;
992                current_token.push(ch);
993            }
994            '>' | ')' | '}' => {
995                depth = (depth - 1).max(0);
996                current_token.push(ch);
997            }
998            _ if ch.is_whitespace() && depth == 0 => {
999                break;
1000            }
1001            _ => {
1002                current_token.push(ch);
1003            }
1004        }
1005    }
1006
1007    // Callable return type syntax: `\Closure(): T` — the token ends with ':'
1008    // because the space between ':' and 'T' caused an early stop. Append the
1009    // return-type token that follows.
1010    if current_token.ends_with(':') {
1011        let offset = current_token.len();
1012        let rest = s[offset..].trim_start();
1013        if !rest.is_empty() {
1014            let ret_type = extract_return_type(rest);
1015            current_token.push_str(&ret_type);
1016        }
1017    }
1018
1019    current_token.trim().to_string()
1020}
1021
1022fn split_union(s: &str) -> Vec<String> {
1023    let mut parts = Vec::new();
1024    let mut depth = 0;
1025    let mut current = String::new();
1026    for ch in s.chars() {
1027        match ch {
1028            '<' | '(' | '{' => {
1029                depth += 1;
1030                current.push(ch);
1031            }
1032            '>' | ')' | '}' => {
1033                depth -= 1;
1034                current.push(ch);
1035            }
1036            '|' if depth == 0 => {
1037                parts.push(current.trim().to_string());
1038                current = String::new();
1039            }
1040            _ => current.push(ch),
1041        }
1042    }
1043    if !current.trim().is_empty() {
1044        parts.push(current.trim().to_string());
1045    }
1046    parts
1047}
1048
1049fn split_generics(s: &str) -> Vec<String> {
1050    let mut parts = Vec::new();
1051    let mut depth = 0;
1052    let mut current = String::new();
1053    for ch in s.chars() {
1054        match ch {
1055            '<' | '(' | '{' => {
1056                depth += 1;
1057                current.push(ch);
1058            }
1059            '>' | ')' | '}' => {
1060                depth -= 1;
1061                current.push(ch);
1062            }
1063            ',' if depth == 0 => {
1064                parts.push(current.trim().to_string());
1065                current = String::new();
1066            }
1067            _ => current.push(ch),
1068        }
1069    }
1070    if !current.trim().is_empty() {
1071        parts.push(current.trim().to_string());
1072    }
1073    parts
1074}
1075
1076fn is_inside_generics(s: &str) -> bool {
1077    let mut depth = 0i32;
1078    for ch in s.chars() {
1079        match ch {
1080            '<' | '(' | '{' => depth += 1,
1081            '>' | ')' | '}' => depth -= 1,
1082            _ => {}
1083        }
1084    }
1085    depth != 0
1086}
1087
1088/// Parses `$param is TypeName ? TrueType : FalseType` into a `TConditional`.
1089fn parse_conditional_type(s: &str) -> Option<Union> {
1090    if !s.starts_with('$') {
1091        return None;
1092    }
1093    let is_pos = s.find(" is ")?;
1094    let after_is = s[is_pos + 4..].trim();
1095    let q_pos = find_char_at_depth(after_is, '?')?;
1096    let subject_str = after_is[..q_pos].trim();
1097    let rest = after_is[q_pos + 1..].trim();
1098    let colon_pos = find_char_at_depth(rest, ':')?;
1099    let true_str = rest[..colon_pos].trim();
1100    let false_str = rest[colon_pos + 1..].trim();
1101    Some(Union::single(Atomic::TConditional {
1102        subject: Box::new(parse_type_string(subject_str)),
1103        if_true: Box::new(parse_type_string(true_str)),
1104        if_false: Box::new(parse_type_string(false_str)),
1105    }))
1106}
1107
1108/// Finds `target` in `s` at nesting depth 0 (not inside `<>`, `()`, `{}`).
1109fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1110    let mut depth = 0i32;
1111    for (i, ch) in s.char_indices() {
1112        match ch {
1113            '<' | '(' | '{' => depth += 1,
1114            '>' | ')' | '}' => depth -= 1,
1115            _ if ch == target && depth == 0 => return Some(i),
1116            _ => {}
1117        }
1118    }
1119    None
1120}
1121
1122fn normalize_fqcn(s: &str) -> String {
1123    // Strip leading backslash if present — we normalize all FQCNs without leading `\`
1124    s.trim_start_matches('\\').to_string()
1125}
1126
1127/// Returns an error message if `s` is a malformed PHPDoc type expression, otherwise `None`.
1128///
1129/// Detects:
1130/// - unclosed generics (`array<`, `Foo<Bar`)
1131/// - `$variable` in type position (only `$this` is valid)
1132fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1133    let s = s.trim();
1134    if s.is_empty() {
1135        return None;
1136    }
1137    if is_inside_generics(s) {
1138        return Some(format!("@{tag} has unclosed generic type `{s}`"));
1139    }
1140    // Skip empty generics check for callable/closure types (e.g., `callable(): T`, `\Closure(): T`)
1141    let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1142    if !is_callable_type && has_empty_generics(s) {
1143        return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1144    }
1145    for part in split_union(s) {
1146        let p = part.trim();
1147        if p.starts_with('$') && p != "$this" {
1148            return Some(format!("@{tag} contains variable `{p}` in type position"));
1149        }
1150    }
1151    None
1152}
1153
1154fn has_empty_generics(s: &str) -> bool {
1155    let mut depth = 0;
1156    let mut prev_open = false;
1157    for ch in s.chars() {
1158        match ch {
1159            '<' | '(' | '{' => {
1160                if prev_open && depth == 0 {
1161                    return true;
1162                }
1163                prev_open = true;
1164                depth += 1;
1165            }
1166            '>' | ')' | '}' => {
1167                depth -= 1;
1168                if depth == 0 {
1169                    if prev_open {
1170                        return true;
1171                    }
1172                    prev_open = false;
1173                }
1174            }
1175            c if !c.is_whitespace() => {
1176                prev_open = false;
1177            }
1178            _ => {}
1179        }
1180    }
1181    false
1182}
1183
1184/// Parse `[static] [ReturnType] name(...)` for @method tags.
1185fn parse_method_line(s: &str) -> Option<DocMethod> {
1186    let mut rest = s.trim();
1187    if rest.is_empty() {
1188        return None;
1189    }
1190    let is_static = rest
1191        .split_whitespace()
1192        .next()
1193        .map(|w| w.eq_ignore_ascii_case("static"))
1194        .unwrap_or(false);
1195    if is_static {
1196        rest = rest["static".len()..].trim_start();
1197    }
1198
1199    let open = rest.find('(').unwrap_or(rest.len());
1200    let prefix = rest[..open].trim();
1201    let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1202    let name = parts.pop()?.to_string();
1203    if name.is_empty() {
1204        return None;
1205    }
1206    let return_type = parts.join(" ");
1207    Some(DocMethod {
1208        return_type,
1209        name,
1210        is_static,
1211        params: parse_method_params(rest),
1212    })
1213}
1214
1215fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1216    let Some(open) = name_part.find('(') else {
1217        return vec![];
1218    };
1219    let Some(close) = name_part.rfind(')') else {
1220        return vec![];
1221    };
1222    let inner = name_part[open + 1..close].trim();
1223    if inner.is_empty() {
1224        return vec![];
1225    }
1226
1227    split_generics(inner)
1228        .into_iter()
1229        .filter_map(|param| parse_method_param(&param))
1230        .collect()
1231}
1232
1233fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1234    let before_default = param.split('=').next()?.trim();
1235    let is_optional = param.contains('=');
1236    let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1237    let raw_name = tokens.pop()?;
1238    let is_variadic = raw_name.contains("...");
1239    let is_byref = raw_name.contains('&');
1240    let name = raw_name
1241        .trim_start_matches('&')
1242        .trim_start_matches("...")
1243        .trim_start_matches('&')
1244        .trim_start_matches('$')
1245        .to_string();
1246    if name.is_empty() {
1247        return None;
1248    }
1249    Some(DocMethodParam {
1250        name,
1251        type_hint: tokens.join(" "),
1252        is_variadic,
1253        is_byref,
1254        is_optional: is_optional || is_variadic,
1255    })
1256}
1257
1258// ---------------------------------------------------------------------------
1259// Tests
1260// ---------------------------------------------------------------------------
1261
1262#[cfg(test)]
1263mod tests {
1264    use super::*;
1265    use mir_types::Atomic;
1266
1267    #[test]
1268    fn parse_string() {
1269        let u = parse_type_string("string");
1270        assert_eq!(u.types.len(), 1);
1271        assert!(matches!(u.types[0], Atomic::TString));
1272    }
1273
1274    #[test]
1275    fn parse_nullable_string() {
1276        let u = parse_type_string("?string");
1277        assert!(u.is_nullable());
1278        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1279    }
1280
1281    #[test]
1282    fn parse_union() {
1283        let u = parse_type_string("string|int|null");
1284        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1285        assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1286        assert!(u.is_nullable());
1287    }
1288
1289    #[test]
1290    fn parse_array_of_string() {
1291        let u = parse_type_string("array<string>");
1292        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1293    }
1294
1295    #[test]
1296    fn parse_list_of_int() {
1297        let u = parse_type_string("list<int>");
1298        assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1299    }
1300
1301    #[test]
1302    fn parse_named_class() {
1303        let u = parse_type_string("Foo\\Bar");
1304        assert!(u.contains(
1305            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1306        ));
1307    }
1308
1309    #[test]
1310    fn parse_docblock_param_return() {
1311        let doc = r#"/**
1312         * @param string $name
1313         * @param int $age
1314         * @return bool
1315         */"#;
1316        let parsed = DocblockParser::parse(doc);
1317        assert_eq!(parsed.params.len(), 2);
1318        assert!(parsed.return_type.is_some());
1319        let ret = parsed.return_type.unwrap();
1320        assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1321    }
1322
1323    #[test]
1324    fn parse_template() {
1325        let doc = "/** @template T of object */";
1326        let parsed = DocblockParser::parse(doc);
1327        assert_eq!(parsed.templates.len(), 1);
1328        assert_eq!(parsed.templates[0].0, "T");
1329        assert!(parsed.templates[0].1.is_some());
1330        assert_eq!(parsed.templates[0].2, Variance::Invariant);
1331    }
1332
1333    #[test]
1334    fn parse_template_covariant() {
1335        let doc = "/** @template-covariant T */";
1336        let parsed = DocblockParser::parse(doc);
1337        assert_eq!(parsed.templates.len(), 1);
1338        assert_eq!(parsed.templates[0].0, "T");
1339        assert_eq!(parsed.templates[0].2, Variance::Covariant);
1340    }
1341
1342    #[test]
1343    fn parse_template_contravariant() {
1344        let doc = "/** @template-contravariant T */";
1345        let parsed = DocblockParser::parse(doc);
1346        assert_eq!(parsed.templates.len(), 1);
1347        assert_eq!(parsed.templates[0].0, "T");
1348        assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1349    }
1350
1351    #[test]
1352    fn parse_deprecated() {
1353        let doc = "/** @deprecated use newMethod() instead */";
1354        let parsed = DocblockParser::parse(doc);
1355        assert!(parsed.is_deprecated);
1356        assert_eq!(
1357            parsed.deprecated.as_deref(),
1358            Some("use newMethod() instead")
1359        );
1360    }
1361
1362    #[test]
1363    fn parse_since_plain() {
1364        let parsed = DocblockParser::parse("/** @since 8.0 */");
1365        assert_eq!(parsed.since.as_deref(), Some("8.0"));
1366        assert_eq!(parsed.removed, None);
1367    }
1368
1369    #[test]
1370    fn parse_since_strips_trailing_description() {
1371        // phpstorm-stubs commonly writes `@since X.Y description text`.
1372        // Only the leading version token must reach the version parser.
1373        let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1374        assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1375    }
1376
1377    #[test]
1378    fn parse_removed_tag() {
1379        let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1380        assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1381    }
1382
1383    #[test]
1384    fn parse_since_empty_body_is_none() {
1385        let parsed = DocblockParser::parse("/** @since */");
1386        assert_eq!(parsed.since, None);
1387    }
1388
1389    #[test]
1390    fn parse_description() {
1391        let doc = r#"/**
1392         * This is a description.
1393         * Spans two lines.
1394         * @param string $x
1395         */"#;
1396        let parsed = DocblockParser::parse(doc);
1397        assert!(parsed.description.contains("This is a description"));
1398        assert!(parsed.description.contains("Spans two lines"));
1399    }
1400
1401    #[test]
1402    fn parse_see_and_link() {
1403        let doc = "/** @see SomeClass\n * @link https://example.com */";
1404        let parsed = DocblockParser::parse(doc);
1405        assert_eq!(parsed.see.len(), 2);
1406        assert!(parsed.see.contains(&"SomeClass".to_string()));
1407        assert!(parsed.see.contains(&"https://example.com".to_string()));
1408    }
1409
1410    #[test]
1411    fn parse_mixin() {
1412        let doc = "/** @mixin SomeTrait */";
1413        let parsed = DocblockParser::parse(doc);
1414        assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1415    }
1416
1417    #[test]
1418    fn parse_property_tags() {
1419        let doc = r#"/**
1420         * @property string $name
1421         * @property-read int $id
1422         * @property-write bool $active
1423         */"#;
1424        let parsed = DocblockParser::parse(doc);
1425        assert_eq!(parsed.properties.len(), 3);
1426        let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1427        assert_eq!(name_prop.type_hint, "string");
1428        assert!(!name_prop.read_only);
1429        assert!(!name_prop.write_only);
1430        let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1431        assert!(id_prop.read_only);
1432        let active_prop = parsed
1433            .properties
1434            .iter()
1435            .find(|p| p.name == "active")
1436            .unwrap();
1437        assert!(active_prop.write_only);
1438    }
1439
1440    #[test]
1441    fn parse_method_tag() {
1442        let doc = r#"/**
1443         * @method string getName()
1444         * @method static int create()
1445         */"#;
1446        let parsed = DocblockParser::parse(doc);
1447        assert_eq!(parsed.methods.len(), 2);
1448        let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1449        assert_eq!(get_name.return_type, "string");
1450        assert!(!get_name.is_static);
1451        let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1452        assert!(create.is_static);
1453    }
1454
1455    #[test]
1456    fn parse_type_alias_tag() {
1457        let doc = "/** @psalm-type MyAlias = string|int */";
1458        let parsed = DocblockParser::parse(doc);
1459        assert_eq!(parsed.type_aliases.len(), 1);
1460        assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1461        assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1462    }
1463
1464    #[test]
1465    fn parse_import_type_no_as() {
1466        let doc = "/** @psalm-import-type UserId from UserRepository */";
1467        let parsed = DocblockParser::parse(doc);
1468        assert_eq!(parsed.import_types.len(), 1);
1469        assert_eq!(parsed.import_types[0].original, "UserId");
1470        assert_eq!(parsed.import_types[0].local, "UserId");
1471        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1472    }
1473
1474    #[test]
1475    fn parse_import_type_with_as() {
1476        let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1477        let parsed = DocblockParser::parse(doc);
1478        assert_eq!(parsed.import_types.len(), 1);
1479        assert_eq!(parsed.import_types[0].original, "UserId");
1480        assert_eq!(parsed.import_types[0].local, "LocalId");
1481        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1482    }
1483
1484    #[test]
1485    fn parse_require_extends() {
1486        let doc = "/** @psalm-require-extends Model */";
1487        let parsed = DocblockParser::parse(doc);
1488        assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1489    }
1490
1491    #[test]
1492    fn parse_require_implements() {
1493        let doc = "/** @psalm-require-implements Countable */";
1494        let parsed = DocblockParser::parse(doc);
1495        assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1496    }
1497
1498    #[test]
1499    fn parse_intersection_two_parts() {
1500        let u = parse_type_string("Iterator&Countable");
1501        assert_eq!(u.types.len(), 1);
1502        assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1503        if let Atomic::TIntersection { parts } = &u.types[0] {
1504            assert!(parts[0].contains(
1505                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1506            ));
1507            assert!(parts[1].contains(
1508                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1509            ));
1510        }
1511    }
1512
1513    #[test]
1514    fn parse_intersection_three_parts() {
1515        let u = parse_type_string("Iterator&Countable&Stringable");
1516        assert_eq!(u.types.len(), 1);
1517        let Atomic::TIntersection { parts } = &u.types[0] else {
1518            panic!("expected TIntersection");
1519        };
1520        assert_eq!(parts.len(), 3);
1521        assert!(parts[0].contains(
1522            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1523        ));
1524        assert!(parts[1].contains(
1525            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1526        ));
1527        assert!(parts[2].contains(
1528            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1529        ));
1530    }
1531
1532    #[test]
1533    fn parse_intersection_in_union_with_null() {
1534        let u = parse_type_string("Iterator&Countable|null");
1535        assert!(u.is_nullable());
1536        let intersection = u
1537            .types
1538            .iter()
1539            .find_map(|t| {
1540                if let Atomic::TIntersection { parts } = t {
1541                    Some(parts)
1542                } else {
1543                    None
1544                }
1545            })
1546            .expect("expected TIntersection");
1547        assert_eq!(intersection.len(), 2);
1548        assert!(intersection[0].contains(
1549            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1550        ));
1551        assert!(intersection[1].contains(
1552            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1553        ));
1554    }
1555
1556    #[test]
1557    fn parse_intersection_in_union_with_scalar() {
1558        let u = parse_type_string("Iterator&Countable|string");
1559        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1560        let intersection = u
1561            .types
1562            .iter()
1563            .find_map(|t| {
1564                if let Atomic::TIntersection { parts } = t {
1565                    Some(parts)
1566                } else {
1567                    None
1568                }
1569            })
1570            .expect("expected TIntersection");
1571        assert!(intersection[0].contains(
1572            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1573        ));
1574        assert!(intersection[1].contains(
1575            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1576        ));
1577    }
1578
1579    #[test]
1580    fn validate_unclosed_generic_return() {
1581        let parsed = DocblockParser::parse("/** @return array< */");
1582        assert_eq!(parsed.invalid_annotations.len(), 1);
1583        assert!(
1584            parsed.invalid_annotations[0].contains("unclosed generic"),
1585            "got: {}",
1586            parsed.invalid_annotations[0]
1587        );
1588    }
1589
1590    #[test]
1591    fn parse_empty_generic_array_graceful() {
1592        let u = parse_type_string("array<>");
1593        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1594    }
1595
1596    #[test]
1597    fn parse_empty_generic_iterable_graceful() {
1598        let u = parse_type_string("iterable<>");
1599        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1600    }
1601
1602    #[test]
1603    fn parse_empty_generic_non_empty_array_graceful() {
1604        let u = parse_type_string("non-empty-array<>");
1605        assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
1606    }
1607
1608    #[test]
1609    fn validate_variable_in_type_position_param() {
1610        let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1611        assert_eq!(parsed.invalid_annotations.len(), 1);
1612        assert!(
1613            parsed.invalid_annotations[0].contains("$invalid"),
1614            "got: {}",
1615            parsed.invalid_annotations[0]
1616        );
1617    }
1618
1619    #[test]
1620    fn validate_this_is_valid_in_type_position() {
1621        let parsed = DocblockParser::parse("/** @return $this */");
1622        assert!(
1623            parsed.invalid_annotations.is_empty(),
1624            "unexpected error: {:?}",
1625            parsed.invalid_annotations
1626        );
1627    }
1628
1629    #[test]
1630    fn validate_unclosed_generic_var() {
1631        let parsed = DocblockParser::parse("/** @var array<string */");
1632        assert_eq!(parsed.invalid_annotations.len(), 1);
1633        assert!(parsed.invalid_annotations[0].contains("@var"));
1634    }
1635
1636    #[test]
1637    fn validate_variable_in_template_bound() {
1638        let parsed = DocblockParser::parse("/** @template T of $invalid */");
1639        assert_eq!(parsed.invalid_annotations.len(), 1);
1640        assert!(parsed.invalid_annotations[0].contains("$invalid"));
1641    }
1642}