Skip to main content

mir_analyzer/parser/
docblock.rs

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