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