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) = if params.len() >= 2 {
587                (
588                    parse_type_string(params[0].trim()),
589                    parse_type_string(params[1].trim()),
590                )
591            } else {
592                (
593                    Union::single(Atomic::TInt),
594                    parse_type_string(params[0].trim()),
595                )
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) = if params.len() >= 2 {
617                (
618                    parse_type_string(params[0].trim()),
619                    parse_type_string(params[1].trim()),
620                )
621            } else {
622                (
623                    Union::single(Atomic::TInt),
624                    parse_type_string(params[0].trim()),
625                )
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 = if params.len() >= 2 {
635                parse_type_string(params[1].trim())
636            } else {
637                parse_type_string(params[0].trim())
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    for part in split_union(s) {
997        let p = part.trim();
998        if p.starts_with('$') && p != "$this" {
999            return Some(format!("@{tag} contains variable `{p}` in type position"));
1000        }
1001    }
1002    None
1003}
1004
1005/// Parse `[static] [ReturnType] name(...)` for @method tags.
1006fn parse_method_line(s: &str) -> Option<DocMethod> {
1007    let mut rest = s.trim();
1008    if rest.is_empty() {
1009        return None;
1010    }
1011    let is_static = rest
1012        .split_whitespace()
1013        .next()
1014        .map(|w| w.eq_ignore_ascii_case("static"))
1015        .unwrap_or(false);
1016    if is_static {
1017        rest = rest["static".len()..].trim_start();
1018    }
1019
1020    let open = rest.find('(').unwrap_or(rest.len());
1021    let prefix = rest[..open].trim();
1022    let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1023    let name = parts.pop()?.to_string();
1024    if name.is_empty() {
1025        return None;
1026    }
1027    let return_type = parts.join(" ");
1028    Some(DocMethod {
1029        return_type,
1030        name,
1031        is_static,
1032        params: parse_method_params(rest),
1033    })
1034}
1035
1036fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1037    let Some(open) = name_part.find('(') else {
1038        return vec![];
1039    };
1040    let Some(close) = name_part.rfind(')') else {
1041        return vec![];
1042    };
1043    let inner = name_part[open + 1..close].trim();
1044    if inner.is_empty() {
1045        return vec![];
1046    }
1047
1048    split_generics(inner)
1049        .into_iter()
1050        .filter_map(|param| parse_method_param(&param))
1051        .collect()
1052}
1053
1054fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1055    let before_default = param.split('=').next()?.trim();
1056    let is_optional = param.contains('=');
1057    let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1058    let raw_name = tokens.pop()?;
1059    let is_variadic = raw_name.contains("...");
1060    let is_byref = raw_name.contains('&');
1061    let name = raw_name
1062        .trim_start_matches('&')
1063        .trim_start_matches("...")
1064        .trim_start_matches('&')
1065        .trim_start_matches('$')
1066        .to_string();
1067    if name.is_empty() {
1068        return None;
1069    }
1070    Some(DocMethodParam {
1071        name,
1072        type_hint: tokens.join(" "),
1073        is_variadic,
1074        is_byref,
1075        is_optional: is_optional || is_variadic,
1076    })
1077}
1078
1079// ---------------------------------------------------------------------------
1080// Tests
1081// ---------------------------------------------------------------------------
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086    use mir_types::Atomic;
1087
1088    #[test]
1089    fn parse_string() {
1090        let u = parse_type_string("string");
1091        assert_eq!(u.types.len(), 1);
1092        assert!(matches!(u.types[0], Atomic::TString));
1093    }
1094
1095    #[test]
1096    fn parse_nullable_string() {
1097        let u = parse_type_string("?string");
1098        assert!(u.is_nullable());
1099        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1100    }
1101
1102    #[test]
1103    fn parse_union() {
1104        let u = parse_type_string("string|int|null");
1105        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1106        assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1107        assert!(u.is_nullable());
1108    }
1109
1110    #[test]
1111    fn parse_array_of_string() {
1112        let u = parse_type_string("array<string>");
1113        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1114    }
1115
1116    #[test]
1117    fn parse_list_of_int() {
1118        let u = parse_type_string("list<int>");
1119        assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1120    }
1121
1122    #[test]
1123    fn parse_named_class() {
1124        let u = parse_type_string("Foo\\Bar");
1125        assert!(u.contains(
1126            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1127        ));
1128    }
1129
1130    #[test]
1131    fn parse_docblock_param_return() {
1132        let doc = r#"/**
1133         * @param string $name
1134         * @param int $age
1135         * @return bool
1136         */"#;
1137        let parsed = DocblockParser::parse(doc);
1138        assert_eq!(parsed.params.len(), 2);
1139        assert!(parsed.return_type.is_some());
1140        let ret = parsed.return_type.unwrap();
1141        assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1142    }
1143
1144    #[test]
1145    fn parse_template() {
1146        let doc = "/** @template T of object */";
1147        let parsed = DocblockParser::parse(doc);
1148        assert_eq!(parsed.templates.len(), 1);
1149        assert_eq!(parsed.templates[0].0, "T");
1150        assert!(parsed.templates[0].1.is_some());
1151        assert_eq!(parsed.templates[0].2, Variance::Invariant);
1152    }
1153
1154    #[test]
1155    fn parse_template_covariant() {
1156        let doc = "/** @template-covariant T */";
1157        let parsed = DocblockParser::parse(doc);
1158        assert_eq!(parsed.templates.len(), 1);
1159        assert_eq!(parsed.templates[0].0, "T");
1160        assert_eq!(parsed.templates[0].2, Variance::Covariant);
1161    }
1162
1163    #[test]
1164    fn parse_template_contravariant() {
1165        let doc = "/** @template-contravariant T */";
1166        let parsed = DocblockParser::parse(doc);
1167        assert_eq!(parsed.templates.len(), 1);
1168        assert_eq!(parsed.templates[0].0, "T");
1169        assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1170    }
1171
1172    #[test]
1173    fn parse_deprecated() {
1174        let doc = "/** @deprecated use newMethod() instead */";
1175        let parsed = DocblockParser::parse(doc);
1176        assert!(parsed.is_deprecated);
1177        assert_eq!(
1178            parsed.deprecated.as_deref(),
1179            Some("use newMethod() instead")
1180        );
1181    }
1182
1183    #[test]
1184    fn parse_since_plain() {
1185        let parsed = DocblockParser::parse("/** @since 8.0 */");
1186        assert_eq!(parsed.since.as_deref(), Some("8.0"));
1187        assert_eq!(parsed.removed, None);
1188    }
1189
1190    #[test]
1191    fn parse_since_strips_trailing_description() {
1192        // phpstorm-stubs commonly writes `@since X.Y description text`.
1193        // Only the leading version token must reach the version parser.
1194        let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1195        assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1196    }
1197
1198    #[test]
1199    fn parse_removed_tag() {
1200        let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1201        assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1202    }
1203
1204    #[test]
1205    fn parse_since_empty_body_is_none() {
1206        let parsed = DocblockParser::parse("/** @since */");
1207        assert_eq!(parsed.since, None);
1208    }
1209
1210    #[test]
1211    fn parse_description() {
1212        let doc = r#"/**
1213         * This is a description.
1214         * Spans two lines.
1215         * @param string $x
1216         */"#;
1217        let parsed = DocblockParser::parse(doc);
1218        assert!(parsed.description.contains("This is a description"));
1219        assert!(parsed.description.contains("Spans two lines"));
1220    }
1221
1222    #[test]
1223    fn parse_see_and_link() {
1224        let doc = "/** @see SomeClass\n * @link https://example.com */";
1225        let parsed = DocblockParser::parse(doc);
1226        assert_eq!(parsed.see.len(), 2);
1227        assert!(parsed.see.contains(&"SomeClass".to_string()));
1228        assert!(parsed.see.contains(&"https://example.com".to_string()));
1229    }
1230
1231    #[test]
1232    fn parse_mixin() {
1233        let doc = "/** @mixin SomeTrait */";
1234        let parsed = DocblockParser::parse(doc);
1235        assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1236    }
1237
1238    #[test]
1239    fn parse_property_tags() {
1240        let doc = r#"/**
1241         * @property string $name
1242         * @property-read int $id
1243         * @property-write bool $active
1244         */"#;
1245        let parsed = DocblockParser::parse(doc);
1246        assert_eq!(parsed.properties.len(), 3);
1247        let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1248        assert_eq!(name_prop.type_hint, "string");
1249        assert!(!name_prop.read_only);
1250        assert!(!name_prop.write_only);
1251        let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1252        assert!(id_prop.read_only);
1253        let active_prop = parsed
1254            .properties
1255            .iter()
1256            .find(|p| p.name == "active")
1257            .unwrap();
1258        assert!(active_prop.write_only);
1259    }
1260
1261    #[test]
1262    fn parse_method_tag() {
1263        let doc = r#"/**
1264         * @method string getName()
1265         * @method static int create()
1266         */"#;
1267        let parsed = DocblockParser::parse(doc);
1268        assert_eq!(parsed.methods.len(), 2);
1269        let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1270        assert_eq!(get_name.return_type, "string");
1271        assert!(!get_name.is_static);
1272        let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1273        assert!(create.is_static);
1274    }
1275
1276    #[test]
1277    fn parse_type_alias_tag() {
1278        let doc = "/** @psalm-type MyAlias = string|int */";
1279        let parsed = DocblockParser::parse(doc);
1280        assert_eq!(parsed.type_aliases.len(), 1);
1281        assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1282        assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1283    }
1284
1285    #[test]
1286    fn parse_import_type_no_as() {
1287        let doc = "/** @psalm-import-type UserId from UserRepository */";
1288        let parsed = DocblockParser::parse(doc);
1289        assert_eq!(parsed.import_types.len(), 1);
1290        assert_eq!(parsed.import_types[0].original, "UserId");
1291        assert_eq!(parsed.import_types[0].local, "UserId");
1292        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1293    }
1294
1295    #[test]
1296    fn parse_import_type_with_as() {
1297        let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1298        let parsed = DocblockParser::parse(doc);
1299        assert_eq!(parsed.import_types.len(), 1);
1300        assert_eq!(parsed.import_types[0].original, "UserId");
1301        assert_eq!(parsed.import_types[0].local, "LocalId");
1302        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1303    }
1304
1305    #[test]
1306    fn parse_require_extends() {
1307        let doc = "/** @psalm-require-extends Model */";
1308        let parsed = DocblockParser::parse(doc);
1309        assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1310    }
1311
1312    #[test]
1313    fn parse_require_implements() {
1314        let doc = "/** @psalm-require-implements Countable */";
1315        let parsed = DocblockParser::parse(doc);
1316        assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1317    }
1318
1319    #[test]
1320    fn parse_intersection_two_parts() {
1321        let u = parse_type_string("Iterator&Countable");
1322        assert_eq!(u.types.len(), 1);
1323        assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1324        if let Atomic::TIntersection { parts } = &u.types[0] {
1325            assert!(parts[0].contains(
1326                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1327            ));
1328            assert!(parts[1].contains(
1329                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1330            ));
1331        }
1332    }
1333
1334    #[test]
1335    fn parse_intersection_three_parts() {
1336        let u = parse_type_string("Iterator&Countable&Stringable");
1337        assert_eq!(u.types.len(), 1);
1338        let Atomic::TIntersection { parts } = &u.types[0] else {
1339            panic!("expected TIntersection");
1340        };
1341        assert_eq!(parts.len(), 3);
1342        assert!(parts[0].contains(
1343            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1344        ));
1345        assert!(parts[1].contains(
1346            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1347        ));
1348        assert!(parts[2].contains(
1349            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1350        ));
1351    }
1352
1353    #[test]
1354    fn parse_intersection_in_union_with_null() {
1355        let u = parse_type_string("Iterator&Countable|null");
1356        assert!(u.is_nullable());
1357        let intersection = u
1358            .types
1359            .iter()
1360            .find_map(|t| {
1361                if let Atomic::TIntersection { parts } = t {
1362                    Some(parts)
1363                } else {
1364                    None
1365                }
1366            })
1367            .expect("expected TIntersection");
1368        assert_eq!(intersection.len(), 2);
1369        assert!(intersection[0].contains(
1370            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1371        ));
1372        assert!(intersection[1].contains(
1373            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1374        ));
1375    }
1376
1377    #[test]
1378    fn parse_intersection_in_union_with_scalar() {
1379        let u = parse_type_string("Iterator&Countable|string");
1380        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1381        let intersection = u
1382            .types
1383            .iter()
1384            .find_map(|t| {
1385                if let Atomic::TIntersection { parts } = t {
1386                    Some(parts)
1387                } else {
1388                    None
1389                }
1390            })
1391            .expect("expected TIntersection");
1392        assert!(intersection[0].contains(
1393            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1394        ));
1395        assert!(intersection[1].contains(
1396            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1397        ));
1398    }
1399
1400    #[test]
1401    fn validate_unclosed_generic_return() {
1402        let parsed = DocblockParser::parse("/** @return array< */");
1403        assert_eq!(parsed.invalid_annotations.len(), 1);
1404        assert!(
1405            parsed.invalid_annotations[0].contains("unclosed generic"),
1406            "got: {}",
1407            parsed.invalid_annotations[0]
1408        );
1409    }
1410
1411    #[test]
1412    fn validate_variable_in_type_position_param() {
1413        let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1414        assert_eq!(parsed.invalid_annotations.len(), 1);
1415        assert!(
1416            parsed.invalid_annotations[0].contains("$invalid"),
1417            "got: {}",
1418            parsed.invalid_annotations[0]
1419        );
1420    }
1421
1422    #[test]
1423    fn validate_this_is_valid_in_type_position() {
1424        let parsed = DocblockParser::parse("/** @return $this */");
1425        assert!(
1426            parsed.invalid_annotations.is_empty(),
1427            "unexpected error: {:?}",
1428            parsed.invalid_annotations
1429        );
1430    }
1431
1432    #[test]
1433    fn validate_unclosed_generic_var() {
1434        let parsed = DocblockParser::parse("/** @var array<string */");
1435        assert_eq!(parsed.invalid_annotations.len(), 1);
1436        assert!(parsed.invalid_annotations[0].contains("@var"));
1437    }
1438
1439    #[test]
1440    fn validate_variable_in_template_bound() {
1441        let parsed = DocblockParser::parse("/** @template T of $invalid */");
1442        assert_eq!(parsed.invalid_annotations.len(), 1);
1443        assert!(parsed.invalid_annotations[0].contains("$invalid"));
1444    }
1445}