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