Skip to main content

mir_analyzer/parser/
docblock.rs

1use mir_types::{ArrayKey, Atomic, Type, Variance};
2/// Docblock parser — delegates to `phpdoc_parser` for tag extraction,
3/// then converts tags into mir's `ParsedDocblock` with resolved types.
4use std::sync::Arc;
5
6use indexmap::IndexMap;
7use phpdoc_parser::{body_text, parse as parse_phpdoc};
8
9// ---------------------------------------------------------------------------
10// DocblockParser
11// ---------------------------------------------------------------------------
12
13pub struct DocblockParser;
14
15impl DocblockParser {
16    pub fn parse(text: &str) -> ParsedDocblock {
17        let doc = parse_phpdoc(text);
18        let mut result = ParsedDocblock {
19            description: extract_description(text),
20            ..Default::default()
21        };
22
23        for tag in &doc.tags {
24            match tag.name.as_str() {
25                "param" | "psalm-param" | "phpstan-param" => {
26                    if let Some(body_str) = body_text(&tag.body) {
27                        if let Some((ty_s, name)) = parse_param_line(&body_str) {
28                            // Check if the parsed type is valid
29                            if is_inside_generics(&ty_s) {
30                                // For unclosed generics, report the full body for context
31                                if let Some(msg) = validate_type_str(&body_str, "param") {
32                                    result.invalid_annotations.push(msg);
33                                }
34                            } else if let Some(msg) = validate_type_str(&ty_s, "param") {
35                                // For other errors, report the parsed type
36                                result.invalid_annotations.push(msg);
37                            } else {
38                                result.params.push((
39                                    name.trim_start_matches('$').to_string(),
40                                    parse_type_string(&ty_s),
41                                ));
42                            }
43                        } else if let Some(msg) = validate_type_str(&body_str, "param") {
44                            // If parsing failed, validate the full body to provide better error context
45                            result.invalid_annotations.push(msg);
46                        }
47                    }
48                }
49                "return" | "psalm-return" | "phpstan-return" => {
50                    if let Some(body_str) = body_text(&tag.body) {
51                        let ty_s = extract_return_type(&body_str);
52                        if let Some(msg) = validate_type_str(&ty_s, "return") {
53                            result.invalid_annotations.push(msg);
54                        }
55                        result.return_type = Some(parse_type_string(&ty_s));
56                    }
57                }
58                "var" => {
59                    if let Some(body_str) = body_text(&tag.body) {
60                        if let Some((ty_s, name)) = parse_param_line(&body_str) {
61                            if let Some(msg) = validate_type_str(&ty_s, "var") {
62                                result.invalid_annotations.push(msg);
63                            }
64                            result.var_type = Some(parse_type_string(&ty_s));
65                            result.var_name = Some(name.trim_start_matches('$').to_string());
66                        } else {
67                            // Spaces inside PHP types only appear within <…> generics.
68                            // Stop at top-level whitespace to exclude description text that
69                            // follows the type in multi-line @var bodies.
70                            let ty_s = extract_type_prefix(body_str.trim());
71                            if let Some(msg) = validate_type_str(ty_s, "var") {
72                                result.invalid_annotations.push(msg);
73                            }
74                            result.var_type = Some(parse_type_string(ty_s));
75                        }
76                    }
77                }
78                "throws" => {
79                    if let Some(body_str) = body_text(&tag.body) {
80                        let class = body_str.split_whitespace().next().unwrap_or("").to_string();
81                        if !class.is_empty() {
82                            result.throws.push(class);
83                        }
84                    }
85                }
86                "deprecated" => {
87                    result.is_deprecated = true;
88                    result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
89                }
90                "template" => {
91                    if let Some((name, bound)) =
92                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
93                    {
94                        if let Some(b) = &bound {
95                            if let Some(msg) = validate_type_str(b, "template") {
96                                result.invalid_annotations.push(msg);
97                            }
98                        }
99                        result.templates.push((
100                            name,
101                            bound.map(|b| parse_type_string(&b)),
102                            Variance::Invariant,
103                        ));
104                    }
105                }
106                "template-covariant" => {
107                    if let Some((name, bound)) =
108                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
109                    {
110                        if let Some(b) = &bound {
111                            if let Some(msg) = validate_type_str(b, "template-covariant") {
112                                result.invalid_annotations.push(msg);
113                            }
114                        }
115                        result.templates.push((
116                            name,
117                            bound.map(|b| parse_type_string(&b)),
118                            Variance::Covariant,
119                        ));
120                    }
121                }
122                "template-contravariant" => {
123                    if let Some((name, bound)) =
124                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
125                    {
126                        if let Some(b) = &bound {
127                            if let Some(msg) = validate_type_str(b, "template-contravariant") {
128                                result.invalid_annotations.push(msg);
129                            }
130                        }
131                        result.templates.push((
132                            name,
133                            bound.map(|b| parse_type_string(&b)),
134                            Variance::Contravariant,
135                        ));
136                    }
137                }
138                "extends" | "template-extends" | "phpstan-extends" => {
139                    if let Some(body_str) = body_text(&tag.body) {
140                        result.extends = Some(parse_type_string(body_str.trim()));
141                    }
142                }
143                "implements" | "template-implements" | "phpstan-implements" => {
144                    if let Some(body_str) = body_text(&tag.body) {
145                        result.implements.push(parse_type_string(body_str.trim()));
146                    }
147                }
148                "assert" | "psalm-assert" | "phpstan-assert" => {
149                    if let Some(body_str) = body_text(&tag.body) {
150                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
151                            result.assertions.push((name, parse_type_string(&ty_str)));
152                        }
153                    }
154                }
155                "suppress" | "psalm-suppress" => {
156                    if let Some(body_str) = body_text(&tag.body) {
157                        for rule in body_str.split([',', ' ']) {
158                            let rule = rule.trim().to_string();
159                            if !rule.is_empty() {
160                                result.suppressed_issues.push(rule);
161                            }
162                        }
163                    }
164                }
165                "see" => {
166                    if let Some(body_str) = body_text(&tag.body) {
167                        result.see.push(body_str.to_string());
168                    }
169                }
170                "link" => {
171                    if let Some(body_str) = body_text(&tag.body) {
172                        result.see.push(body_str.to_string());
173                    }
174                }
175                "mixin" => {
176                    if let Some(body_str) = body_text(&tag.body) {
177                        let base_class =
178                            body_str.split('<').next().unwrap_or(&body_str).to_string();
179                        result.mixins.push(base_class);
180                    }
181                }
182                "property" => {
183                    if let Some(body_str) = body_text(&tag.body) {
184                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
185                            result.properties.push(DocProperty {
186                                type_hint: ty_str,
187                                name: name.trim_start_matches('$').to_string(),
188                                read_only: false,
189                                write_only: false,
190                            });
191                        }
192                    }
193                }
194                "property-read" => {
195                    if let Some(body_str) = body_text(&tag.body) {
196                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
197                            result.properties.push(DocProperty {
198                                type_hint: ty_str,
199                                name: name.trim_start_matches('$').to_string(),
200                                read_only: true,
201                                write_only: false,
202                            });
203                        }
204                    }
205                }
206                "property-write" => {
207                    if let Some(body_str) = body_text(&tag.body) {
208                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
209                            result.properties.push(DocProperty {
210                                type_hint: ty_str,
211                                name: name.trim_start_matches('$').to_string(),
212                                read_only: false,
213                                write_only: true,
214                            });
215                        }
216                    }
217                }
218                "method" | "psalm-method" => {
219                    let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
220                    if let Some(err) = validate_method_body(&body_str) {
221                        result.invalid_annotations.push(err);
222                    } else if let Some(m) = parse_method_line(&body_str) {
223                        result.methods.push(m);
224                    }
225                }
226                "psalm-type" | "phpstan-type" => {
227                    if let Some(body_str) = body_text(&tag.body) {
228                        if let Some((name, type_expr)) = body_str.split_once('=') {
229                            result.type_aliases.push(DocTypeAlias {
230                                name: name.trim().to_string(),
231                                type_expr: type_expr.trim().to_string(),
232                            });
233                        }
234                    }
235                }
236                "psalm-import-type" | "phpstan-import-type" => {
237                    if let Some(body_str) = body_text(&tag.body) {
238                        if let Some(import) = parse_import_type(&body_str) {
239                            result.import_types.push(import);
240                        }
241                    }
242                }
243                "since" if result.since.is_none() => {
244                    if let Some(body_str) = body_text(&tag.body) {
245                        let v = body_str.split_whitespace().next().unwrap_or("");
246                        if !v.is_empty() {
247                            result.since = Some(v.to_string());
248                        }
249                    }
250                }
251                "removed" if result.removed.is_none() => {
252                    if let Some(body_str) = body_text(&tag.body) {
253                        let v = body_str.split_whitespace().next().unwrap_or("");
254                        if !v.is_empty() {
255                            result.removed = Some(v.to_string());
256                        }
257                    }
258                }
259                "internal" => result.is_internal = true,
260                "pure" => result.is_pure = true,
261                "immutable" => result.is_immutable = true,
262                "readonly" => result.is_readonly = true,
263                "final" => result.is_final = true,
264                "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
265                "api" | "psalm-api" => result.is_api = true,
266                "psalm-assert-if-true" | "phpstan-assert-if-true" => {
267                    if let Some(body_str) = body_text(&tag.body) {
268                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
269                            result
270                                .assertions_if_true
271                                .push((name, parse_type_string(&ty_str)));
272                        }
273                    }
274                }
275                "psalm-assert-if-false" | "phpstan-assert-if-false" => {
276                    if let Some(body_str) = body_text(&tag.body) {
277                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
278                            result
279                                .assertions_if_false
280                                .push((name, parse_type_string(&ty_str)));
281                        }
282                    }
283                }
284                "psalm-property" => {
285                    if let Some(body_str) = body_text(&tag.body) {
286                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
287                            result.properties.push(DocProperty {
288                                type_hint: ty_str,
289                                name,
290                                read_only: false,
291                                write_only: false,
292                            });
293                        }
294                    }
295                }
296                "psalm-property-read" => {
297                    if let Some(body_str) = body_text(&tag.body) {
298                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
299                            result.properties.push(DocProperty {
300                                type_hint: ty_str,
301                                name,
302                                read_only: true,
303                                write_only: false,
304                            });
305                        }
306                    }
307                }
308                "psalm-property-write" => {
309                    if let Some(body_str) = body_text(&tag.body) {
310                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
311                            result.properties.push(DocProperty {
312                                type_hint: ty_str,
313                                name,
314                                read_only: false,
315                                write_only: true,
316                            });
317                        }
318                    }
319                }
320                "psalm-require-extends" | "phpstan-require-extends" => {
321                    if let Some(body_str) = body_text(&tag.body) {
322                        let cls = body_str
323                            .split_whitespace()
324                            .next()
325                            .unwrap_or("")
326                            .trim()
327                            .to_string();
328                        if !cls.is_empty() {
329                            result.require_extends.push(cls);
330                        }
331                    }
332                }
333                "psalm-require-implements" | "phpstan-require-implements" => {
334                    if let Some(body_str) = body_text(&tag.body) {
335                        let cls = body_str
336                            .split_whitespace()
337                            .next()
338                            .unwrap_or("")
339                            .trim()
340                            .to_string();
341                        if !cls.is_empty() {
342                            result.require_implements.push(cls);
343                        }
344                    }
345                }
346                "mir-check" => {
347                    if let Some(body_str) = body_text(&tag.body) {
348                        if let Some((var_part, type_part)) = body_str.split_once(" is ") {
349                            let var_name = var_part.trim().trim_start_matches('$').to_string();
350                            let type_string = type_part.trim().to_string();
351                            if !var_name.is_empty() && !type_string.is_empty() {
352                                result.mir_checks.push((var_name, type_string));
353                            }
354                        }
355                    }
356                }
357                _ => {}
358            }
359        }
360
361        if text.to_ascii_lowercase().contains("{@inheritdoc}") {
362            result.is_inherit_doc = true;
363        }
364
365        result
366    }
367}
368
369// ---------------------------------------------------------------------------
370// ParsedDocblock support types
371// ---------------------------------------------------------------------------
372
373#[derive(Debug, Default, Clone)]
374pub struct DocProperty {
375    pub type_hint: String,
376    pub name: String,     // without leading $
377    pub read_only: bool,  // true for @property-read
378    pub write_only: bool, // true for @property-write
379}
380
381#[derive(Debug, Default, Clone)]
382pub struct DocMethod {
383    pub return_type: String,
384    pub name: String,
385    pub is_static: bool,
386    pub params: Vec<DocMethodParam>,
387}
388
389#[derive(Debug, Default, Clone)]
390pub struct DocMethodParam {
391    pub name: String,
392    pub type_hint: String,
393    pub is_variadic: bool,
394    pub is_byref: bool,
395    pub is_optional: bool,
396}
397
398#[derive(Debug, Default, Clone)]
399pub struct DocTypeAlias {
400    pub name: String,
401    pub type_expr: String,
402}
403
404#[derive(Debug, Default, Clone)]
405pub struct DocImportType {
406    /// The name exported by the source class (the original alias name).
407    pub original: String,
408    /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
409    pub local: String,
410    /// The FQCN of the class to import the type from.
411    pub from_class: String,
412}
413
414// ---------------------------------------------------------------------------
415// ParsedDocblock
416// ---------------------------------------------------------------------------
417
418#[derive(Debug, Default, Clone)]
419pub struct ParsedDocblock {
420    /// `@param Type $name`
421    pub params: Vec<(String, Type)>,
422    /// `@return Type`
423    pub return_type: Option<Type>,
424    /// `@var Type` or `@var Type $name` — type and optional variable name
425    pub var_type: Option<Type>,
426    /// Optional variable name from `@var Type $name`
427    pub var_name: Option<String>,
428    /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
429    pub templates: Vec<(String, Option<Type>, Variance)>,
430    /// `@extends ClassName<T>`
431    pub extends: Option<Type>,
432    /// `@implements InterfaceName<T>`
433    pub implements: Vec<Type>,
434    /// `@throws ClassName`
435    pub throws: Vec<String>,
436    /// `@psalm-assert Type $var`
437    pub assertions: Vec<(String, Type)>,
438    /// `@psalm-assert-if-true Type $var`
439    pub assertions_if_true: Vec<(String, Type)>,
440    /// `@psalm-assert-if-false Type $var`
441    pub assertions_if_false: Vec<(String, Type)>,
442    /// `@psalm-suppress IssueName`
443    pub suppressed_issues: Vec<String>,
444    pub is_deprecated: bool,
445    pub is_internal: bool,
446    pub is_pure: bool,
447    pub is_immutable: bool,
448    pub is_readonly: bool,
449    pub is_api: bool,
450    /// `@final` — class should be treated as final even without the PHP `final` keyword.
451    pub is_final: bool,
452    /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
453    /// inherited from the nearest ancestor that has a real docblock.
454    pub is_inherit_doc: bool,
455    /// Free text before first `@` tag — used for hover display
456    pub description: String,
457    /// `@deprecated message` — Some(message) or Some("") if no message
458    pub deprecated: Option<String>,
459    /// `@see ClassName` / `@link URL`
460    pub see: Vec<String>,
461    /// `@mixin ClassName`
462    pub mixins: Vec<String>,
463    /// `@property`, `@property-read`, `@property-write`
464    pub properties: Vec<DocProperty>,
465    /// `@method [static] ReturnType name([params])`
466    pub methods: Vec<DocMethod>,
467    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
468    pub type_aliases: Vec<DocTypeAlias>,
469    /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
470    pub import_types: Vec<DocImportType>,
471    /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
472    pub require_extends: Vec<String>,
473    /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
474    pub require_implements: Vec<String>,
475    /// `@since X.Y` — first PHP version this symbol exists in.
476    pub since: Option<String>,
477    /// `@removed X.Y` — first PHP version this symbol no longer exists in.
478    pub removed: Option<String>,
479    /// Malformed type annotations detected during parsing.
480    pub invalid_annotations: Vec<String>,
481    /// `@mir-check $var is TYPE` — (var_name_without_dollar, type_string)
482    pub mir_checks: Vec<(String, String)>,
483}
484
485impl ParsedDocblock {
486    /// Returns the type for a given parameter name (strips leading `$`).
487    ///
488    /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
489    /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
490    /// preceding plain `@param` annotation.
491    pub fn get_param_type(&self, name: &str) -> Option<&Type> {
492        let name = name.trim_start_matches('$');
493        self.params
494            .iter()
495            .rfind(|(n, _)| n.trim_start_matches('$') == name)
496            .map(|(_, ty)| ty)
497    }
498}
499
500// ---------------------------------------------------------------------------
501// Type string parser
502// ---------------------------------------------------------------------------
503
504/// Parse a PHPDoc type expression string into a `Type`.
505/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
506/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
507pub fn parse_type_string(s: &str) -> Type {
508    let s = s.trim();
509
510    // Nullable shorthand: `?Type`
511    if let Some(inner) = s.strip_prefix('?') {
512        let inner_ty = parse_type_string(inner);
513        let mut u = inner_ty;
514        u.add_type(Atomic::TNull);
515        return u;
516    }
517
518    // Conditional type: `($param is TypeName ? TrueType : FalseType)`
519    // Parenthesized type: `(A&B)|null` — strip outer parens and recurse.
520    if s.starts_with('(') && s.ends_with(')') {
521        let inner = s[1..s.len() - 1].trim();
522        if let Some(conditional) = parse_conditional_type(inner) {
523            return conditional;
524        }
525        // Strip balanced outer parens: verify depth doesn't go negative before the end.
526        if is_balanced_parens(s) {
527            return parse_type_string(inner);
528        }
529    }
530
531    // Type: `A|B|C`
532    if s.contains('|') && !is_inside_generics(s) {
533        let parts = split_union(s);
534        if parts.len() > 1 {
535            let mut u = Type::empty();
536            for part in parts {
537                for atomic in parse_type_string(&part).types {
538                    u.add_type(atomic);
539                }
540            }
541            return u;
542        }
543    }
544
545    // Intersection: `A&B&C` — PHP 8.1+ pure intersection type.
546    // Use a depth-aware split so `&` inside generics (e.g. `array<K,V>`) is not broken.
547    if s.contains('&') && !is_inside_generics(s) {
548        let parts = split_intersection(s);
549        if parts.len() > 1 {
550            let parts: Vec<Type> = parts.iter().map(|p| parse_type_string(p.trim())).collect();
551            return Type::single(Atomic::TIntersection {
552                parts: mir_types::union::vec_to_type_params(parts),
553            });
554        }
555    }
556
557    // Array shorthand: `Type[]` or `Type[][]`
558    if let Some(value_str) = s.strip_suffix("[]") {
559        let value = parse_type_string(value_str);
560        return Type::single(Atomic::TArray {
561            key: Box::new(Type::single(Atomic::TInt)),
562            value: Box::new(value),
563        });
564    }
565
566    // Callable/closure syntax: `Closure(T): R` or `callable(T): R`
567    if let Some(call_ty) = parse_callable_syntax(s) {
568        return call_ty;
569    }
570
571    // Array shape: `array{key: Type, ...}` or `list{Type, ...}`
572    if s.ends_with('}') {
573        if let Some(open) = s.find('{') {
574            let prefix = s[..open].to_lowercase();
575            let inner = &s[open + 1..s.len() - 1];
576            if prefix == "array" {
577                return parse_keyed_array(inner, false);
578            } else if prefix == "list" {
579                return parse_keyed_array(inner, true);
580            }
581        }
582    }
583
584    // Generic: `name<...>`
585    if let Some(open) = s.find('<') {
586        if s.ends_with('>') {
587            let name = &s[..open];
588            let inner = &s[open + 1..s.len() - 1];
589            return parse_generic(name, inner);
590        }
591    }
592
593    // Keywords
594    match s.to_lowercase().as_str() {
595        "string" => Type::single(Atomic::TString),
596        "non-empty-string" => Type::single(Atomic::TNonEmptyString),
597        "numeric-string" => Type::single(Atomic::TNumericString),
598        "class-string" => Type::single(Atomic::TClassString(None)),
599        "int" | "integer" => Type::single(Atomic::TInt),
600        "positive-int" => Type::single(Atomic::TPositiveInt),
601        "negative-int" => Type::single(Atomic::TNegativeInt),
602        "non-negative-int" => Type::single(Atomic::TNonNegativeInt),
603        "float" | "double" => Type::single(Atomic::TFloat),
604        "bool" | "boolean" => Type::single(Atomic::TBool),
605        "true" => Type::single(Atomic::TTrue),
606        "false" => Type::single(Atomic::TFalse),
607        "null" => Type::single(Atomic::TNull),
608        "void" => Type::single(Atomic::TVoid),
609        "never" | "never-return" | "no-return" | "never-returns" => Type::single(Atomic::TNever),
610        "mixed" => Type::single(Atomic::TMixed),
611        "object" => Type::single(Atomic::TObject),
612        "array" => Type::single(Atomic::TArray {
613            key: Box::new(Type::single(Atomic::TMixed)),
614            value: Box::new(Type::mixed()),
615        }),
616        "list" => Type::single(Atomic::TList {
617            value: Box::new(Type::mixed()),
618        }),
619        "callable" => Type::single(Atomic::TCallable {
620            params: None,
621            return_type: None,
622        }),
623        "callable-string" => Type::single(Atomic::TCallableString),
624        "iterable" => Type::single(Atomic::TArray {
625            key: Box::new(Type::single(Atomic::TMixed)),
626            value: Box::new(Type::mixed()),
627        }),
628        "scalar" => Type::single(Atomic::TScalar),
629        "numeric" => Type::single(Atomic::TNumeric),
630        "array-key" => {
631            let mut u = Type::single(Atomic::TInt);
632            u.add_type(Atomic::TString);
633            u
634        }
635        "resource" => Type::mixed(), // treat as mixed
636        // self/static/parent: emit sentinel with empty FQCN; collector fills it in.
637        "static" => Type::single(Atomic::TStaticObject {
638            fqcn: mir_types::Name::from(""),
639        }),
640        "self" | "$this" => Type::single(Atomic::TSelf {
641            fqcn: mir_types::Name::from(""),
642        }),
643        "parent" => Type::single(Atomic::TParent {
644            fqcn: mir_types::Name::from(""),
645        }),
646
647        // Named class
648        _ if !s.is_empty()
649            && s.chars()
650                .next()
651                .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
652                .unwrap_or(false) =>
653        {
654            // Integer literal: `1`, `-42`, `0` etc.
655            if let Ok(n) = s.parse::<i64>() {
656                return Type::single(Atomic::TLiteralInt(n));
657            }
658            Type::single(Atomic::TNamedObject {
659                fqcn: normalize_fqcn(s).into(),
660                type_params: mir_types::union::empty_type_params(),
661            })
662        }
663
664        // Negative integer literal: `-1`, `-42` — starts with `-`, not caught by alphanumeric check
665        _ if s.starts_with('-') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) => {
666            if let Ok(n) = s.parse::<i64>() {
667                Type::single(Atomic::TLiteralInt(n))
668            } else {
669                Type::mixed()
670            }
671        }
672
673        // String literal: `'foo'` or `"bar"`
674        _ if (s.starts_with('\'') && s.ends_with('\''))
675            || (s.starts_with('"') && s.ends_with('"')) =>
676        {
677            let inner = &s[1..s.len() - 1];
678            Type::single(Atomic::TLiteralString(Arc::from(inner)))
679        }
680
681        _ => Type::mixed(),
682    }
683}
684
685fn parse_generic(name: &str, inner: &str) -> Type {
686    match name.to_lowercase().as_str() {
687        "array" => {
688            let params = split_generics(inner);
689            let array_key = || {
690                let mut k = Type::single(Atomic::TInt);
691                k.add_type(Atomic::TString);
692                k
693            };
694            let (key, value) = match params.len() {
695                n if n >= 2 => (
696                    parse_type_string(params[0].trim()),
697                    parse_type_string(params[1].trim()),
698                ),
699                1 => (array_key(), parse_type_string(params[0].trim())),
700                _ => (array_key(), Type::mixed()),
701            };
702            Type::single(Atomic::TArray {
703                key: Box::new(key),
704                value: Box::new(value),
705            })
706        }
707        "list" | "non-empty-list" => {
708            let value = parse_type_string(inner.trim());
709            if name.to_lowercase().starts_with("non-empty") {
710                Type::single(Atomic::TNonEmptyList {
711                    value: Box::new(value),
712                })
713            } else {
714                Type::single(Atomic::TList {
715                    value: Box::new(value),
716                })
717            }
718        }
719        "non-empty-array" => {
720            let params = split_generics(inner);
721            let array_key = || {
722                let mut k = Type::single(Atomic::TInt);
723                k.add_type(Atomic::TString);
724                k
725            };
726            let (key, value) = match params.len() {
727                n if n >= 2 => (
728                    parse_type_string(params[0].trim()),
729                    parse_type_string(params[1].trim()),
730                ),
731                1 => (array_key(), parse_type_string(params[0].trim())),
732                _ => (array_key(), Type::mixed()),
733            };
734            Type::single(Atomic::TNonEmptyArray {
735                key: Box::new(key),
736                value: Box::new(value),
737            })
738        }
739        "iterable" => {
740            let params = split_generics(inner);
741            let value = match params.len() {
742                n if n >= 2 => parse_type_string(params[1].trim()),
743                1 => parse_type_string(params[0].trim()),
744                _ => Type::mixed(),
745            };
746            Type::single(Atomic::TArray {
747                key: Box::new(Type::single(Atomic::TMixed)),
748                value: Box::new(value),
749            })
750        }
751        "class-string" => Type::single(Atomic::TClassString(Some(
752            normalize_fqcn(inner.trim()).into(),
753        ))),
754        "int" => {
755            // int<min, max>
756            Type::single(Atomic::TIntRange {
757                min: None,
758                max: None,
759            })
760        }
761        // Named class with type params
762        _ => {
763            let params: Vec<Type> = split_generics(inner)
764                .iter()
765                .map(|p| parse_type_string(p.trim()))
766                .collect();
767            Type::single(Atomic::TNamedObject {
768                fqcn: normalize_fqcn(name).into(),
769                type_params: mir_types::union::vec_to_type_params(params),
770            })
771        }
772    }
773}
774
775fn strip_quotes(s: &str) -> &str {
776    if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
777        &s[1..s.len() - 1]
778    } else {
779        s
780    }
781}
782
783fn parse_keyed_array(inner: &str, is_list: bool) -> Type {
784    use mir_types::atomic::KeyedProperty;
785    let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
786    let mut is_open = false;
787    let mut auto_index = 0i64;
788
789    for item in split_generics(inner) {
790        let item = item.trim();
791        if item.is_empty() {
792            continue;
793        }
794        if item == "..." {
795            is_open = true;
796            continue;
797        }
798        // Find a colon that is not inside nested generics/braces
799        let colon_pos = {
800            let mut depth = 0i32;
801            let mut found = None;
802            for (i, ch) in item.char_indices() {
803                match ch {
804                    '<' | '(' | '{' => depth += 1,
805                    '>' | ')' | '}' => depth -= 1,
806                    ':' if depth == 0 => {
807                        found = Some(i);
808                        break;
809                    }
810                    _ => {}
811                }
812            }
813            found
814        };
815        if let Some(colon) = colon_pos {
816            let key_part = item[..colon].trim();
817            let ty_part = item[colon + 1..].trim();
818            let optional = key_part.ends_with('?');
819            let key_str = key_part.trim_end_matches('?').trim();
820            let key_str = strip_quotes(key_str);
821            let key = if let Ok(n) = key_str.parse::<i64>() {
822                ArrayKey::Int(n)
823            } else {
824                ArrayKey::String(Arc::from(key_str))
825            };
826            properties.insert(
827                key,
828                KeyedProperty {
829                    ty: parse_type_string(ty_part),
830                    optional,
831                },
832            );
833        } else {
834            properties.insert(
835                ArrayKey::Int(auto_index),
836                KeyedProperty {
837                    ty: parse_type_string(item),
838                    optional: false,
839                },
840            );
841            auto_index += 1;
842        }
843    }
844
845    Type::single(Atomic::TKeyedArray {
846        properties,
847        is_open,
848        is_list,
849    })
850}
851
852fn parse_callable_syntax(s: &str) -> Option<Type> {
853    let s = s.trim_start_matches('\\');
854    let lower = s.to_lowercase();
855    let is_closure = lower.starts_with("closure");
856    let is_callable = lower.starts_with("callable");
857    if !is_closure && !is_callable {
858        return None;
859    }
860    let prefix_len = if is_closure {
861        "closure".len()
862    } else {
863        "callable".len()
864    };
865    let rest = s[prefix_len..].trim_start();
866    if !rest.starts_with('(') {
867        return None;
868    }
869    let close = find_matching_paren(rest)?;
870    let params_str = &rest[1..close];
871    let after = rest[close + 1..].trim();
872    let return_type = after
873        .strip_prefix(':')
874        .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
875    let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
876        .into_iter()
877        .enumerate()
878        .filter(|(_, p)| !p.trim().is_empty())
879        .map(|(i, p)| {
880            let p = p.trim();
881            let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
882                (p[..dollar].trim(), p[dollar + 1..].to_string())
883            } else {
884                (p, format!("arg{i}"))
885            };
886            mir_types::atomic::FnParam {
887                name: name.into(),
888                ty: Some(mir_types::SimpleType::from_union(parse_type_string(ty_str))),
889                default: None,
890                is_variadic: false,
891                is_byref: false,
892                is_optional: false,
893            }
894        })
895        .collect();
896    if is_closure {
897        Some(Type::single(Atomic::TClosure {
898            params,
899            return_type: return_type.unwrap_or_else(|| Box::new(Type::single(Atomic::TVoid))),
900            this_type: None,
901        }))
902    } else {
903        Some(Type::single(Atomic::TCallable {
904            params: Some(params),
905            return_type,
906        }))
907    }
908}
909
910fn find_matching_paren(s: &str) -> Option<usize> {
911    if !s.starts_with('(') {
912        return None;
913    }
914    let mut depth = 0i32;
915    for (i, ch) in s.char_indices() {
916        match ch {
917            '(' | '<' | '{' => depth += 1,
918            ')' | '>' | '}' => {
919                depth -= 1;
920                if depth == 0 {
921                    return Some(i);
922                }
923            }
924            _ => {}
925        }
926    }
927    None
928}
929
930// ---------------------------------------------------------------------------
931// Helpers
932// ---------------------------------------------------------------------------
933
934/// Parse template tag format: `T`, `T of Bound`, or `T as Bound`.
935///
936/// For single-line docblocks (e.g. `/** @template T @param T $x @return T */`)
937/// `phpdoc_parser` hands us a body that runs all the way to the closing `*/`,
938/// so the body may carry trailing tags (`T @param T $x @return T`). The template
939/// name is the first whitespace-delimited token and any `of`/`as` bound is only
940/// parsed up to the next `@` tag.
941fn parse_template_line(_tag_name: &str, body: Option<String>) -> Option<(String, Option<String>)> {
942    let body = body?;
943    let body = body.trim();
944    // Stop at the next embedded tag so single-line docblocks don't swallow the
945    // following `@param`/`@return` tokens into the template name/bound.
946    let body = match body.find(" @") {
947        Some(idx) => body[..idx].trim_end(),
948        None => body,
949    };
950    if body.is_empty() {
951        return None;
952    }
953    if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
954        let bound = bound.trim();
955        Some((
956            name.trim().to_string(),
957            (!bound.is_empty()).then(|| bound.to_string()),
958        ))
959    } else {
960        // No bound: take just the first whitespace-delimited token as the name.
961        let name = body.split_whitespace().next().unwrap_or(body);
962        Some((name.to_string(), None))
963    }
964}
965
966/// Extract the description text (all prose before the first `@` tag) from a raw docblock.
967fn extract_description(text: &str) -> String {
968    let mut desc_lines: Vec<&str> = Vec::new();
969    for line in text.lines() {
970        let l = line.trim();
971        let l = l.trim_start_matches("/**").trim();
972        let l = l.trim_end_matches("*/").trim();
973        let l = l.trim_start_matches("*/").trim();
974        let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
975        let l = l.trim();
976        if l.starts_with('@') {
977            break;
978        }
979        if !l.is_empty() {
980            desc_lines.push(l);
981        }
982    }
983    desc_lines.join(" ")
984}
985
986/// Parse `@psalm-import-type` body.
987///
988/// Formats:
989/// - `AliasName from SourceClass`
990/// - `AliasName as LocalAlias from SourceClass`
991fn parse_import_type(body: &str) -> Option<DocImportType> {
992    // Split on " from " (with spaces to avoid matching partial words)
993    let (before_from, from_class_raw) = body.split_once(" from ")?;
994    let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
995    if from_class.is_empty() {
996        return None;
997    }
998    // Check for " as " in before_from
999    let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
1000        (orig.trim().to_string(), loc.trim().to_string())
1001    } else {
1002        let name = before_from.trim().to_string();
1003        (name.clone(), name)
1004    };
1005    if original.is_empty() || local.is_empty() {
1006        return None;
1007    }
1008    Some(DocImportType {
1009        original,
1010        local,
1011        from_class,
1012    })
1013}
1014
1015fn parse_param_line(s: &str) -> Option<(String, String)> {
1016    // Formats: `Type $name`, `Type $name description`, or `Type &$name ...` (byref)
1017    // Types can contain spaces (e.g., `array<string, int>`), so we need to find the variable name.
1018    // The variable name is the `$identifier` that comes after whitespace (not part of type syntax).
1019    //
1020    // Only examine the first line to avoid matching `$var` references in multi-line descriptions.
1021    let first_line = s.lines().next().unwrap_or(s);
1022
1023    // Strategy: find the last sequence of whitespace followed by `$identifier` or `&$identifier`
1024    // on the first line. This handles both simple types and types with generics/spaces.
1025    let mut best_split: Option<(String, String)> = None;
1026
1027    for (i, ch) in first_line.char_indices() {
1028        if ch.is_whitespace() {
1029            let after = first_line[i..].trim_start();
1030            // Accept `$name` or `&$name` (by-reference params in PHPDoc)
1031            let after_stripped = after.strip_prefix('&').unwrap_or(after);
1032            if after_stripped.starts_with('$') {
1033                let mut var_parts = after_stripped.split(char::is_whitespace);
1034                if let Some(name_with_dollar) = var_parts.next() {
1035                    let name = name_with_dollar.trim_start_matches('$').to_string();
1036                    if !name.is_empty() {
1037                        let type_part = first_line[..i].trim().to_string();
1038                        if !type_part.is_empty() {
1039                            best_split = Some((type_part, name));
1040                        }
1041                    }
1042                }
1043            }
1044        }
1045    }
1046
1047    best_split
1048}
1049
1050fn extract_return_type(s: &str) -> String {
1051    // Extract just the type portion from a @return tag body.
1052    // Format: `Type [description...]`
1053    // Types can contain generics <>, unions |, intersections &, but descriptions are
1054    // separated by whitespace after the type token.
1055    // Example: `bool true if var is of type string` -> `bool`
1056    // Example: `array<string, int> an associative array` -> `array<string, int>`
1057    // Example: `\Closure(): T description` -> `\Closure(): T`
1058
1059    let mut depth: i32 = 0;
1060    let mut current_token = String::new();
1061
1062    for ch in s.chars() {
1063        match ch {
1064            '<' | '(' | '{' => {
1065                depth += 1;
1066                current_token.push(ch);
1067            }
1068            '>' | ')' | '}' => {
1069                depth = (depth - 1).max(0);
1070                current_token.push(ch);
1071            }
1072            _ if ch.is_whitespace() && depth == 0 => {
1073                break;
1074            }
1075            _ => {
1076                current_token.push(ch);
1077            }
1078        }
1079    }
1080
1081    // Callable return type syntax: `\Closure(): T` — the token ends with ':'
1082    // because the space between ':' and 'T' caused an early stop. Append the
1083    // return-type token that follows.
1084    if current_token.ends_with(':') {
1085        let offset = current_token.len();
1086        let rest = s[offset..].trim_start();
1087        if !rest.is_empty() {
1088            let ret_type = extract_return_type(rest);
1089            current_token.push_str(&ret_type);
1090        }
1091    }
1092
1093    current_token.trim().to_string()
1094}
1095
1096fn split_union(s: &str) -> Vec<String> {
1097    let mut parts = Vec::new();
1098    let mut depth = 0;
1099    let mut current = String::new();
1100    for ch in s.chars() {
1101        match ch {
1102            '<' | '(' | '{' => {
1103                depth += 1;
1104                current.push(ch);
1105            }
1106            '>' | ')' | '}' => {
1107                depth -= 1;
1108                current.push(ch);
1109            }
1110            '|' if depth == 0 => {
1111                parts.push(current.trim().to_string());
1112                current = String::new();
1113            }
1114            _ => current.push(ch),
1115        }
1116    }
1117    if !current.trim().is_empty() {
1118        parts.push(current.trim().to_string());
1119    }
1120    parts
1121}
1122
1123/// Depth-aware split on `&` — does not break `&` inside `<>`, `()`, or `{}`.
1124fn split_intersection(s: &str) -> Vec<String> {
1125    let mut parts = Vec::new();
1126    let mut depth = 0i32;
1127    let mut current = String::new();
1128    for ch in s.chars() {
1129        match ch {
1130            '<' | '(' | '{' => {
1131                depth += 1;
1132                current.push(ch);
1133            }
1134            '>' | ')' | '}' => {
1135                depth -= 1;
1136                current.push(ch);
1137            }
1138            '&' if depth == 0 => {
1139                parts.push(current.trim().to_string());
1140                current = String::new();
1141            }
1142            _ => current.push(ch),
1143        }
1144    }
1145    if !current.trim().is_empty() {
1146        parts.push(current.trim().to_string());
1147    }
1148    parts
1149}
1150
1151/// Returns true when `s` starts with `(` and ends with `)` and those two
1152/// characters are a matched pair (i.e. the depth never goes below 1 before
1153/// the final character).
1154fn is_balanced_parens(s: &str) -> bool {
1155    if !s.starts_with('(') || !s.ends_with(')') {
1156        return false;
1157    }
1158    let mut depth = 0i32;
1159    let chars: Vec<char> = s.chars().collect();
1160    let last = chars.len() - 1;
1161    for (i, ch) in chars.iter().enumerate() {
1162        match ch {
1163            '(' => depth += 1,
1164            ')' => {
1165                depth -= 1;
1166                // If depth reaches 0 before the last char, the outer parens
1167                // are not a single balanced pair (e.g. `(A)(B)`).
1168                if depth == 0 && i < last {
1169                    return false;
1170                }
1171            }
1172            _ => {}
1173        }
1174    }
1175    depth == 0
1176}
1177
1178fn split_generics(s: &str) -> Vec<String> {
1179    let mut parts = Vec::new();
1180    let mut depth = 0;
1181    let mut current = String::new();
1182    for ch in s.chars() {
1183        match ch {
1184            '<' | '(' | '{' => {
1185                depth += 1;
1186                current.push(ch);
1187            }
1188            '>' | ')' | '}' => {
1189                depth -= 1;
1190                current.push(ch);
1191            }
1192            ',' if depth == 0 => {
1193                parts.push(current.trim().to_string());
1194                current = String::new();
1195            }
1196            _ => current.push(ch),
1197        }
1198    }
1199    if !current.trim().is_empty() {
1200        parts.push(current.trim().to_string());
1201    }
1202    parts
1203}
1204
1205/// Return the leading type expression from `s`, stopping at top-level whitespace.
1206/// Spaces inside `<…>` brackets are kept so that `array<string, int>` is returned whole.
1207fn extract_type_prefix(s: &str) -> &str {
1208    let mut depth = 0i32;
1209    let mut end = s.len();
1210    for (i, ch) in s.char_indices() {
1211        match ch {
1212            '<' | '(' | '{' => depth += 1,
1213            '>' | ')' | '}' => depth -= 1,
1214            _ if ch.is_whitespace() && depth == 0 => {
1215                end = i;
1216                break;
1217            }
1218            _ => {}
1219        }
1220    }
1221    &s[..end]
1222}
1223
1224fn is_inside_generics(s: &str) -> bool {
1225    let mut depth = 0i32;
1226    for ch in s.chars() {
1227        match ch {
1228            '<' | '(' | '{' => depth += 1,
1229            '>' | ')' | '}' => depth -= 1,
1230            _ => {}
1231        }
1232    }
1233    depth != 0
1234}
1235
1236/// Parses `$param is TypeName ? TrueType : FalseType` or `T is TypeName ? TrueType : FalseType`
1237/// (template-type conditional, no `$`) into a `TConditional`.
1238fn parse_conditional_type(s: &str) -> Option<Type> {
1239    let is_pos = s.find(" is ")?;
1240    let param_raw = s[..is_pos].trim();
1241
1242    // Accept either `$identifier` (regular param) or a bare identifier (template name).
1243    let param_name_str: &str = if let Some(name) = param_raw.strip_prefix('$') {
1244        if name.is_empty() {
1245            return None;
1246        }
1247        name
1248    } else {
1249        // Bare template name: must be a valid identifier and the string must contain `?`
1250        // so we don't accidentally parse class-hierarchy expressions.
1251        if param_raw.is_empty()
1252            || !param_raw.starts_with(|c: char| c.is_alphabetic() || c == '_')
1253            || !param_raw.chars().all(|c| c.is_alphanumeric() || c == '_')
1254            || !s.contains('?')
1255        {
1256            return None;
1257        }
1258        param_raw
1259    };
1260    let param_name = Some(mir_types::Name::new(param_name_str));
1261    let after_is = s[is_pos + 4..].trim();
1262    let q_pos = find_char_at_depth(after_is, '?')?;
1263    let subject_str = after_is[..q_pos].trim();
1264    let rest = after_is[q_pos + 1..].trim();
1265    let colon_pos = find_char_at_depth(rest, ':')?;
1266    let true_str = rest[..colon_pos].trim();
1267    let false_str = rest[colon_pos + 1..].trim();
1268    Some(Type::single(Atomic::TConditional {
1269        param_name,
1270        subject: Box::new(parse_type_string(subject_str)),
1271        if_true: Box::new(parse_type_string(true_str)),
1272        if_false: Box::new(parse_type_string(false_str)),
1273    }))
1274}
1275
1276/// Finds `target` in `s` at nesting depth 0 (not inside `<>`, `()`, `{}`).
1277fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1278    let mut depth = 0i32;
1279    for (i, ch) in s.char_indices() {
1280        match ch {
1281            '<' | '(' | '{' => depth += 1,
1282            '>' | ')' | '}' => depth -= 1,
1283            _ if ch == target && depth == 0 => return Some(i),
1284            _ => {}
1285        }
1286    }
1287    None
1288}
1289
1290fn normalize_fqcn(s: &str) -> String {
1291    // Strip leading backslash if present — we normalize all FQCNs without leading `\`
1292    s.trim_start_matches('\\').to_string()
1293}
1294
1295/// Returns an error message if `s` is a malformed PHPDoc type expression, otherwise `None`.
1296///
1297/// Detects:
1298/// - unclosed generics (`array<`, `Foo<Bar`)
1299/// - `$variable` in type position (only `$this` is valid)
1300fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1301    let s = s.trim();
1302    if s.is_empty() {
1303        return None;
1304    }
1305    if is_inside_generics(s) {
1306        return Some(format!("@{tag} has unclosed generic type `{s}`"));
1307    }
1308    // Skip empty generics check for callable/closure types (e.g., `callable(): T`, `\Closure(): T`)
1309    let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1310    if !is_callable_type && has_empty_generics(s) {
1311        return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1312    }
1313    for part in split_union(s) {
1314        let p = part.trim();
1315        if p.starts_with('$') && p != "$this" {
1316            return Some(format!("@{tag} contains variable `{p}` in type position"));
1317        }
1318        if let Some(err) = validate_generic_semantics(p, tag) {
1319            return Some(err);
1320        }
1321    }
1322    None
1323}
1324
1325/// Validates semantic constraints on generic type expressions like `int<min, max>` and `array<key, value>`.
1326fn validate_generic_semantics(s: &str, tag: &str) -> Option<String> {
1327    let lower = s.to_lowercase();
1328    let (name, inner) = extract_generic_content(s)?;
1329    match lower[..name.len()].as_ref() {
1330        "int" => validate_int_range_inner(inner, tag),
1331        "array" | "non-empty-array" => validate_array_key_inner(inner, tag),
1332        _ => None,
1333    }
1334}
1335
1336/// Extracts `(name, inner)` from `Name<inner>`. Returns `None` if not a generic.
1337fn extract_generic_content(s: &str) -> Option<(&str, &str)> {
1338    let lt = s.find('<')?;
1339    let name = s[..lt].trim();
1340    if name.is_empty() {
1341        return None;
1342    }
1343    let after_lt = &s[lt + 1..];
1344    let mut depth = 1i32;
1345    for (i, ch) in after_lt.char_indices() {
1346        match ch {
1347            '<' | '(' | '{' => depth += 1,
1348            '>' | ')' | '}' => {
1349                depth -= 1;
1350                if depth == 0 {
1351                    return Some((name, &after_lt[..i]));
1352                }
1353            }
1354            _ => {}
1355        }
1356    }
1357    None
1358}
1359
1360fn validate_int_range_inner(inner: &str, tag: &str) -> Option<String> {
1361    let mut parts = inner.splitn(2, ',');
1362    let min_str = parts.next()?.trim();
1363    let max_str = parts.next()?.trim();
1364
1365    if min_str == "max" {
1366        return Some(format!(
1367            "@{tag} has invalid int range: `max` must be the second argument, not the first"
1368        ));
1369    }
1370    if max_str == "min" {
1371        return Some(format!(
1372            "@{tag} has invalid int range: `min` must be the first argument, not the second"
1373        ));
1374    }
1375
1376    let is_valid_bound = |s: &str| s == "min" || s == "max" || s.parse::<i64>().is_ok();
1377
1378    if !is_valid_bound(min_str) {
1379        return Some(format!(
1380            "@{tag} has invalid int range boundary `{min_str}`: must be an integer literal, `min`, or `max`"
1381        ));
1382    }
1383    if !is_valid_bound(max_str) {
1384        return Some(format!(
1385            "@{tag} has invalid int range boundary `{max_str}`: must be an integer literal, `min`, or `max`"
1386        ));
1387    }
1388
1389    if let (Ok(lo), Ok(hi)) = (min_str.parse::<i64>(), max_str.parse::<i64>()) {
1390        if lo > hi {
1391            return Some(format!(
1392                "@{tag} has invalid int range: min ({lo}) must not be greater than max ({hi})"
1393            ));
1394        }
1395    }
1396    None
1397}
1398
1399fn validate_array_key_inner(inner: &str, tag: &str) -> Option<String> {
1400    let params = split_generics(inner);
1401    if params.len() < 2 {
1402        return None;
1403    }
1404    let key_str = params[0].trim();
1405    // Only flag types that are fundamentally invalid as PHP array keys (float, bool variants).
1406    // Reference types like `object` are technically invalid array keys in PHP but are
1407    // left to the template-bound checker (InvalidTemplateParam) to handle more precisely.
1408    let invalid_key_types = ["float", "bool", "true", "false"];
1409    if invalid_key_types.contains(&key_str.to_lowercase().as_str()) {
1410        return Some(format!(
1411            "@{tag} has invalid array key type `{key_str}`: must be a subtype of int|string"
1412        ));
1413    }
1414    None
1415}
1416
1417fn has_empty_generics(s: &str) -> bool {
1418    let mut depth = 0;
1419    let mut prev_open = false;
1420    for ch in s.chars() {
1421        match ch {
1422            '<' | '(' | '{' => {
1423                if prev_open && depth == 0 {
1424                    return true;
1425                }
1426                prev_open = true;
1427                depth += 1;
1428            }
1429            '>' | ')' | '}' => {
1430                depth -= 1;
1431                if depth == 0 {
1432                    if prev_open {
1433                        return true;
1434                    }
1435                    prev_open = false;
1436                }
1437            }
1438            c if !c.is_whitespace() => {
1439                prev_open = false;
1440            }
1441            _ => {}
1442        }
1443    }
1444    false
1445}
1446
1447/// Validate `@method` body for common errors before parsing.
1448/// Returns `Some(error_message)` if the annotation is invalid.
1449fn validate_method_body(s: &str) -> Option<String> {
1450    let s = s.trim();
1451    if s.is_empty() {
1452        return Some("@method annotation is missing a method definition".to_string());
1453    }
1454    // Strip optional `static` prefix
1455    let rest = if s.to_lowercase().starts_with("static ") {
1456        s["static ".len()..].trim_start()
1457    } else {
1458        s
1459    };
1460    // Extract the method name (the token immediately before `(`)
1461    let open = rest.find('(').unwrap_or(rest.len());
1462    let prefix = rest[..open].trim();
1463    let parts: Vec<&str> = prefix.split_whitespace().collect();
1464    let name = parts.last().unwrap_or(&"");
1465    // Check for invalid characters in method name (e.g., dash)
1466    if !name.is_empty() && !is_valid_php_identifier(name) {
1467        return Some(format!(
1468            "@method has invalid method name `{name}`: must be a valid PHP identifier"
1469        ));
1470    }
1471    // Validate parameters for by-ref annotations
1472    if rest.contains('(') {
1473        let params_str = rest;
1474        let open_pos = params_str.find('(').unwrap();
1475        let after_open = &params_str[open_pos + 1..];
1476        if let Some(rel_close) = find_matching_paren(&params_str[open_pos..]) {
1477            let close_pos = open_pos + rel_close;
1478            let inner = params_str[open_pos + 1..close_pos].trim();
1479            if !inner.is_empty() {
1480                for param in split_generics(inner) {
1481                    let param = param.trim();
1482                    if param.starts_with('&') {
1483                        return Some(format!(
1484                            "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1485                        ));
1486                    }
1487                    // Detect `type & $name` pattern (ampersand with space before `$`)
1488                    if let Some(amp_pos) = param.find('&') {
1489                        let before_amp = &param[..amp_pos];
1490                        let after_amp = param[amp_pos + 1..].trim_start();
1491                        if !before_amp.trim().is_empty() && after_amp.starts_with('$') {
1492                            return Some(format!(
1493                                "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1494                            ));
1495                        }
1496                    }
1497                }
1498            }
1499        } else {
1500            let _ = after_open;
1501        }
1502    }
1503    None
1504}
1505
1506fn is_valid_php_identifier(s: &str) -> bool {
1507    let mut chars = s.chars();
1508    match chars.next() {
1509        Some(c) if c.is_alphabetic() || c == '_' => {}
1510        _ => return false,
1511    }
1512    chars.all(|c| c.is_alphanumeric() || c == '_')
1513}
1514
1515/// Parse `[static] [ReturnType] name(...)` for @method tags.
1516fn parse_method_line(s: &str) -> Option<DocMethod> {
1517    let mut rest = s.trim();
1518    if rest.is_empty() {
1519        return None;
1520    }
1521    let is_static = rest
1522        .split_whitespace()
1523        .next()
1524        .map(|w| w.eq_ignore_ascii_case("static"))
1525        .unwrap_or(false);
1526    if is_static {
1527        rest = rest["static".len()..].trim_start();
1528    }
1529
1530    let open = rest.find('(').unwrap_or(rest.len());
1531    let prefix = rest[..open].trim();
1532    let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1533    let name = parts.pop()?.to_string();
1534    if name.is_empty() {
1535        return None;
1536    }
1537    let return_type = parts.join(" ");
1538    Some(DocMethod {
1539        return_type,
1540        name,
1541        is_static,
1542        params: parse_method_params(rest),
1543    })
1544}
1545
1546fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1547    let Some(open) = name_part.find('(') else {
1548        return vec![];
1549    };
1550    // Use the existing balanced-paren matcher, which expects the slice to start
1551    // at '('. This avoids capturing closing parens from description text that
1552    // follows the method signature (e.g. Carbon's "@method addDay() ... (desc)").
1553    let Some(rel_close) = find_matching_paren(&name_part[open..]) else {
1554        return vec![];
1555    };
1556    let close = open + rel_close;
1557    let inner = name_part[open + 1..close].trim();
1558    if inner.is_empty() {
1559        return vec![];
1560    }
1561
1562    split_generics(inner)
1563        .into_iter()
1564        .filter_map(|param| parse_method_param(&param))
1565        .collect()
1566}
1567
1568fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1569    let before_default = param.split('=').next()?.trim();
1570    let is_optional = param.contains('=');
1571    let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1572    let raw_name = tokens.pop()?;
1573    let is_variadic = raw_name.contains("...");
1574    let is_byref = raw_name.contains('&');
1575    let name = raw_name
1576        .trim_start_matches('&')
1577        .trim_start_matches("...")
1578        .trim_start_matches('&')
1579        .trim_start_matches('$')
1580        .to_string();
1581    if name.is_empty() {
1582        return None;
1583    }
1584    Some(DocMethodParam {
1585        name,
1586        type_hint: tokens.join(" "),
1587        is_variadic,
1588        is_byref,
1589        is_optional: is_optional || is_variadic,
1590    })
1591}
1592
1593// ---------------------------------------------------------------------------
1594// Tests
1595// ---------------------------------------------------------------------------
1596
1597#[cfg(test)]
1598mod tests {
1599    use super::*;
1600    use mir_types::Atomic;
1601
1602    #[test]
1603    fn parse_string() {
1604        let u = parse_type_string("string");
1605        assert_eq!(u.types.len(), 1);
1606        assert!(matches!(u.types[0], Atomic::TString));
1607    }
1608
1609    #[test]
1610    fn parse_nullable_string() {
1611        let u = parse_type_string("?string");
1612        assert!(u.is_nullable());
1613        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1614    }
1615
1616    #[test]
1617    fn parse_union() {
1618        let u = parse_type_string("string|int|null");
1619        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1620        assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1621        assert!(u.is_nullable());
1622    }
1623
1624    #[test]
1625    fn parse_array_of_string() {
1626        let u = parse_type_string("array<string>");
1627        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1628    }
1629
1630    #[test]
1631    fn parse_list_of_int() {
1632        let u = parse_type_string("list<int>");
1633        assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1634    }
1635
1636    #[test]
1637    fn parse_named_class() {
1638        let u = parse_type_string("Foo\\Bar");
1639        assert!(u.contains(
1640            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1641        ));
1642    }
1643
1644    #[test]
1645    fn parse_docblock_param_return() {
1646        let doc = r#"/**
1647         * @param string $name
1648         * @param int $age
1649         * @return bool
1650         */"#;
1651        let parsed = DocblockParser::parse(doc);
1652        assert_eq!(parsed.params.len(), 2);
1653        assert!(parsed.return_type.is_some());
1654        let ret = parsed.return_type.unwrap();
1655        assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1656    }
1657
1658    #[test]
1659    fn parse_template() {
1660        let doc = "/** @template T of object */";
1661        let parsed = DocblockParser::parse(doc);
1662        assert_eq!(parsed.templates.len(), 1);
1663        assert_eq!(parsed.templates[0].0, "T");
1664        assert!(parsed.templates[0].1.is_some());
1665        assert_eq!(parsed.templates[0].2, Variance::Invariant);
1666    }
1667
1668    #[test]
1669    fn parse_template_covariant() {
1670        let doc = "/** @template-covariant T */";
1671        let parsed = DocblockParser::parse(doc);
1672        assert_eq!(parsed.templates.len(), 1);
1673        assert_eq!(parsed.templates[0].0, "T");
1674        assert_eq!(parsed.templates[0].2, Variance::Covariant);
1675    }
1676
1677    #[test]
1678    fn parse_template_contravariant() {
1679        let doc = "/** @template-contravariant T */";
1680        let parsed = DocblockParser::parse(doc);
1681        assert_eq!(parsed.templates.len(), 1);
1682        assert_eq!(parsed.templates[0].0, "T");
1683        assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1684    }
1685
1686    #[test]
1687    fn parse_template_single_line_does_not_over_read() {
1688        // E1: single-line docblock — the @template body runs to the closing `*/`,
1689        // so the parser used to take `T @param T $x @return T` as the template name.
1690        let doc = "/** @template T @param T $x @return T */";
1691        let parsed = DocblockParser::parse(doc);
1692        assert_eq!(parsed.templates.len(), 1);
1693        assert_eq!(parsed.templates[0].0, "T");
1694        assert!(parsed.templates[0].1.is_none(), "expected no bound");
1695    }
1696
1697    #[test]
1698    fn parse_template_multiline_with_bound_still_works() {
1699        // E1 regression guard: a normal multi-line `@template T of Base` keeps name + bound.
1700        let doc = r#"/**
1701         * @template T of Base
1702         */"#;
1703        let parsed = DocblockParser::parse(doc);
1704        assert_eq!(parsed.templates.len(), 1);
1705        assert_eq!(parsed.templates[0].0, "T");
1706        let bound = parsed.templates[0].1.as_ref().expect("expected a bound");
1707        assert!(bound.contains(
1708            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Base")
1709        ));
1710    }
1711
1712    #[test]
1713    fn parse_template_extends_alias() {
1714        // E2: `@template-extends` / `@phpstan-extends` route into `extends`.
1715        for tag in ["template-extends", "phpstan-extends"] {
1716            let doc = format!("/** @{tag} Base<User> */");
1717            let parsed = DocblockParser::parse(&doc);
1718            let extends = parsed
1719                .extends
1720                .unwrap_or_else(|| panic!("@{tag} should populate extends"));
1721            assert!(
1722                extends.contains(|t| matches!(
1723                    t,
1724                    Atomic::TNamedObject { fqcn, type_params }
1725                        if fqcn.as_ref() == "Base" && !type_params.is_empty()
1726                )),
1727                "@{tag} should produce a generic Base<User>"
1728            );
1729        }
1730    }
1731
1732    #[test]
1733    fn parse_template_implements_alias() {
1734        // E2: `@template-implements` / `@phpstan-implements` route into `implements`.
1735        for tag in ["template-implements", "phpstan-implements"] {
1736            let doc = format!("/** @{tag} Iter<User> */");
1737            let parsed = DocblockParser::parse(&doc);
1738            assert_eq!(
1739                parsed.implements.len(),
1740                1,
1741                "@{tag} should populate implements"
1742            );
1743            assert!(
1744                parsed.implements[0].contains(|t| matches!(
1745                    t,
1746                    Atomic::TNamedObject { fqcn, type_params }
1747                        if fqcn.as_ref() == "Iter" && !type_params.is_empty()
1748                )),
1749                "@{tag} should produce a generic Iter<User>"
1750            );
1751        }
1752    }
1753
1754    #[test]
1755    fn parse_deprecated() {
1756        let doc = "/** @deprecated use newMethod() instead */";
1757        let parsed = DocblockParser::parse(doc);
1758        assert!(parsed.is_deprecated);
1759        assert_eq!(
1760            parsed.deprecated.as_deref(),
1761            Some("use newMethod() instead")
1762        );
1763    }
1764
1765    #[test]
1766    fn parse_since_plain() {
1767        let parsed = DocblockParser::parse("/** @since 8.0 */");
1768        assert_eq!(parsed.since.as_deref(), Some("8.0"));
1769        assert_eq!(parsed.removed, None);
1770    }
1771
1772    #[test]
1773    fn parse_since_strips_trailing_description() {
1774        // phpstorm-stubs commonly writes `@since X.Y description text`.
1775        // Only the leading version token must reach the version parser.
1776        let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1777        assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1778    }
1779
1780    #[test]
1781    fn parse_removed_tag() {
1782        let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1783        assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1784    }
1785
1786    #[test]
1787    fn parse_since_empty_body_is_none() {
1788        let parsed = DocblockParser::parse("/** @since */");
1789        assert_eq!(parsed.since, None);
1790    }
1791
1792    #[test]
1793    fn parse_description() {
1794        let doc = r#"/**
1795         * This is a description.
1796         * Spans two lines.
1797         * @param string $x
1798         */"#;
1799        let parsed = DocblockParser::parse(doc);
1800        assert!(parsed.description.contains("This is a description"));
1801        assert!(parsed.description.contains("Spans two lines"));
1802    }
1803
1804    #[test]
1805    fn parse_see_and_link() {
1806        let doc = "/** @see SomeClass\n * @link https://example.com */";
1807        let parsed = DocblockParser::parse(doc);
1808        assert_eq!(parsed.see.len(), 2);
1809        assert!(parsed.see.contains(&"SomeClass".to_string()));
1810        assert!(parsed.see.contains(&"https://example.com".to_string()));
1811    }
1812
1813    #[test]
1814    fn parse_mixin() {
1815        let doc = "/** @mixin SomeTrait */";
1816        let parsed = DocblockParser::parse(doc);
1817        assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1818    }
1819
1820    #[test]
1821    fn parse_property_tags() {
1822        let doc = r#"/**
1823         * @property string $name
1824         * @property-read int $id
1825         * @property-write bool $active
1826         */"#;
1827        let parsed = DocblockParser::parse(doc);
1828        assert_eq!(parsed.properties.len(), 3);
1829        let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1830        assert_eq!(name_prop.type_hint, "string");
1831        assert!(!name_prop.read_only);
1832        assert!(!name_prop.write_only);
1833        let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1834        assert!(id_prop.read_only);
1835        let active_prop = parsed
1836            .properties
1837            .iter()
1838            .find(|p| p.name == "active")
1839            .unwrap();
1840        assert!(active_prop.write_only);
1841    }
1842
1843    #[test]
1844    fn parse_method_tag() {
1845        let doc = r#"/**
1846         * @method string getName()
1847         * @method static int create()
1848         */"#;
1849        let parsed = DocblockParser::parse(doc);
1850        assert_eq!(parsed.methods.len(), 2);
1851        let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1852        assert_eq!(get_name.return_type, "string");
1853        assert!(!get_name.is_static);
1854        let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1855        assert!(create.is_static);
1856    }
1857
1858    #[test]
1859    fn parse_method_tag_description_with_parens() {
1860        // Carbon-style: description text contains "(using date interval)" after the
1861        // closing paren of the method signature. The old rfind(')') would capture
1862        // the description's closing paren and produce a phantom parameter.
1863        let doc = r#"/**
1864         * @method $this addDay() Add one day to the instance (using date interval).
1865         * @method $this subDays(int|float $value = 1) Sub days (the $value count passed in).
1866         */"#;
1867        let parsed = DocblockParser::parse(doc);
1868        let add_day = parsed.methods.iter().find(|m| m.name == "addDay").unwrap();
1869        assert_eq!(add_day.params.len(), 0, "addDay() must have zero params");
1870        let sub_days = parsed.methods.iter().find(|m| m.name == "subDays").unwrap();
1871        assert_eq!(sub_days.params.len(), 1);
1872        assert!(sub_days.params[0].is_optional);
1873    }
1874
1875    #[test]
1876    fn parse_type_alias_tag() {
1877        let doc = "/** @psalm-type MyAlias = string|int */";
1878        let parsed = DocblockParser::parse(doc);
1879        assert_eq!(parsed.type_aliases.len(), 1);
1880        assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1881        assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1882    }
1883
1884    #[test]
1885    fn parse_import_type_no_as() {
1886        let doc = "/** @psalm-import-type UserId from UserRepository */";
1887        let parsed = DocblockParser::parse(doc);
1888        assert_eq!(parsed.import_types.len(), 1);
1889        assert_eq!(parsed.import_types[0].original, "UserId");
1890        assert_eq!(parsed.import_types[0].local, "UserId");
1891        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1892    }
1893
1894    #[test]
1895    fn parse_import_type_with_as() {
1896        let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1897        let parsed = DocblockParser::parse(doc);
1898        assert_eq!(parsed.import_types.len(), 1);
1899        assert_eq!(parsed.import_types[0].original, "UserId");
1900        assert_eq!(parsed.import_types[0].local, "LocalId");
1901        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1902    }
1903
1904    #[test]
1905    fn parse_require_extends() {
1906        let doc = "/** @psalm-require-extends Model */";
1907        let parsed = DocblockParser::parse(doc);
1908        assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1909    }
1910
1911    #[test]
1912    fn parse_require_implements() {
1913        let doc = "/** @psalm-require-implements Countable */";
1914        let parsed = DocblockParser::parse(doc);
1915        assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1916    }
1917
1918    #[test]
1919    fn parse_intersection_two_parts() {
1920        let u = parse_type_string("Iterator&Countable");
1921        assert_eq!(u.types.len(), 1);
1922        assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1923        if let Atomic::TIntersection { parts } = &u.types[0] {
1924            assert!(parts[0].contains(
1925                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1926            ));
1927            assert!(parts[1].contains(
1928                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1929            ));
1930        }
1931    }
1932
1933    #[test]
1934    fn parse_intersection_three_parts() {
1935        let u = parse_type_string("Iterator&Countable&Stringable");
1936        assert_eq!(u.types.len(), 1);
1937        let Atomic::TIntersection { parts } = &u.types[0] else {
1938            panic!("expected TIntersection");
1939        };
1940        assert_eq!(parts.len(), 3);
1941        assert!(parts[0].contains(
1942            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1943        ));
1944        assert!(parts[1].contains(
1945            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1946        ));
1947        assert!(parts[2].contains(
1948            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1949        ));
1950    }
1951
1952    #[test]
1953    fn parse_intersection_in_union_with_null() {
1954        let u = parse_type_string("Iterator&Countable|null");
1955        assert!(u.is_nullable());
1956        let intersection = u
1957            .types
1958            .iter()
1959            .find_map(|t| {
1960                if let Atomic::TIntersection { parts } = t {
1961                    Some(parts)
1962                } else {
1963                    None
1964                }
1965            })
1966            .expect("expected TIntersection");
1967        assert_eq!(intersection.len(), 2);
1968        assert!(intersection[0].contains(
1969            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1970        ));
1971        assert!(intersection[1].contains(
1972            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1973        ));
1974    }
1975
1976    #[test]
1977    fn parse_intersection_in_union_with_scalar() {
1978        let u = parse_type_string("Iterator&Countable|string");
1979        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1980        let intersection = u
1981            .types
1982            .iter()
1983            .find_map(|t| {
1984                if let Atomic::TIntersection { parts } = t {
1985                    Some(parts)
1986                } else {
1987                    None
1988                }
1989            })
1990            .expect("expected TIntersection");
1991        assert!(intersection[0].contains(
1992            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1993        ));
1994        assert!(intersection[1].contains(
1995            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1996        ));
1997    }
1998
1999    #[test]
2000    fn validate_unclosed_generic_return() {
2001        let parsed = DocblockParser::parse("/** @return array< */");
2002        assert_eq!(parsed.invalid_annotations.len(), 1);
2003        assert!(
2004            parsed.invalid_annotations[0].contains("unclosed generic"),
2005            "got: {}",
2006            parsed.invalid_annotations[0]
2007        );
2008    }
2009
2010    #[test]
2011    fn parse_empty_generic_array_graceful() {
2012        let u = parse_type_string("array<>");
2013        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2014    }
2015
2016    #[test]
2017    fn parse_empty_generic_iterable_graceful() {
2018        let u = parse_type_string("iterable<>");
2019        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2020    }
2021
2022    #[test]
2023    fn parse_empty_generic_non_empty_array_graceful() {
2024        let u = parse_type_string("non-empty-array<>");
2025        assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
2026    }
2027
2028    #[test]
2029    fn validate_variable_in_type_position_param() {
2030        let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
2031        assert_eq!(parsed.invalid_annotations.len(), 1);
2032        assert!(
2033            parsed.invalid_annotations[0].contains("$invalid"),
2034            "got: {}",
2035            parsed.invalid_annotations[0]
2036        );
2037    }
2038
2039    #[test]
2040    fn validate_this_is_valid_in_type_position() {
2041        let parsed = DocblockParser::parse("/** @return $this */");
2042        assert!(
2043            parsed.invalid_annotations.is_empty(),
2044            "unexpected error: {:?}",
2045            parsed.invalid_annotations
2046        );
2047    }
2048
2049    #[test]
2050    fn validate_unclosed_generic_var() {
2051        let parsed = DocblockParser::parse("/** @var array<string */");
2052        assert_eq!(parsed.invalid_annotations.len(), 1);
2053        assert!(parsed.invalid_annotations[0].contains("@var"));
2054    }
2055
2056    #[test]
2057    fn validate_variable_in_template_bound() {
2058        let parsed = DocblockParser::parse("/** @template T of $invalid */");
2059        assert_eq!(parsed.invalid_annotations.len(), 1);
2060        assert!(parsed.invalid_annotations[0].contains("$invalid"));
2061    }
2062}