Skip to main content

mir_analyzer/parser/
docblock.rs

1use mir_types::{Atomic, Union};
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_ast::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(description.unwrap_or("").to_string());
60                }
61                PhpDocTag::Template { name, bound }
62                | PhpDocTag::TemplateCovariant { name, bound }
63                | PhpDocTag::TemplateContravariant { name, bound } => {
64                    result
65                        .templates
66                        .push((name.to_string(), bound.map(parse_type_string)));
67                }
68                PhpDocTag::Extends { type_str } => {
69                    result.extends = Some(type_str.to_string());
70                }
71                PhpDocTag::Implements { type_str } => {
72                    result.implements.push(type_str.to_string());
73                }
74                PhpDocTag::Assert {
75                    type_str: Some(ty_s),
76                    name: Some(n),
77                } => {
78                    result.assertions.push((
79                        n.trim_start_matches('$').to_string(),
80                        parse_type_string(ty_s),
81                    ));
82                }
83                PhpDocTag::Suppress { rules } => {
84                    for rule in rules.split([',', ' ']) {
85                        let rule = rule.trim().to_string();
86                        if !rule.is_empty() {
87                            result.suppressed_issues.push(rule);
88                        }
89                    }
90                }
91                PhpDocTag::See { reference } => result.see.push(reference.to_string()),
92                PhpDocTag::Link { url } => result.see.push(url.to_string()),
93                PhpDocTag::Mixin { class } => result.mixins.push(class.to_string()),
94                PhpDocTag::Property {
95                    type_str,
96                    name: Some(n),
97                    ..
98                } => result.properties.push(DocProperty {
99                    type_hint: type_str.unwrap_or("").to_string(),
100                    name: n.trim_start_matches('$').to_string(),
101                    read_only: false,
102                    write_only: false,
103                }),
104                PhpDocTag::PropertyRead {
105                    type_str,
106                    name: Some(n),
107                    ..
108                } => result.properties.push(DocProperty {
109                    type_hint: type_str.unwrap_or("").to_string(),
110                    name: n.trim_start_matches('$').to_string(),
111                    read_only: true,
112                    write_only: false,
113                }),
114                PhpDocTag::PropertyWrite {
115                    type_str,
116                    name: Some(n),
117                    ..
118                } => result.properties.push(DocProperty {
119                    type_hint: type_str.unwrap_or("").to_string(),
120                    name: n.trim_start_matches('$').to_string(),
121                    read_only: false,
122                    write_only: true,
123                }),
124                PhpDocTag::Method { signature } => {
125                    if let Some(m) = parse_method_line(signature) {
126                        result.methods.push(m);
127                    }
128                }
129                PhpDocTag::TypeAlias {
130                    name: Some(n),
131                    type_str,
132                } => result.type_aliases.push(DocTypeAlias {
133                    name: n.to_string(),
134                    type_expr: type_str.unwrap_or("").to_string(),
135                }),
136                PhpDocTag::Internal => result.is_internal = true,
137                PhpDocTag::Pure => result.is_pure = true,
138                PhpDocTag::Immutable => result.is_immutable = true,
139                PhpDocTag::Readonly => result.is_readonly = true,
140                PhpDocTag::Generic { tag, body } => match *tag {
141                    "api" | "psalm-api" => result.is_api = true,
142                    "psalm-assert-if-true" | "phpstan-assert-if-true" => {
143                        if let Some((ty_str, name)) = body.and_then(parse_param_line) {
144                            result
145                                .assertions_if_true
146                                .push((name, parse_type_string(&ty_str)));
147                        }
148                    }
149                    "psalm-assert-if-false" | "phpstan-assert-if-false" => {
150                        if let Some((ty_str, name)) = body.and_then(parse_param_line) {
151                            result
152                                .assertions_if_false
153                                .push((name, parse_type_string(&ty_str)));
154                        }
155                    }
156                    _ => {}
157                },
158                _ => {}
159            }
160        }
161
162        result
163    }
164}
165
166// ---------------------------------------------------------------------------
167// ParsedDocblock support types
168// ---------------------------------------------------------------------------
169
170#[derive(Debug, Default, Clone)]
171pub struct DocProperty {
172    pub type_hint: String,
173    pub name: String,     // without leading $
174    pub read_only: bool,  // true for @property-read
175    pub write_only: bool, // true for @property-write
176}
177
178#[derive(Debug, Default, Clone)]
179pub struct DocMethod {
180    pub return_type: String,
181    pub name: String,
182    pub is_static: bool,
183}
184
185#[derive(Debug, Default, Clone)]
186pub struct DocTypeAlias {
187    pub name: String,
188    pub type_expr: String,
189}
190
191// ---------------------------------------------------------------------------
192// ParsedDocblock
193// ---------------------------------------------------------------------------
194
195#[derive(Debug, Default, Clone)]
196pub struct ParsedDocblock {
197    /// `@param Type $name`
198    pub params: Vec<(String, Union)>,
199    /// `@return Type`
200    pub return_type: Option<Union>,
201    /// `@var Type` or `@var Type $name` — type and optional variable name
202    pub var_type: Option<Union>,
203    /// Optional variable name from `@var Type $name`
204    pub var_name: Option<String>,
205    /// `@template T` / `@template T of Bound`
206    pub templates: Vec<(String, Option<Union>)>,
207    /// `@extends ClassName<T>`
208    pub extends: Option<String>,
209    /// `@implements InterfaceName<T>`
210    pub implements: Vec<String>,
211    /// `@throws ClassName`
212    pub throws: Vec<String>,
213    /// `@psalm-assert Type $var`
214    pub assertions: Vec<(String, Union)>,
215    /// `@psalm-assert-if-true Type $var`
216    pub assertions_if_true: Vec<(String, Union)>,
217    /// `@psalm-assert-if-false Type $var`
218    pub assertions_if_false: Vec<(String, Union)>,
219    /// `@psalm-suppress IssueName`
220    pub suppressed_issues: Vec<String>,
221    pub is_deprecated: bool,
222    pub is_internal: bool,
223    pub is_pure: bool,
224    pub is_immutable: bool,
225    pub is_readonly: bool,
226    pub is_api: bool,
227    /// Free text before first `@` tag — used for hover display
228    pub description: String,
229    /// `@deprecated message` — Some(message) or Some("") if no message
230    pub deprecated: Option<String>,
231    /// `@see ClassName` / `@link URL`
232    pub see: Vec<String>,
233    /// `@mixin ClassName`
234    pub mixins: Vec<String>,
235    /// `@property`, `@property-read`, `@property-write`
236    pub properties: Vec<DocProperty>,
237    /// `@method [static] ReturnType name([params])`
238    pub methods: Vec<DocMethod>,
239    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
240    pub type_aliases: Vec<DocTypeAlias>,
241}
242
243impl ParsedDocblock {
244    /// Returns the type for a given parameter name (strips leading `$`).
245    pub fn get_param_type(&self, name: &str) -> Option<&Union> {
246        let name = name.trim_start_matches('$');
247        self.params
248            .iter()
249            .find(|(n, _)| n.trim_start_matches('$') == name)
250            .map(|(_, ty)| ty)
251    }
252}
253
254// ---------------------------------------------------------------------------
255// Type string parser
256// ---------------------------------------------------------------------------
257
258/// Parse a PHPDoc type expression string into a `Union`.
259/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
260/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
261pub fn parse_type_string(s: &str) -> Union {
262    let s = s.trim();
263
264    // Nullable shorthand: `?Type`
265    if let Some(inner) = s.strip_prefix('?') {
266        let inner_ty = parse_type_string(inner);
267        let mut u = inner_ty;
268        u.add_type(Atomic::TNull);
269        return u;
270    }
271
272    // Union: `A|B|C`
273    if s.contains('|') && !is_inside_generics(s) {
274        let parts = split_union(s);
275        if parts.len() > 1 {
276            let mut u = Union::empty();
277            for part in parts {
278                for atomic in parse_type_string(&part).types {
279                    u.add_type(atomic);
280                }
281            }
282            return u;
283        }
284    }
285
286    // Intersection: `A&B` (simplified — treat as first type for now)
287    if s.contains('&') && !is_inside_generics(s) {
288        let first = s.split('&').next().unwrap_or(s);
289        return parse_type_string(first.trim());
290    }
291
292    // Array shorthand: `Type[]` or `Type[][]`
293    if let Some(value_str) = s.strip_suffix("[]") {
294        let value = parse_type_string(value_str);
295        return Union::single(Atomic::TArray {
296            key: Box::new(Union::single(Atomic::TInt)),
297            value: Box::new(value),
298        });
299    }
300
301    // Generic: `name<...>`
302    if let Some(open) = s.find('<') {
303        if s.ends_with('>') {
304            let name = &s[..open];
305            let inner = &s[open + 1..s.len() - 1];
306            return parse_generic(name, inner);
307        }
308    }
309
310    // Keywords
311    match s.to_lowercase().as_str() {
312        "string" => Union::single(Atomic::TString),
313        "non-empty-string" => Union::single(Atomic::TNonEmptyString),
314        "numeric-string" => Union::single(Atomic::TNumericString),
315        "class-string" => Union::single(Atomic::TClassString(None)),
316        "int" | "integer" => Union::single(Atomic::TInt),
317        "positive-int" => Union::single(Atomic::TPositiveInt),
318        "negative-int" => Union::single(Atomic::TNegativeInt),
319        "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
320        "float" | "double" => Union::single(Atomic::TFloat),
321        "bool" | "boolean" => Union::single(Atomic::TBool),
322        "true" => Union::single(Atomic::TTrue),
323        "false" => Union::single(Atomic::TFalse),
324        "null" => Union::single(Atomic::TNull),
325        "void" => Union::single(Atomic::TVoid),
326        "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
327        "mixed" => Union::single(Atomic::TMixed),
328        "object" => Union::single(Atomic::TObject),
329        "array" => Union::single(Atomic::TArray {
330            key: Box::new(Union::single(Atomic::TMixed)),
331            value: Box::new(Union::mixed()),
332        }),
333        "list" => Union::single(Atomic::TList {
334            value: Box::new(Union::mixed()),
335        }),
336        "callable" => Union::single(Atomic::TCallable {
337            params: None,
338            return_type: None,
339        }),
340        "iterable" => Union::single(Atomic::TArray {
341            key: Box::new(Union::single(Atomic::TMixed)),
342            value: Box::new(Union::mixed()),
343        }),
344        "scalar" => Union::single(Atomic::TScalar),
345        "numeric" => Union::single(Atomic::TNumeric),
346        "resource" => Union::mixed(), // treat as mixed
347        // self/static/parent: emit sentinel with empty FQCN; collector fills it in.
348        "static" => Union::single(Atomic::TStaticObject {
349            fqcn: Arc::from(""),
350        }),
351        "self" | "$this" => Union::single(Atomic::TSelf {
352            fqcn: Arc::from(""),
353        }),
354        "parent" => Union::single(Atomic::TParent {
355            fqcn: Arc::from(""),
356        }),
357
358        // Named class
359        _ if !s.is_empty()
360            && s.chars()
361                .next()
362                .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
363                .unwrap_or(false) =>
364        {
365            Union::single(Atomic::TNamedObject {
366                fqcn: normalize_fqcn(s).into(),
367                type_params: vec![],
368            })
369        }
370
371        _ => Union::mixed(),
372    }
373}
374
375fn parse_generic(name: &str, inner: &str) -> Union {
376    match name.to_lowercase().as_str() {
377        "array" => {
378            let params = split_generics(inner);
379            let (key, value) = if params.len() >= 2 {
380                (
381                    parse_type_string(params[0].trim()),
382                    parse_type_string(params[1].trim()),
383                )
384            } else {
385                (
386                    Union::single(Atomic::TInt),
387                    parse_type_string(params[0].trim()),
388                )
389            };
390            Union::single(Atomic::TArray {
391                key: Box::new(key),
392                value: Box::new(value),
393            })
394        }
395        "list" | "non-empty-list" => {
396            let value = parse_type_string(inner.trim());
397            if name.to_lowercase().starts_with("non-empty") {
398                Union::single(Atomic::TNonEmptyList {
399                    value: Box::new(value),
400                })
401            } else {
402                Union::single(Atomic::TList {
403                    value: Box::new(value),
404                })
405            }
406        }
407        "non-empty-array" => {
408            let params = split_generics(inner);
409            let (key, value) = if params.len() >= 2 {
410                (
411                    parse_type_string(params[0].trim()),
412                    parse_type_string(params[1].trim()),
413                )
414            } else {
415                (
416                    Union::single(Atomic::TInt),
417                    parse_type_string(params[0].trim()),
418                )
419            };
420            Union::single(Atomic::TNonEmptyArray {
421                key: Box::new(key),
422                value: Box::new(value),
423            })
424        }
425        "iterable" => {
426            let params = split_generics(inner);
427            let value = if params.len() >= 2 {
428                parse_type_string(params[1].trim())
429            } else {
430                parse_type_string(params[0].trim())
431            };
432            Union::single(Atomic::TArray {
433                key: Box::new(Union::single(Atomic::TMixed)),
434                value: Box::new(value),
435            })
436        }
437        "class-string" => Union::single(Atomic::TClassString(Some(
438            normalize_fqcn(inner.trim()).into(),
439        ))),
440        "int" => {
441            // int<min, max>
442            Union::single(Atomic::TIntRange {
443                min: None,
444                max: None,
445            })
446        }
447        // Named class with type params
448        _ => {
449            let params: Vec<Union> = split_generics(inner)
450                .iter()
451                .map(|p| parse_type_string(p.trim()))
452                .collect();
453            Union::single(Atomic::TNamedObject {
454                fqcn: normalize_fqcn(name).into(),
455                type_params: params,
456            })
457        }
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Helpers
463// ---------------------------------------------------------------------------
464
465/// Extract the description text (all prose before the first `@` tag) from a raw docblock.
466fn extract_description(text: &str) -> String {
467    let mut desc_lines: Vec<&str> = Vec::new();
468    for line in text.lines() {
469        let l = line.trim();
470        let l = l.trim_start_matches("/**").trim();
471        let l = l.trim_end_matches("*/").trim();
472        let l = l.trim_start_matches("*/").trim();
473        let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
474        let l = l.trim();
475        if l.starts_with('@') {
476            break;
477        }
478        if !l.is_empty() {
479            desc_lines.push(l);
480        }
481    }
482    desc_lines.join(" ")
483}
484
485fn parse_param_line(s: &str) -> Option<(String, String)> {
486    // Formats: `Type $name`, `Type $name description`
487    let mut parts = s.splitn(3, char::is_whitespace);
488    let ty = parts.next()?.trim().to_string();
489    let name = parts.next()?.trim().trim_start_matches('$').to_string();
490    if ty.is_empty() || name.is_empty() {
491        return None;
492    }
493    Some((ty, name))
494}
495
496fn split_union(s: &str) -> Vec<String> {
497    let mut parts = Vec::new();
498    let mut depth = 0;
499    let mut current = String::new();
500    for ch in s.chars() {
501        match ch {
502            '<' | '(' | '{' => {
503                depth += 1;
504                current.push(ch);
505            }
506            '>' | ')' | '}' => {
507                depth -= 1;
508                current.push(ch);
509            }
510            '|' if depth == 0 => {
511                parts.push(current.trim().to_string());
512                current = String::new();
513            }
514            _ => current.push(ch),
515        }
516    }
517    if !current.trim().is_empty() {
518        parts.push(current.trim().to_string());
519    }
520    parts
521}
522
523fn split_generics(s: &str) -> Vec<String> {
524    let mut parts = Vec::new();
525    let mut depth = 0;
526    let mut current = String::new();
527    for ch in s.chars() {
528        match ch {
529            '<' | '(' | '{' => {
530                depth += 1;
531                current.push(ch);
532            }
533            '>' | ')' | '}' => {
534                depth -= 1;
535                current.push(ch);
536            }
537            ',' if depth == 0 => {
538                parts.push(current.trim().to_string());
539                current = String::new();
540            }
541            _ => current.push(ch),
542        }
543    }
544    if !current.trim().is_empty() {
545        parts.push(current.trim().to_string());
546    }
547    parts
548}
549
550fn is_inside_generics(s: &str) -> bool {
551    let mut depth = 0i32;
552    for ch in s.chars() {
553        match ch {
554            '<' | '(' | '{' => depth += 1,
555            '>' | ')' | '}' => depth -= 1,
556            _ => {}
557        }
558    }
559    depth != 0
560}
561
562fn normalize_fqcn(s: &str) -> String {
563    // Strip leading backslash if present — we normalize all FQCNs without leading `\`
564    s.trim_start_matches('\\').to_string()
565}
566
567/// Parse `[static] [ReturnType] name(...)` for @method tags.
568fn parse_method_line(s: &str) -> Option<DocMethod> {
569    let mut words = s.splitn(4, char::is_whitespace);
570    let first = words.next()?.trim();
571    if first.is_empty() {
572        return None;
573    }
574    let is_static = first.eq_ignore_ascii_case("static");
575    let (return_type, name_part) = if is_static {
576        let ret = words.next()?.trim().to_string();
577        let nm = words.next()?.trim().to_string();
578        (ret, nm)
579    } else {
580        // Check if next token looks like a method name (contains '(')
581        let second = words
582            .next()
583            .map(|s| s.trim().to_string())
584            .unwrap_or_default();
585        if second.is_empty() {
586            // Only one word — treat as name with no return type
587            let name = first.split('(').next().unwrap_or(first).to_string();
588            return Some(DocMethod {
589                return_type: String::new(),
590                name,
591                is_static: false,
592            });
593        }
594        if first.contains('(') {
595            // first word is `name(...)`, no return type
596            let name = first.split('(').next().unwrap_or(first).to_string();
597            return Some(DocMethod {
598                return_type: String::new(),
599                name,
600                is_static: false,
601            });
602        }
603        (first.to_string(), second)
604    };
605    let name = name_part
606        .split('(')
607        .next()
608        .unwrap_or(&name_part)
609        .to_string();
610    Some(DocMethod {
611        return_type,
612        name,
613        is_static,
614    })
615}
616
617// ---------------------------------------------------------------------------
618// Tests
619// ---------------------------------------------------------------------------
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use mir_types::Atomic;
625
626    #[test]
627    fn parse_string() {
628        let u = parse_type_string("string");
629        assert_eq!(u.types.len(), 1);
630        assert!(matches!(u.types[0], Atomic::TString));
631    }
632
633    #[test]
634    fn parse_nullable_string() {
635        let u = parse_type_string("?string");
636        assert!(u.is_nullable());
637        assert!(u.contains(|t| matches!(t, Atomic::TString)));
638    }
639
640    #[test]
641    fn parse_union() {
642        let u = parse_type_string("string|int|null");
643        assert!(u.contains(|t| matches!(t, Atomic::TString)));
644        assert!(u.contains(|t| matches!(t, Atomic::TInt)));
645        assert!(u.is_nullable());
646    }
647
648    #[test]
649    fn parse_array_of_string() {
650        let u = parse_type_string("array<string>");
651        assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
652    }
653
654    #[test]
655    fn parse_list_of_int() {
656        let u = parse_type_string("list<int>");
657        assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
658    }
659
660    #[test]
661    fn parse_named_class() {
662        let u = parse_type_string("Foo\\Bar");
663        assert!(u.contains(
664            |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
665        ));
666    }
667
668    #[test]
669    fn parse_docblock_param_return() {
670        let doc = r#"/**
671         * @param string $name
672         * @param int $age
673         * @return bool
674         */"#;
675        let parsed = DocblockParser::parse(doc);
676        assert_eq!(parsed.params.len(), 2);
677        assert!(parsed.return_type.is_some());
678        let ret = parsed.return_type.unwrap();
679        assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
680    }
681
682    #[test]
683    fn parse_template() {
684        let doc = "/** @template T of object */";
685        let parsed = DocblockParser::parse(doc);
686        assert_eq!(parsed.templates.len(), 1);
687        assert_eq!(parsed.templates[0].0, "T");
688        assert!(parsed.templates[0].1.is_some());
689    }
690
691    #[test]
692    fn parse_deprecated() {
693        let doc = "/** @deprecated use newMethod() instead */";
694        let parsed = DocblockParser::parse(doc);
695        assert!(parsed.is_deprecated);
696        assert_eq!(
697            parsed.deprecated.as_deref(),
698            Some("use newMethod() instead")
699        );
700    }
701
702    #[test]
703    fn parse_description() {
704        let doc = r#"/**
705         * This is a description.
706         * Spans two lines.
707         * @param string $x
708         */"#;
709        let parsed = DocblockParser::parse(doc);
710        assert!(parsed.description.contains("This is a description"));
711        assert!(parsed.description.contains("Spans two lines"));
712    }
713
714    #[test]
715    fn parse_see_and_link() {
716        let doc = "/** @see SomeClass\n * @link https://example.com */";
717        let parsed = DocblockParser::parse(doc);
718        assert_eq!(parsed.see.len(), 2);
719        assert!(parsed.see.contains(&"SomeClass".to_string()));
720        assert!(parsed.see.contains(&"https://example.com".to_string()));
721    }
722
723    #[test]
724    fn parse_mixin() {
725        let doc = "/** @mixin SomeTrait */";
726        let parsed = DocblockParser::parse(doc);
727        assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
728    }
729
730    #[test]
731    fn parse_property_tags() {
732        let doc = r#"/**
733         * @property string $name
734         * @property-read int $id
735         * @property-write bool $active
736         */"#;
737        let parsed = DocblockParser::parse(doc);
738        assert_eq!(parsed.properties.len(), 3);
739        let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
740        assert_eq!(name_prop.type_hint, "string");
741        assert!(!name_prop.read_only);
742        assert!(!name_prop.write_only);
743        let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
744        assert!(id_prop.read_only);
745        let active_prop = parsed
746            .properties
747            .iter()
748            .find(|p| p.name == "active")
749            .unwrap();
750        assert!(active_prop.write_only);
751    }
752
753    #[test]
754    fn parse_method_tag() {
755        let doc = r#"/**
756         * @method string getName()
757         * @method static int create()
758         */"#;
759        let parsed = DocblockParser::parse(doc);
760        assert_eq!(parsed.methods.len(), 2);
761        let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
762        assert_eq!(get_name.return_type, "string");
763        assert!(!get_name.is_static);
764        let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
765        assert!(create.is_static);
766    }
767
768    #[test]
769    fn parse_type_alias_tag() {
770        let doc = "/** @psalm-type MyAlias = string|int */";
771        let parsed = DocblockParser::parse(doc);
772        assert_eq!(parsed.type_aliases.len(), 1);
773        assert_eq!(parsed.type_aliases[0].name, "MyAlias");
774        assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
775    }
776}