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