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