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