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