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