Skip to main content

mir_analyzer/parser/
docblock.rs

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