Skip to main content

mir_analyzer/parser/
docblock.rs

1use mir_types::{ArrayKey, Atomic, Union, Variance};
2/// Docblock parser — delegates to `php_rs_parser::phpdoc` for tag extraction,
3/// then converts `PhpDocTag`s into mir's `ParsedDocblock` with resolved types.
4use std::sync::Arc;
5
6use indexmap::IndexMap;
7
8use php_rs_parser::phpdoc::PhpDocTag;
9
10// ---------------------------------------------------------------------------
11// DocblockParser
12// ---------------------------------------------------------------------------
13
14pub struct DocblockParser;
15
16impl DocblockParser {
17    pub fn parse(text: &str) -> ParsedDocblock {
18        let doc = php_rs_parser::phpdoc::parse(text);
19        let mut result = ParsedDocblock {
20            description: extract_description(text),
21            ..Default::default()
22        };
23
24        for tag in &doc.tags {
25            match tag {
26                PhpDocTag::Param {
27                    type_str: Some(ty_s),
28                    name: Some(n),
29                    ..
30                } => {
31                    if let Some(msg) = validate_type_str(ty_s, "param") {
32                        result.invalid_annotations.push(msg);
33                    }
34                    result.params.push((
35                        n.trim_start_matches('$').to_string(),
36                        parse_type_string(ty_s),
37                    ));
38                }
39                // @param with a type but no variable name — can happen when an unclosed generic
40                // swallows the rest of the tag body (e.g. `@param array< $x`).
41                PhpDocTag::Param {
42                    type_str: Some(ty_s),
43                    name: None,
44                    ..
45                } => {
46                    if let Some(msg) = validate_type_str(ty_s, "param") {
47                        result.invalid_annotations.push(msg);
48                    }
49                }
50                PhpDocTag::Return {
51                    type_str: Some(ty_s),
52                    ..
53                } => {
54                    if let Some(msg) = validate_type_str(ty_s, "return") {
55                        result.invalid_annotations.push(msg);
56                    }
57                    result.return_type = Some(parse_type_string(ty_s));
58                }
59                PhpDocTag::Var { type_str, name, .. } => {
60                    if let Some(ty_s) = type_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                    }
66                    if let Some(n) = name {
67                        result.var_name = Some(n.trim_start_matches('$').to_string());
68                    }
69                }
70                PhpDocTag::Throws {
71                    type_str: Some(ty_s),
72                    ..
73                } => {
74                    let class = ty_s.split_whitespace().next().unwrap_or("").to_string();
75                    if !class.is_empty() {
76                        result.throws.push(class);
77                    }
78                }
79                PhpDocTag::Deprecated { description } => {
80                    result.is_deprecated = true;
81                    result.deprecated = Some(
82                        description
83                            .as_ref()
84                            .map(|d| d.to_string())
85                            .unwrap_or_default(),
86                    );
87                }
88                PhpDocTag::Template { name, bound } => {
89                    if let Some(b) = bound {
90                        if let Some(msg) = validate_type_str(b, "template") {
91                            result.invalid_annotations.push(msg);
92                        }
93                    }
94                    result.templates.push((
95                        name.to_string(),
96                        bound.map(parse_type_string),
97                        Variance::Invariant,
98                    ));
99                }
100                PhpDocTag::TemplateCovariant { name, bound } => {
101                    if let Some(b) = bound {
102                        if let Some(msg) = validate_type_str(b, "template-covariant") {
103                            result.invalid_annotations.push(msg);
104                        }
105                    }
106                    result.templates.push((
107                        name.to_string(),
108                        bound.map(parse_type_string),
109                        Variance::Covariant,
110                    ));
111                }
112                PhpDocTag::TemplateContravariant { name, bound } => {
113                    if let Some(b) = bound {
114                        if let Some(msg) = validate_type_str(b, "template-contravariant") {
115                            result.invalid_annotations.push(msg);
116                        }
117                    }
118                    result.templates.push((
119                        name.to_string(),
120                        bound.map(parse_type_string),
121                        Variance::Contravariant,
122                    ));
123                }
124                PhpDocTag::Extends { type_str } => {
125                    result.extends = Some(parse_type_string(type_str));
126                }
127                PhpDocTag::Implements { type_str } => {
128                    result.implements.push(parse_type_string(type_str));
129                }
130                PhpDocTag::Assert {
131                    type_str: Some(ty_s),
132                    name: Some(n),
133                } => {
134                    result.assertions.push((
135                        n.trim_start_matches('$').to_string(),
136                        parse_type_string(ty_s),
137                    ));
138                }
139                PhpDocTag::Suppress { rules } => {
140                    for rule in rules.split([',', ' ']) {
141                        let rule = rule.trim().to_string();
142                        if !rule.is_empty() {
143                            result.suppressed_issues.push(rule);
144                        }
145                    }
146                }
147                PhpDocTag::See { reference } => result.see.push(reference.to_string()),
148                PhpDocTag::Link { url } => result.see.push(url.to_string()),
149                PhpDocTag::Mixin { class } => result.mixins.push(class.to_string()),
150                PhpDocTag::Property {
151                    type_str,
152                    name: Some(n),
153                    ..
154                } => result.properties.push(DocProperty {
155                    type_hint: type_str.unwrap_or("").to_string(),
156                    name: n.trim_start_matches('$').to_string(),
157                    read_only: false,
158                    write_only: false,
159                }),
160                PhpDocTag::PropertyRead {
161                    type_str,
162                    name: Some(n),
163                    ..
164                } => result.properties.push(DocProperty {
165                    type_hint: type_str.unwrap_or("").to_string(),
166                    name: n.trim_start_matches('$').to_string(),
167                    read_only: true,
168                    write_only: false,
169                }),
170                PhpDocTag::PropertyWrite {
171                    type_str,
172                    name: Some(n),
173                    ..
174                } => result.properties.push(DocProperty {
175                    type_hint: type_str.unwrap_or("").to_string(),
176                    name: n.trim_start_matches('$').to_string(),
177                    read_only: false,
178                    write_only: true,
179                }),
180                PhpDocTag::Method { signature } => {
181                    if let Some(m) = parse_method_line(signature) {
182                        result.methods.push(m);
183                    }
184                }
185                PhpDocTag::TypeAlias {
186                    name: Some(n),
187                    type_str,
188                } => result.type_aliases.push(DocTypeAlias {
189                    name: n.to_string(),
190                    type_expr: type_str.unwrap_or("").to_string(),
191                }),
192                PhpDocTag::ImportType { body } => {
193                    if let Some(import) = parse_import_type(body) {
194                        result.import_types.push(import);
195                    }
196                }
197                PhpDocTag::Since { version } if result.since.is_none() => {
198                    // `version` is the full tag body, e.g. `"5.2.4 PHP 5.2.4 introduced…"`.
199                    // Keep only the leading version token so `PhpVersion::from_str` can parse it.
200                    let v = version.split_whitespace().next().unwrap_or("");
201                    if !v.is_empty() {
202                        result.since = Some(v.to_string());
203                    }
204                }
205                PhpDocTag::Internal => result.is_internal = true,
206                PhpDocTag::Pure => result.is_pure = true,
207                PhpDocTag::Immutable => result.is_immutable = true,
208                PhpDocTag::Readonly => result.is_readonly = true,
209                PhpDocTag::Generic { tag, body } => match *tag {
210                    "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
211                    "api" | "psalm-api" => result.is_api = true,
212                    "removed" if result.removed.is_none() => {
213                        if let Some(b) = body {
214                            let v = b.split_whitespace().next().unwrap_or("");
215                            if !v.is_empty() {
216                                result.removed = Some(v.to_string());
217                            }
218                        }
219                    }
220                    "psalm-assert" | "phpstan-assert" => {
221                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
222                            result.assertions.push((name, parse_type_string(&ty_str)));
223                        }
224                    }
225                    "psalm-assert-if-true" | "phpstan-assert-if-true" => {
226                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
227                            result
228                                .assertions_if_true
229                                .push((name, parse_type_string(&ty_str)));
230                        }
231                    }
232                    "psalm-assert-if-false" | "phpstan-assert-if-false" => {
233                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
234                            result
235                                .assertions_if_false
236                                .push((name, parse_type_string(&ty_str)));
237                        }
238                    }
239                    "psalm-property" => {
240                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
241                            result.properties.push(DocProperty {
242                                type_hint: ty_str,
243                                name,
244                                read_only: false,
245                                write_only: false,
246                            });
247                        }
248                    }
249                    "psalm-property-read" => {
250                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
251                            result.properties.push(DocProperty {
252                                type_hint: ty_str,
253                                name,
254                                read_only: true,
255                                write_only: false,
256                            });
257                        }
258                    }
259                    "psalm-property-write" => {
260                        if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
261                            result.properties.push(DocProperty {
262                                type_hint: ty_str,
263                                name,
264                                read_only: false,
265                                write_only: true,
266                            });
267                        }
268                    }
269                    "psalm-method" => {
270                        if let Some(method) = body.as_deref().and_then(parse_method_line) {
271                            result.methods.push(method);
272                        }
273                    }
274                    "psalm-require-extends" | "phpstan-require-extends" => {
275                        if let Some(b) = body {
276                            let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
277                            if !cls.is_empty() {
278                                result.require_extends.push(cls);
279                            }
280                        }
281                    }
282                    "psalm-require-implements" | "phpstan-require-implements" => {
283                        if let Some(b) = body {
284                            let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
285                            if !cls.is_empty() {
286                                result.require_implements.push(cls);
287                            }
288                        }
289                    }
290                    _ => {}
291                },
292                _ => {}
293            }
294        }
295
296        if text.to_ascii_lowercase().contains("{@inheritdoc}") {
297            result.is_inherit_doc = true;
298        }
299
300        result
301    }
302}
303
304// ---------------------------------------------------------------------------
305// ParsedDocblock support types
306// ---------------------------------------------------------------------------
307
308#[derive(Debug, Default, Clone)]
309pub struct DocProperty {
310    pub type_hint: String,
311    pub name: String,     // without leading $
312    pub read_only: bool,  // true for @property-read
313    pub write_only: bool, // true for @property-write
314}
315
316#[derive(Debug, Default, Clone)]
317pub struct DocMethod {
318    pub return_type: String,
319    pub name: String,
320    pub is_static: bool,
321    pub params: Vec<DocMethodParam>,
322}
323
324#[derive(Debug, Default, Clone)]
325pub struct DocMethodParam {
326    pub name: String,
327    pub type_hint: String,
328    pub is_variadic: bool,
329    pub is_byref: bool,
330    pub is_optional: bool,
331}
332
333#[derive(Debug, Default, Clone)]
334pub struct DocTypeAlias {
335    pub name: String,
336    pub type_expr: String,
337}
338
339#[derive(Debug, Default, Clone)]
340pub struct DocImportType {
341    /// The name exported by the source class (the original alias name).
342    pub original: String,
343    /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
344    pub local: String,
345    /// The FQCN of the class to import the type from.
346    pub from_class: String,
347}
348
349// ---------------------------------------------------------------------------
350// ParsedDocblock
351// ---------------------------------------------------------------------------
352
353#[derive(Debug, Default, Clone)]
354pub struct ParsedDocblock {
355    /// `@param Type $name`
356    pub params: Vec<(String, Union)>,
357    /// `@return Type`
358    pub return_type: Option<Union>,
359    /// `@var Type` or `@var Type $name` — type and optional variable name
360    pub var_type: Option<Union>,
361    /// Optional variable name from `@var Type $name`
362    pub var_name: Option<String>,
363    /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
364    pub templates: Vec<(String, Option<Union>, Variance)>,
365    /// `@extends ClassName<T>`
366    pub extends: Option<Union>,
367    /// `@implements InterfaceName<T>`
368    pub implements: Vec<Union>,
369    /// `@throws ClassName`
370    pub throws: Vec<String>,
371    /// `@psalm-assert Type $var`
372    pub assertions: Vec<(String, Union)>,
373    /// `@psalm-assert-if-true Type $var`
374    pub assertions_if_true: Vec<(String, Union)>,
375    /// `@psalm-assert-if-false Type $var`
376    pub assertions_if_false: Vec<(String, Union)>,
377    /// `@psalm-suppress IssueName`
378    pub suppressed_issues: Vec<String>,
379    pub is_deprecated: bool,
380    pub is_internal: bool,
381    pub is_pure: bool,
382    pub is_immutable: bool,
383    pub is_readonly: bool,
384    pub is_api: bool,
385    /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
386    /// inherited from the nearest ancestor that has a real docblock.
387    pub is_inherit_doc: bool,
388    /// Free text before first `@` tag — used for hover display
389    pub description: String,
390    /// `@deprecated message` — Some(message) or Some("") if no message
391    pub deprecated: Option<String>,
392    /// `@see ClassName` / `@link URL`
393    pub see: Vec<String>,
394    /// `@mixin ClassName`
395    pub mixins: Vec<String>,
396    /// `@property`, `@property-read`, `@property-write`
397    pub properties: Vec<DocProperty>,
398    /// `@method [static] ReturnType name([params])`
399    pub methods: Vec<DocMethod>,
400    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
401    pub type_aliases: Vec<DocTypeAlias>,
402    /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
403    pub import_types: Vec<DocImportType>,
404    /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
405    pub require_extends: Vec<String>,
406    /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
407    pub require_implements: Vec<String>,
408    /// `@since X.Y` — first PHP version this symbol exists in.
409    pub since: Option<String>,
410    /// `@removed X.Y` — first PHP version this symbol no longer exists in.
411    pub removed: Option<String>,
412    /// Malformed type annotations detected during parsing.
413    pub invalid_annotations: Vec<String>,
414}
415
416impl ParsedDocblock {
417    /// Returns the type for a given parameter name (strips leading `$`).
418    ///
419    /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
420    /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
421    /// preceding plain `@param` annotation.
422    pub fn get_param_type(&self, name: &str) -> Option<&Union> {
423        let name = name.trim_start_matches('$');
424        self.params
425            .iter()
426            .rfind(|(n, _)| n.trim_start_matches('$') == name)
427            .map(|(_, ty)| ty)
428    }
429}
430
431// ---------------------------------------------------------------------------
432// Type string parser
433// ---------------------------------------------------------------------------
434
435/// Parse a PHPDoc type expression string into a `Union`.
436/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
437/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
438pub fn parse_type_string(s: &str) -> Union {
439    let s = s.trim();
440
441    // Nullable shorthand: `?Type`
442    if let Some(inner) = s.strip_prefix('?') {
443        let inner_ty = parse_type_string(inner);
444        let mut u = inner_ty;
445        u.add_type(Atomic::TNull);
446        return u;
447    }
448
449    // Union: `A|B|C`
450    if s.contains('|') && !is_inside_generics(s) {
451        let parts = split_union(s);
452        if parts.len() > 1 {
453            let mut u = Union::empty();
454            for part in parts {
455                for atomic in parse_type_string(&part).types {
456                    u.add_type(atomic);
457                }
458            }
459            return u;
460        }
461    }
462
463    // Intersection: `A&B&C` — PHP 8.1+ pure intersection type
464    if s.contains('&') && !is_inside_generics(s) {
465        let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
466        return Union::single(Atomic::TIntersection { parts });
467    }
468
469    // Array shorthand: `Type[]` or `Type[][]`
470    if let Some(value_str) = s.strip_suffix("[]") {
471        let value = parse_type_string(value_str);
472        return Union::single(Atomic::TArray {
473            key: Box::new(Union::single(Atomic::TInt)),
474            value: Box::new(value),
475        });
476    }
477
478    // Callable/closure syntax: `Closure(T): R` or `callable(T): R`
479    if let Some(call_ty) = parse_callable_syntax(s) {
480        return call_ty;
481    }
482
483    // Array shape: `array{key: Type, ...}` or `list{Type, ...}`
484    if s.ends_with('}') {
485        if let Some(open) = s.find('{') {
486            let prefix = s[..open].to_lowercase();
487            let inner = &s[open + 1..s.len() - 1];
488            if prefix == "array" {
489                return parse_keyed_array(inner, false);
490            } else if prefix == "list" {
491                return parse_keyed_array(inner, true);
492            }
493        }
494    }
495
496    // Generic: `name<...>`
497    if let Some(open) = s.find('<') {
498        if s.ends_with('>') {
499            let name = &s[..open];
500            let inner = &s[open + 1..s.len() - 1];
501            return parse_generic(name, inner);
502        }
503    }
504
505    // Keywords
506    match s.to_lowercase().as_str() {
507        "string" => Union::single(Atomic::TString),
508        "non-empty-string" => Union::single(Atomic::TNonEmptyString),
509        "numeric-string" => Union::single(Atomic::TNumericString),
510        "class-string" => Union::single(Atomic::TClassString(None)),
511        "int" | "integer" => Union::single(Atomic::TInt),
512        "positive-int" => Union::single(Atomic::TPositiveInt),
513        "negative-int" => Union::single(Atomic::TNegativeInt),
514        "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
515        "float" | "double" => Union::single(Atomic::TFloat),
516        "bool" | "boolean" => Union::single(Atomic::TBool),
517        "true" => Union::single(Atomic::TTrue),
518        "false" => Union::single(Atomic::TFalse),
519        "null" => Union::single(Atomic::TNull),
520        "void" => Union::single(Atomic::TVoid),
521        "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
522        "mixed" => Union::single(Atomic::TMixed),
523        "object" => Union::single(Atomic::TObject),
524        "array" => Union::single(Atomic::TArray {
525            key: Box::new(Union::single(Atomic::TMixed)),
526            value: Box::new(Union::mixed()),
527        }),
528        "list" => Union::single(Atomic::TList {
529            value: Box::new(Union::mixed()),
530        }),
531        "callable" => Union::single(Atomic::TCallable {
532            params: None,
533            return_type: None,
534        }),
535        "iterable" => Union::single(Atomic::TArray {
536            key: Box::new(Union::single(Atomic::TMixed)),
537            value: Box::new(Union::mixed()),
538        }),
539        "scalar" => Union::single(Atomic::TScalar),
540        "numeric" => Union::single(Atomic::TNumeric),
541        "resource" => Union::mixed(), // treat as mixed
542        // self/static/parent: emit sentinel with empty FQCN; collector fills it in.
543        "static" => Union::single(Atomic::TStaticObject {
544            fqcn: Arc::from(""),
545        }),
546        "self" | "$this" => Union::single(Atomic::TSelf {
547            fqcn: Arc::from(""),
548        }),
549        "parent" => Union::single(Atomic::TParent {
550            fqcn: Arc::from(""),
551        }),
552
553        // Named class
554        _ if !s.is_empty()
555            && s.chars()
556                .next()
557                .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
558                .unwrap_or(false) =>
559        {
560            Union::single(Atomic::TNamedObject {
561                fqcn: normalize_fqcn(s).into(),
562                type_params: vec![],
563            })
564        }
565
566        _ => Union::mixed(),
567    }
568}
569
570fn parse_generic(name: &str, inner: &str) -> Union {
571    match name.to_lowercase().as_str() {
572        "array" => {
573            let params = split_generics(inner);
574            let (key, value) = if params.len() >= 2 {
575                (
576                    parse_type_string(params[0].trim()),
577                    parse_type_string(params[1].trim()),
578                )
579            } else {
580                (
581                    Union::single(Atomic::TInt),
582                    parse_type_string(params[0].trim()),
583                )
584            };
585            Union::single(Atomic::TArray {
586                key: Box::new(key),
587                value: Box::new(value),
588            })
589        }
590        "list" | "non-empty-list" => {
591            let value = parse_type_string(inner.trim());
592            if name.to_lowercase().starts_with("non-empty") {
593                Union::single(Atomic::TNonEmptyList {
594                    value: Box::new(value),
595                })
596            } else {
597                Union::single(Atomic::TList {
598                    value: Box::new(value),
599                })
600            }
601        }
602        "non-empty-array" => {
603            let params = split_generics(inner);
604            let (key, value) = if params.len() >= 2 {
605                (
606                    parse_type_string(params[0].trim()),
607                    parse_type_string(params[1].trim()),
608                )
609            } else {
610                (
611                    Union::single(Atomic::TInt),
612                    parse_type_string(params[0].trim()),
613                )
614            };
615            Union::single(Atomic::TNonEmptyArray {
616                key: Box::new(key),
617                value: Box::new(value),
618            })
619        }
620        "iterable" => {
621            let params = split_generics(inner);
622            let value = if params.len() >= 2 {
623                parse_type_string(params[1].trim())
624            } else {
625                parse_type_string(params[0].trim())
626            };
627            Union::single(Atomic::TArray {
628                key: Box::new(Union::single(Atomic::TMixed)),
629                value: Box::new(value),
630            })
631        }
632        "class-string" => Union::single(Atomic::TClassString(Some(
633            normalize_fqcn(inner.trim()).into(),
634        ))),
635        "int" => {
636            // int<min, max>
637            Union::single(Atomic::TIntRange {
638                min: None,
639                max: None,
640            })
641        }
642        // Named class with type params
643        _ => {
644            let params: Vec<Union> = split_generics(inner)
645                .iter()
646                .map(|p| parse_type_string(p.trim()))
647                .collect();
648            Union::single(Atomic::TNamedObject {
649                fqcn: normalize_fqcn(name).into(),
650                type_params: params,
651            })
652        }
653    }
654}
655
656fn parse_keyed_array(inner: &str, is_list: bool) -> Union {
657    use mir_types::atomic::KeyedProperty;
658    let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
659    let mut is_open = false;
660    let mut auto_index = 0i64;
661
662    for item in split_generics(inner) {
663        let item = item.trim();
664        if item.is_empty() {
665            continue;
666        }
667        if item == "..." {
668            is_open = true;
669            continue;
670        }
671        // Find a colon that is not inside nested generics/braces
672        let colon_pos = {
673            let mut depth = 0i32;
674            let mut found = None;
675            for (i, ch) in item.char_indices() {
676                match ch {
677                    '<' | '(' | '{' => depth += 1,
678                    '>' | ')' | '}' => depth -= 1,
679                    ':' if depth == 0 => {
680                        found = Some(i);
681                        break;
682                    }
683                    _ => {}
684                }
685            }
686            found
687        };
688        if let Some(colon) = colon_pos {
689            let key_part = item[..colon].trim();
690            let ty_part = item[colon + 1..].trim();
691            let optional = key_part.ends_with('?');
692            let key_str = key_part.trim_end_matches('?').trim();
693            let key = if let Ok(n) = key_str.parse::<i64>() {
694                ArrayKey::Int(n)
695            } else {
696                ArrayKey::String(Arc::from(key_str))
697            };
698            properties.insert(
699                key,
700                KeyedProperty {
701                    ty: parse_type_string(ty_part),
702                    optional,
703                },
704            );
705        } else {
706            properties.insert(
707                ArrayKey::Int(auto_index),
708                KeyedProperty {
709                    ty: parse_type_string(item),
710                    optional: false,
711                },
712            );
713            auto_index += 1;
714        }
715    }
716
717    Union::single(Atomic::TKeyedArray {
718        properties,
719        is_open,
720        is_list,
721    })
722}
723
724fn parse_callable_syntax(s: &str) -> Option<Union> {
725    let s = s.trim_start_matches('\\');
726    let lower = s.to_lowercase();
727    let is_closure = lower.starts_with("closure");
728    let is_callable = lower.starts_with("callable");
729    if !is_closure && !is_callable {
730        return None;
731    }
732    let prefix_len = if is_closure {
733        "closure".len()
734    } else {
735        "callable".len()
736    };
737    let rest = s[prefix_len..].trim_start();
738    if !rest.starts_with('(') {
739        return None;
740    }
741    let close = find_matching_paren(rest)?;
742    let params_str = &rest[1..close];
743    let after = rest[close + 1..].trim();
744    let return_type = after
745        .strip_prefix(':')
746        .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
747    let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
748        .into_iter()
749        .enumerate()
750        .filter(|(_, p)| !p.trim().is_empty())
751        .map(|(i, p)| {
752            let p = p.trim();
753            let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
754                (p[..dollar].trim(), p[dollar + 1..].to_string())
755            } else {
756                (p, format!("arg{i}"))
757            };
758            mir_types::atomic::FnParam {
759                name: name.into(),
760                ty: Some(parse_type_string(ty_str)),
761                default: None,
762                is_variadic: false,
763                is_byref: false,
764                is_optional: false,
765            }
766        })
767        .collect();
768    if is_closure {
769        Some(Union::single(Atomic::TClosure {
770            params,
771            return_type: return_type.unwrap_or_else(|| Box::new(Union::single(Atomic::TVoid))),
772            this_type: None,
773        }))
774    } else {
775        Some(Union::single(Atomic::TCallable {
776            params: Some(params),
777            return_type,
778        }))
779    }
780}
781
782fn find_matching_paren(s: &str) -> Option<usize> {
783    if !s.starts_with('(') {
784        return None;
785    }
786    let mut depth = 0i32;
787    for (i, ch) in s.char_indices() {
788        match ch {
789            '(' | '<' | '{' => depth += 1,
790            ')' | '>' | '}' => {
791                depth -= 1;
792                if depth == 0 {
793                    return Some(i);
794                }
795            }
796            _ => {}
797        }
798    }
799    None
800}
801
802// ---------------------------------------------------------------------------
803// Helpers
804// ---------------------------------------------------------------------------
805
806/// Extract the description text (all prose before the first `@` tag) from a raw docblock.
807fn extract_description(text: &str) -> String {
808    let mut desc_lines: Vec<&str> = Vec::new();
809    for line in text.lines() {
810        let l = line.trim();
811        let l = l.trim_start_matches("/**").trim();
812        let l = l.trim_end_matches("*/").trim();
813        let l = l.trim_start_matches("*/").trim();
814        let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
815        let l = l.trim();
816        if l.starts_with('@') {
817            break;
818        }
819        if !l.is_empty() {
820            desc_lines.push(l);
821        }
822    }
823    desc_lines.join(" ")
824}
825
826/// Parse `@psalm-import-type` body.
827///
828/// Formats:
829/// - `AliasName from SourceClass`
830/// - `AliasName as LocalAlias from SourceClass`
831fn parse_import_type(body: &str) -> Option<DocImportType> {
832    // Split on " from " (with spaces to avoid matching partial words)
833    let (before_from, from_class_raw) = body.split_once(" from ")?;
834    let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
835    if from_class.is_empty() {
836        return None;
837    }
838    // Check for " as " in before_from
839    let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
840        (orig.trim().to_string(), loc.trim().to_string())
841    } else {
842        let name = before_from.trim().to_string();
843        (name.clone(), name)
844    };
845    if original.is_empty() || local.is_empty() {
846        return None;
847    }
848    Some(DocImportType {
849        original,
850        local,
851        from_class,
852    })
853}
854
855fn parse_param_line(s: &str) -> Option<(String, String)> {
856    // Formats: `Type $name`, `Type $name description`
857    let mut parts = s.splitn(3, char::is_whitespace);
858    let ty = parts.next()?.trim().to_string();
859    let name = parts.next()?.trim().trim_start_matches('$').to_string();
860    if ty.is_empty() || name.is_empty() {
861        return None;
862    }
863    Some((ty, name))
864}
865
866fn split_union(s: &str) -> Vec<String> {
867    let mut parts = Vec::new();
868    let mut depth = 0;
869    let mut current = String::new();
870    for ch in s.chars() {
871        match ch {
872            '<' | '(' | '{' => {
873                depth += 1;
874                current.push(ch);
875            }
876            '>' | ')' | '}' => {
877                depth -= 1;
878                current.push(ch);
879            }
880            '|' if depth == 0 => {
881                parts.push(current.trim().to_string());
882                current = String::new();
883            }
884            _ => current.push(ch),
885        }
886    }
887    if !current.trim().is_empty() {
888        parts.push(current.trim().to_string());
889    }
890    parts
891}
892
893fn split_generics(s: &str) -> Vec<String> {
894    let mut parts = Vec::new();
895    let mut depth = 0;
896    let mut current = String::new();
897    for ch in s.chars() {
898        match ch {
899            '<' | '(' | '{' => {
900                depth += 1;
901                current.push(ch);
902            }
903            '>' | ')' | '}' => {
904                depth -= 1;
905                current.push(ch);
906            }
907            ',' if depth == 0 => {
908                parts.push(current.trim().to_string());
909                current = String::new();
910            }
911            _ => current.push(ch),
912        }
913    }
914    if !current.trim().is_empty() {
915        parts.push(current.trim().to_string());
916    }
917    parts
918}
919
920fn is_inside_generics(s: &str) -> bool {
921    let mut depth = 0i32;
922    for ch in s.chars() {
923        match ch {
924            '<' | '(' | '{' => depth += 1,
925            '>' | ')' | '}' => depth -= 1,
926            _ => {}
927        }
928    }
929    depth != 0
930}
931
932fn normalize_fqcn(s: &str) -> String {
933    // Strip leading backslash if present — we normalize all FQCNs without leading `\`
934    s.trim_start_matches('\\').to_string()
935}
936
937/// Returns an error message if `s` is a malformed PHPDoc type expression, otherwise `None`.
938///
939/// Detects:
940/// - unclosed generics (`array<`, `Foo<Bar`)
941/// - `$variable` in type position (only `$this` is valid)
942fn validate_type_str(s: &str, tag: &str) -> Option<String> {
943    let s = s.trim();
944    if s.is_empty() {
945        return None;
946    }
947    if is_inside_generics(s) {
948        return Some(format!("@{tag} has unclosed generic type `{s}`"));
949    }
950    for part in split_union(s) {
951        let p = part.trim();
952        if p.starts_with('$') && p != "$this" {
953            return Some(format!("@{tag} contains variable `{p}` in type position"));
954        }
955    }
956    None
957}
958
959/// Parse `[static] [ReturnType] name(...)` for @method tags.
960fn parse_method_line(s: &str) -> Option<DocMethod> {
961    let mut rest = s.trim();
962    if rest.is_empty() {
963        return None;
964    }
965    let is_static = rest
966        .split_whitespace()
967        .next()
968        .map(|w| w.eq_ignore_ascii_case("static"))
969        .unwrap_or(false);
970    if is_static {
971        rest = rest["static".len()..].trim_start();
972    }
973
974    let open = rest.find('(').unwrap_or(rest.len());
975    let prefix = rest[..open].trim();
976    let mut parts: Vec<&str> = prefix.split_whitespace().collect();
977    let name = parts.pop()?.to_string();
978    if name.is_empty() {
979        return None;
980    }
981    let return_type = parts.join(" ");
982    Some(DocMethod {
983        return_type,
984        name,
985        is_static,
986        params: parse_method_params(rest),
987    })
988}
989
990fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
991    let Some(open) = name_part.find('(') else {
992        return vec![];
993    };
994    let Some(close) = name_part.rfind(')') else {
995        return vec![];
996    };
997    let inner = name_part[open + 1..close].trim();
998    if inner.is_empty() {
999        return vec![];
1000    }
1001
1002    split_generics(inner)
1003        .into_iter()
1004        .filter_map(|param| parse_method_param(&param))
1005        .collect()
1006}
1007
1008fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1009    let before_default = param.split('=').next()?.trim();
1010    let is_optional = param.contains('=');
1011    let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1012    let raw_name = tokens.pop()?;
1013    let is_variadic = raw_name.contains("...");
1014    let is_byref = raw_name.contains('&');
1015    let name = raw_name
1016        .trim_start_matches('&')
1017        .trim_start_matches("...")
1018        .trim_start_matches('&')
1019        .trim_start_matches('$')
1020        .to_string();
1021    if name.is_empty() {
1022        return None;
1023    }
1024    Some(DocMethodParam {
1025        name,
1026        type_hint: tokens.join(" "),
1027        is_variadic,
1028        is_byref,
1029        is_optional: is_optional || is_variadic,
1030    })
1031}
1032
1033// ---------------------------------------------------------------------------
1034// Tests
1035// ---------------------------------------------------------------------------
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040    use mir_types::Atomic;
1041
1042    #[test]
1043    fn parse_string() {
1044        let u = parse_type_string("string");
1045        assert_eq!(u.types.len(), 1);
1046        assert!(matches!(u.types[0], Atomic::TString));
1047    }
1048
1049    #[test]
1050    fn parse_nullable_string() {
1051        let u = parse_type_string("?string");
1052        assert!(u.is_nullable());
1053        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1054    }
1055
1056    #[test]
1057    fn parse_union() {
1058        let u = parse_type_string("string|int|null");
1059        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1060        assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1061        assert!(u.is_nullable());
1062    }
1063
1064    #[test]
1065    fn parse_array_of_string() {
1066        let u = parse_type_string("array<string>");
1067        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1068    }
1069
1070    #[test]
1071    fn parse_list_of_int() {
1072        let u = parse_type_string("list<int>");
1073        assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1074    }
1075
1076    #[test]
1077    fn parse_named_class() {
1078        let u = parse_type_string("Foo\\Bar");
1079        assert!(u.contains(
1080            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1081        ));
1082    }
1083
1084    #[test]
1085    fn parse_docblock_param_return() {
1086        let doc = r#"/**
1087         * @param string $name
1088         * @param int $age
1089         * @return bool
1090         */"#;
1091        let parsed = DocblockParser::parse(doc);
1092        assert_eq!(parsed.params.len(), 2);
1093        assert!(parsed.return_type.is_some());
1094        let ret = parsed.return_type.unwrap();
1095        assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1096    }
1097
1098    #[test]
1099    fn parse_template() {
1100        let doc = "/** @template T of object */";
1101        let parsed = DocblockParser::parse(doc);
1102        assert_eq!(parsed.templates.len(), 1);
1103        assert_eq!(parsed.templates[0].0, "T");
1104        assert!(parsed.templates[0].1.is_some());
1105        assert_eq!(parsed.templates[0].2, Variance::Invariant);
1106    }
1107
1108    #[test]
1109    fn parse_template_covariant() {
1110        let doc = "/** @template-covariant T */";
1111        let parsed = DocblockParser::parse(doc);
1112        assert_eq!(parsed.templates.len(), 1);
1113        assert_eq!(parsed.templates[0].0, "T");
1114        assert_eq!(parsed.templates[0].2, Variance::Covariant);
1115    }
1116
1117    #[test]
1118    fn parse_template_contravariant() {
1119        let doc = "/** @template-contravariant T */";
1120        let parsed = DocblockParser::parse(doc);
1121        assert_eq!(parsed.templates.len(), 1);
1122        assert_eq!(parsed.templates[0].0, "T");
1123        assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1124    }
1125
1126    #[test]
1127    fn parse_deprecated() {
1128        let doc = "/** @deprecated use newMethod() instead */";
1129        let parsed = DocblockParser::parse(doc);
1130        assert!(parsed.is_deprecated);
1131        assert_eq!(
1132            parsed.deprecated.as_deref(),
1133            Some("use newMethod() instead")
1134        );
1135    }
1136
1137    #[test]
1138    fn parse_since_plain() {
1139        let parsed = DocblockParser::parse("/** @since 8.0 */");
1140        assert_eq!(parsed.since.as_deref(), Some("8.0"));
1141        assert_eq!(parsed.removed, None);
1142    }
1143
1144    #[test]
1145    fn parse_since_strips_trailing_description() {
1146        // phpstorm-stubs commonly writes `@since X.Y description text`.
1147        // Only the leading version token must reach the version parser.
1148        let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1149        assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1150    }
1151
1152    #[test]
1153    fn parse_removed_tag() {
1154        let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1155        assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1156    }
1157
1158    #[test]
1159    fn parse_since_empty_body_is_none() {
1160        let parsed = DocblockParser::parse("/** @since */");
1161        assert_eq!(parsed.since, None);
1162    }
1163
1164    #[test]
1165    fn parse_description() {
1166        let doc = r#"/**
1167         * This is a description.
1168         * Spans two lines.
1169         * @param string $x
1170         */"#;
1171        let parsed = DocblockParser::parse(doc);
1172        assert!(parsed.description.contains("This is a description"));
1173        assert!(parsed.description.contains("Spans two lines"));
1174    }
1175
1176    #[test]
1177    fn parse_see_and_link() {
1178        let doc = "/** @see SomeClass\n * @link https://example.com */";
1179        let parsed = DocblockParser::parse(doc);
1180        assert_eq!(parsed.see.len(), 2);
1181        assert!(parsed.see.contains(&"SomeClass".to_string()));
1182        assert!(parsed.see.contains(&"https://example.com".to_string()));
1183    }
1184
1185    #[test]
1186    fn parse_mixin() {
1187        let doc = "/** @mixin SomeTrait */";
1188        let parsed = DocblockParser::parse(doc);
1189        assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1190    }
1191
1192    #[test]
1193    fn parse_property_tags() {
1194        let doc = r#"/**
1195         * @property string $name
1196         * @property-read int $id
1197         * @property-write bool $active
1198         */"#;
1199        let parsed = DocblockParser::parse(doc);
1200        assert_eq!(parsed.properties.len(), 3);
1201        let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1202        assert_eq!(name_prop.type_hint, "string");
1203        assert!(!name_prop.read_only);
1204        assert!(!name_prop.write_only);
1205        let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1206        assert!(id_prop.read_only);
1207        let active_prop = parsed
1208            .properties
1209            .iter()
1210            .find(|p| p.name == "active")
1211            .unwrap();
1212        assert!(active_prop.write_only);
1213    }
1214
1215    #[test]
1216    fn parse_method_tag() {
1217        let doc = r#"/**
1218         * @method string getName()
1219         * @method static int create()
1220         */"#;
1221        let parsed = DocblockParser::parse(doc);
1222        assert_eq!(parsed.methods.len(), 2);
1223        let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1224        assert_eq!(get_name.return_type, "string");
1225        assert!(!get_name.is_static);
1226        let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1227        assert!(create.is_static);
1228    }
1229
1230    #[test]
1231    fn parse_type_alias_tag() {
1232        let doc = "/** @psalm-type MyAlias = string|int */";
1233        let parsed = DocblockParser::parse(doc);
1234        assert_eq!(parsed.type_aliases.len(), 1);
1235        assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1236        assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1237    }
1238
1239    #[test]
1240    fn parse_import_type_no_as() {
1241        let doc = "/** @psalm-import-type UserId from UserRepository */";
1242        let parsed = DocblockParser::parse(doc);
1243        assert_eq!(parsed.import_types.len(), 1);
1244        assert_eq!(parsed.import_types[0].original, "UserId");
1245        assert_eq!(parsed.import_types[0].local, "UserId");
1246        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1247    }
1248
1249    #[test]
1250    fn parse_import_type_with_as() {
1251        let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1252        let parsed = DocblockParser::parse(doc);
1253        assert_eq!(parsed.import_types.len(), 1);
1254        assert_eq!(parsed.import_types[0].original, "UserId");
1255        assert_eq!(parsed.import_types[0].local, "LocalId");
1256        assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1257    }
1258
1259    #[test]
1260    fn parse_require_extends() {
1261        let doc = "/** @psalm-require-extends Model */";
1262        let parsed = DocblockParser::parse(doc);
1263        assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1264    }
1265
1266    #[test]
1267    fn parse_require_implements() {
1268        let doc = "/** @psalm-require-implements Countable */";
1269        let parsed = DocblockParser::parse(doc);
1270        assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1271    }
1272
1273    #[test]
1274    fn parse_intersection_two_parts() {
1275        let u = parse_type_string("Iterator&Countable");
1276        assert_eq!(u.types.len(), 1);
1277        assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1278        if let Atomic::TIntersection { parts } = &u.types[0] {
1279            assert!(parts[0].contains(
1280                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1281            ));
1282            assert!(parts[1].contains(
1283                |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1284            ));
1285        }
1286    }
1287
1288    #[test]
1289    fn parse_intersection_three_parts() {
1290        let u = parse_type_string("Iterator&Countable&Stringable");
1291        assert_eq!(u.types.len(), 1);
1292        let Atomic::TIntersection { parts } = &u.types[0] else {
1293            panic!("expected TIntersection");
1294        };
1295        assert_eq!(parts.len(), 3);
1296        assert!(parts[0].contains(
1297            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1298        ));
1299        assert!(parts[1].contains(
1300            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1301        ));
1302        assert!(parts[2].contains(
1303            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1304        ));
1305    }
1306
1307    #[test]
1308    fn parse_intersection_in_union_with_null() {
1309        let u = parse_type_string("Iterator&Countable|null");
1310        assert!(u.is_nullable());
1311        let intersection = u
1312            .types
1313            .iter()
1314            .find_map(|t| {
1315                if let Atomic::TIntersection { parts } = t {
1316                    Some(parts)
1317                } else {
1318                    None
1319                }
1320            })
1321            .expect("expected TIntersection");
1322        assert_eq!(intersection.len(), 2);
1323        assert!(intersection[0].contains(
1324            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1325        ));
1326        assert!(intersection[1].contains(
1327            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1328        ));
1329    }
1330
1331    #[test]
1332    fn parse_intersection_in_union_with_scalar() {
1333        let u = parse_type_string("Iterator&Countable|string");
1334        assert!(u.contains(|t| matches!(t, Atomic::TString)));
1335        let intersection = u
1336            .types
1337            .iter()
1338            .find_map(|t| {
1339                if let Atomic::TIntersection { parts } = t {
1340                    Some(parts)
1341                } else {
1342                    None
1343                }
1344            })
1345            .expect("expected TIntersection");
1346        assert!(intersection[0].contains(
1347            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1348        ));
1349        assert!(intersection[1].contains(
1350            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1351        ));
1352    }
1353
1354    #[test]
1355    fn validate_unclosed_generic_return() {
1356        let parsed = DocblockParser::parse("/** @return array< */");
1357        assert_eq!(parsed.invalid_annotations.len(), 1);
1358        assert!(
1359            parsed.invalid_annotations[0].contains("unclosed generic"),
1360            "got: {}",
1361            parsed.invalid_annotations[0]
1362        );
1363    }
1364
1365    #[test]
1366    fn validate_variable_in_type_position_param() {
1367        let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1368        assert_eq!(parsed.invalid_annotations.len(), 1);
1369        assert!(
1370            parsed.invalid_annotations[0].contains("$invalid"),
1371            "got: {}",
1372            parsed.invalid_annotations[0]
1373        );
1374    }
1375
1376    #[test]
1377    fn validate_this_is_valid_in_type_position() {
1378        let parsed = DocblockParser::parse("/** @return $this */");
1379        assert!(
1380            parsed.invalid_annotations.is_empty(),
1381            "unexpected error: {:?}",
1382            parsed.invalid_annotations
1383        );
1384    }
1385
1386    #[test]
1387    fn validate_unclosed_generic_var() {
1388        let parsed = DocblockParser::parse("/** @var array<string */");
1389        assert_eq!(parsed.invalid_annotations.len(), 1);
1390        assert!(parsed.invalid_annotations[0].contains("@var"));
1391    }
1392
1393    #[test]
1394    fn validate_variable_in_template_bound() {
1395        let parsed = DocblockParser::parse("/** @template T of $invalid */");
1396        assert_eq!(parsed.invalid_annotations.len(), 1);
1397        assert!(parsed.invalid_annotations[0].contains("$invalid"));
1398    }
1399}