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