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