Skip to main content

php_lsp/
docblock.rs

1/// Docblock (`/** ... */`) parser.
2///
3/// Delegates to [`mir_analyzer::DocblockParser`] for type parsing and
4/// [`php_rs_parser::phpdoc`] for description extraction.
5use std::collections::HashMap;
6
7use mir_analyzer::DocblockParser;
8use php_rs_parser::phpdoc::{self, PhpDocTag};
9
10#[derive(Debug, Default, PartialEq)]
11pub struct Docblock {
12    /// Free-text description (lines before the first `@` tag).
13    pub description: String,
14    /// `@param  TypeHint  $name  description`
15    pub params: Vec<DocParam>,
16    /// `@return  TypeHint  description`
17    pub return_type: Option<DocReturn>,
18    /// `@var  TypeHint` or `@var  TypeHint  $varName`
19    pub var_type: Option<String>,
20    /// Variable name from `@var TypeHint $varName`, if present.
21    pub var_name: Option<String>,
22    /// Free-text description after the type in `@var TypeHint description`.
23    pub var_description: Option<String>,
24    /// `@deprecated  message`  — `Some("")` when present without a message.
25    pub deprecated: Option<String>,
26    /// `@throws  ClassName  description`
27    pub throws: Vec<DocThrows>,
28    /// `@see target` and `@link url`
29    pub see: Vec<String>,
30    /// `@template T` or `@template T of BaseClass`
31    pub templates: Vec<DocTemplate>,
32    /// `@mixin ClassName`
33    pub mixins: Vec<String>,
34    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
35    pub type_aliases: Vec<DocTypeAlias>,
36    /// `@property Type $name` / `@property-read Type $name` / `@property-write Type $name`
37    pub properties: Vec<DocProperty>,
38    /// `@method [static] ReturnType name([params])`
39    pub methods: Vec<DocMethod>,
40}
41
42#[derive(Debug, PartialEq)]
43pub struct DocProperty {
44    pub type_hint: String,
45    pub name: String,    // without $
46    pub read_only: bool, // true for @property-read
47}
48
49#[derive(Debug, PartialEq)]
50pub struct DocMethod {
51    pub return_type: String,
52    pub name: String,
53    pub is_static: bool,
54}
55
56#[derive(Debug, PartialEq)]
57pub struct DocTypeAlias {
58    /// Alias name, e.g. `UserId`.
59    pub name: String,
60    /// Right-hand side type expression, e.g. `string|int`.
61    pub type_expr: String,
62}
63
64#[derive(Debug, PartialEq)]
65pub struct DocTemplate {
66    /// Template parameter name, e.g. `T`.
67    pub name: String,
68    /// Optional upper bound, e.g. `Base` from `@template T of Base`.
69    pub bound: Option<String>,
70}
71
72#[derive(Debug, PartialEq)]
73pub struct DocParam {
74    pub type_hint: String,
75    pub name: String,
76    pub description: String,
77}
78
79#[derive(Debug, PartialEq)]
80pub struct DocReturn {
81    pub type_hint: String,
82    pub description: String,
83}
84
85#[derive(Debug, PartialEq)]
86pub struct DocThrows {
87    pub class: String,
88    pub description: String,
89}
90
91impl Docblock {
92    /// Returns `true` if the `@deprecated` tag is present.
93    pub fn is_deprecated(&self) -> bool {
94        self.deprecated.is_some()
95    }
96
97    /// Format as a Markdown string suitable for LSP hover content.
98    pub fn to_markdown(&self) -> String {
99        let mut out = String::new();
100
101        if let Some(msg) = &self.deprecated {
102            if msg.is_empty() {
103                out.push_str("> **Deprecated**\n\n");
104            } else {
105                out.push_str(&format!("> **Deprecated**: {}\n\n", msg));
106            }
107        }
108
109        if !self.description.is_empty() {
110            out.push_str(&self.description);
111            out.push_str("\n\n");
112        }
113        if let Some(vt) = &self.var_type {
114            out.push_str(&format!("**@var** `{}`", vt));
115            if let Some(vd) = &self.var_description
116                && !vd.is_empty()
117            {
118                out.push_str(&format!(" — {}", vd));
119            }
120            out.push('\n');
121        }
122        if let Some(ret) = &self.return_type {
123            out.push_str(&format!("**@return** `{}`", ret.type_hint));
124            if !ret.description.is_empty() {
125                out.push_str(&format!(" — {}", ret.description));
126            }
127            out.push('\n');
128        }
129        for p in &self.params {
130            out.push_str(&format!("**@param** `{}` `{}`", p.type_hint, p.name));
131            if !p.description.is_empty() {
132                out.push_str(&format!(" — {}", p.description));
133            }
134            out.push('\n');
135        }
136        for t in &self.throws {
137            out.push_str(&format!("**@throws** `{}`", t.class));
138            if !t.description.is_empty() {
139                out.push_str(&format!(" — {}", t.description));
140            }
141            out.push('\n');
142        }
143        for s in &self.see {
144            out.push_str(&format!("**@see** {}\n", s));
145        }
146        for t in &self.templates {
147            if let Some(bound) = &t.bound {
148                out.push_str(&format!("**@template** `{}` of `{}`\n", t.name, bound));
149            } else {
150                out.push_str(&format!("**@template** `{}`\n", t.name));
151            }
152        }
153        for m in &self.mixins {
154            out.push_str(&format!("**@mixin** `{}`\n", m));
155        }
156        for ta in &self.type_aliases {
157            if ta.type_expr.is_empty() {
158                out.push_str(&format!("**@type** `{}`\n", ta.name));
159            } else {
160                out.push_str(&format!("**@type** `{}` = `{}`\n", ta.name, ta.type_expr));
161            }
162        }
163        out.trim_end().to_string()
164    }
165}
166
167/// Parse a raw docblock string (the full `/** ... */` text, or just the
168/// inner content — either form is handled).
169///
170/// Delegates to [`mir_analyzer::DocblockParser`] for type resolution and
171/// [`php_rs_parser::phpdoc`] for description fields.
172pub fn parse_docblock(raw: &str) -> Docblock {
173    let mir = DocblockParser::parse(raw);
174    let raw_doc = phpdoc::parse(raw);
175
176    // Collect descriptions from the raw tags (mir discards them).
177    let mut param_descs: HashMap<String, String> = HashMap::new();
178    let mut return_desc = String::new();
179    let mut throws_descs: Vec<String> = Vec::new();
180    let mut var_desc: Option<String> = None;
181
182    for tag in &raw_doc.tags {
183        match tag {
184            PhpDocTag::Param {
185                name: Some(n),
186                description: Some(d),
187                ..
188            } => {
189                param_descs.insert(n.trim_start_matches('$').to_string(), d.to_string());
190            }
191            PhpDocTag::Return {
192                description: Some(d),
193                ..
194            } => {
195                return_desc = d.to_string();
196            }
197            PhpDocTag::Throws {
198                type_str: Some(ts),
199                description,
200            } => {
201                let class = ts.split_whitespace().next().unwrap_or("");
202                if !class.is_empty() {
203                    throws_descs.push(
204                        description
205                            .as_ref()
206                            .map(|d| d.to_string())
207                            .unwrap_or_default(),
208                    );
209                }
210            }
211            PhpDocTag::Var {
212                description: Some(d),
213                ..
214            } => {
215                var_desc = Some(d.to_string());
216            }
217            _ => {}
218        }
219    }
220
221    let params: Vec<DocParam> = mir
222        .params
223        .iter()
224        .map(|(name, union)| {
225            let description = param_descs.get(name.as_str()).cloned().unwrap_or_default();
226            DocParam {
227                type_hint: union.to_string(),
228                name: format!("${}", name),
229                description,
230            }
231        })
232        .collect();
233
234    let return_type = mir.return_type.as_ref().map(|union| DocReturn {
235        type_hint: union.to_string(),
236        description: return_desc,
237    });
238
239    let throws: Vec<DocThrows> = mir
240        .throws
241        .iter()
242        .enumerate()
243        .map(|(i, class)| DocThrows {
244            class: class.clone(),
245            description: throws_descs.get(i).cloned().unwrap_or_default(),
246        })
247        .collect();
248
249    let deprecated = if mir.is_deprecated {
250        Some(mir.deprecated.as_deref().unwrap_or("").to_string())
251    } else {
252        None
253    };
254
255    let templates: Vec<DocTemplate> = mir
256        .templates
257        .iter()
258        .map(|(name, bound, _variance)| DocTemplate {
259            name: name.clone(),
260            bound: bound.as_ref().map(|u| u.to_string()),
261        })
262        .collect();
263
264    let properties: Vec<DocProperty> = mir
265        .properties
266        .iter()
267        .map(|p| DocProperty {
268            type_hint: p.type_hint.clone(),
269            name: p.name.clone(),
270            read_only: p.read_only,
271        })
272        .collect();
273
274    let methods: Vec<DocMethod> = mir
275        .methods
276        .iter()
277        .map(|m| DocMethod {
278            return_type: m.return_type.clone(),
279            name: m.name.clone(),
280            is_static: m.is_static,
281        })
282        .collect();
283
284    let type_aliases: Vec<DocTypeAlias> = mir
285        .type_aliases
286        .iter()
287        .map(|ta| DocTypeAlias {
288            name: ta.name.clone(),
289            type_expr: ta.type_expr.clone(),
290        })
291        .collect();
292
293    Docblock {
294        description: mir.description.clone(),
295        params,
296        return_type,
297        var_type: mir.var_type.as_ref().map(|u| u.to_string()),
298        var_name: mir.var_name.clone(),
299        var_description: var_desc,
300        deprecated,
301        throws,
302        see: mir.see.clone(),
303        templates,
304        mixins: mir.mixins.clone(),
305        type_aliases,
306        properties,
307        methods,
308    }
309}
310
311/// Scan `source` for a `/** ... */` docblock that ends immediately before
312/// `node_start` (byte offset). Whitespace between the `*/` and the node is
313/// allowed; non-whitespace text in between disqualifies the block.
314pub fn docblock_before(source: &str, node_start: u32) -> Option<String> {
315    mir_analyzer::parser::find_preceding_docblock(source, node_start)
316}
317
318/// Walk an AST and return the parsed docblock for the declaration named `word`.
319pub fn find_docblock(
320    source: &str,
321    stmts: &[php_ast::Stmt<'_, '_>],
322    word: &str,
323) -> Option<Docblock> {
324    use php_ast::{ClassMemberKind, NamespaceBody, StmtKind};
325    for stmt in stmts {
326        match &stmt.kind {
327            StmtKind::Function(f) if f.name == word => {
328                let raw = docblock_before(source, stmt.span.start)?;
329                return Some(parse_docblock(&raw));
330            }
331            StmtKind::Class(c) => {
332                for member in c.members.iter() {
333                    if let ClassMemberKind::Method(m) = &member.kind
334                        && m.name == word
335                    {
336                        let raw = docblock_before(source, member.span.start)?;
337                        return Some(parse_docblock(&raw));
338                    }
339                }
340            }
341            StmtKind::Namespace(ns) => {
342                if let NamespaceBody::Braced(inner) = &ns.body
343                    && let Some(db) = find_docblock(source, inner, word)
344                {
345                    return Some(db);
346                }
347            }
348            _ => {}
349        }
350    }
351    None
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn parses_description() {
360        let raw = "/** Does something useful. */";
361        let db = parse_docblock(raw);
362        assert_eq!(db.description, "Does something useful.");
363    }
364
365    #[test]
366    fn parses_return_tag() {
367        let raw = "/**\n * @return string The greeting\n */";
368        let db = parse_docblock(raw);
369        let ret = db.return_type.unwrap();
370        assert_eq!(ret.type_hint, "string");
371        assert_eq!(ret.description, "The greeting");
372    }
373
374    #[test]
375    fn parses_param_tag() {
376        let raw = "/**\n * @param string $name The user name\n */";
377        let db = parse_docblock(raw);
378        assert_eq!(db.params.len(), 1);
379        assert_eq!(db.params[0].type_hint, "string");
380        assert_eq!(db.params[0].name, "$name");
381        assert_eq!(db.params[0].description, "The user name");
382    }
383
384    #[test]
385    fn parses_var_tag() {
386        let raw = "/** @var string */";
387        let db = parse_docblock(raw);
388        assert_eq!(db.var_type.as_deref(), Some("string"));
389    }
390
391    #[test]
392    fn parses_var_tag_with_description() {
393        let raw = "/** @var string The user's name */";
394        let db = parse_docblock(raw);
395        assert_eq!(db.var_type.as_deref(), Some("string"));
396        assert_eq!(db.var_description.as_deref(), Some("The user's name"));
397    }
398
399    #[test]
400    fn to_markdown_shows_var_type() {
401        let db = Docblock {
402            var_type: Some("string".to_string()),
403            ..Default::default()
404        };
405        let md = db.to_markdown();
406        assert!(
407            md.contains("@var"),
408            "expected @var in markdown, got: {}",
409            md
410        );
411        assert!(
412            md.contains("string"),
413            "expected type in markdown, got: {}",
414            md
415        );
416    }
417
418    #[test]
419    fn to_markdown_shows_var_type_with_description() {
420        let db = Docblock {
421            var_type: Some("string".to_string()),
422            var_description: Some("The user's name".to_string()),
423            ..Default::default()
424        };
425        let md = db.to_markdown();
426        assert!(
427            md.contains("@var"),
428            "expected @var in markdown, got: {}",
429            md
430        );
431        assert!(
432            md.contains("string"),
433            "expected type in markdown, got: {}",
434            md
435        );
436        assert!(
437            md.contains("The user's name"),
438            "expected description in markdown, got: {}",
439            md
440        );
441    }
442
443    #[test]
444    fn multiple_params() {
445        let raw = "/**\n * @param int $a First\n * @param int $b Second\n */";
446        let db = parse_docblock(raw);
447        assert_eq!(db.params.len(), 2);
448        assert_eq!(db.params[0].name, "$a");
449        assert_eq!(db.params[1].name, "$b");
450    }
451
452    #[test]
453    fn to_markdown_includes_description_and_return() {
454        let db = Docblock {
455            description: "Greets the user.".to_string(),
456            params: vec![],
457            return_type: Some(DocReturn {
458                type_hint: "string".to_string(),
459                description: "The greeting".to_string(),
460            }),
461            var_type: None,
462            ..Default::default()
463        };
464        let md = db.to_markdown();
465        assert!(md.contains("Greets the user."));
466        assert!(md.contains("@return"));
467        assert!(md.contains("string"));
468    }
469
470    #[test]
471    fn find_docblock_from_ast() {
472        use crate::ast::ParsedDoc;
473        let src = "<?php\n/** Greets someone. */\nfunction greet() {}";
474        let doc = ParsedDoc::parse(src.to_string());
475        let db = find_docblock(src, &doc.program().stmts, "greet");
476        assert!(db.is_some(), "expected docblock for greet");
477        assert!(db.unwrap().description.contains("Greets"));
478    }
479
480    #[test]
481    fn find_docblock_returns_none_without_docblock() {
482        use crate::ast::ParsedDoc;
483        let src = "<?php\nfunction greet() {}";
484        let doc = ParsedDoc::parse(src.to_string());
485        let db = find_docblock(src, &doc.program().stmts, "greet");
486        assert!(db.is_none());
487    }
488
489    #[test]
490    fn empty_docblock_gives_defaults() {
491        let db = parse_docblock("/** */");
492        assert_eq!(db.description, "");
493        assert!(db.return_type.is_none());
494        assert!(db.params.is_empty());
495    }
496
497    #[test]
498    fn parses_deprecated_with_message() {
499        let raw = "/**\n * @deprecated Use newMethod() instead\n */";
500        let db = parse_docblock(raw);
501        assert_eq!(db.deprecated.as_deref(), Some("Use newMethod() instead"));
502        assert!(db.is_deprecated());
503    }
504
505    #[test]
506    fn parses_deprecated_without_message() {
507        let raw = "/** @deprecated */";
508        let db = parse_docblock(raw);
509        assert_eq!(db.deprecated.as_deref(), Some(""));
510        assert!(db.is_deprecated());
511    }
512
513    #[test]
514    fn not_deprecated_when_tag_absent() {
515        let raw = "/** Does stuff. */";
516        let db = parse_docblock(raw);
517        assert!(!db.is_deprecated());
518    }
519
520    #[test]
521    fn parses_throws_tag() {
522        let raw = "/**\n * @throws RuntimeException When something fails\n */";
523        let db = parse_docblock(raw);
524        assert_eq!(db.throws.len(), 1);
525        assert_eq!(db.throws[0].class, "RuntimeException");
526        assert_eq!(db.throws[0].description, "When something fails");
527    }
528
529    #[test]
530    fn parses_multiple_throws() {
531        let raw =
532            "/**\n * @throws InvalidArgumentException\n * @throws RuntimeException Bad state\n */";
533        let db = parse_docblock(raw);
534        assert_eq!(db.throws.len(), 2);
535        assert_eq!(db.throws[0].class, "InvalidArgumentException");
536        assert_eq!(db.throws[1].class, "RuntimeException");
537    }
538
539    #[test]
540    fn parses_see_tag() {
541        let raw = "/**\n * @see OtherClass::method()\n */";
542        let db = parse_docblock(raw);
543        assert_eq!(db.see.len(), 1);
544        assert_eq!(db.see[0], "OtherClass::method()");
545    }
546
547    #[test]
548    fn parses_link_tag() {
549        let raw = "/**\n * @link https://example.com/docs\n */";
550        let db = parse_docblock(raw);
551        assert_eq!(db.see.len(), 1);
552        assert_eq!(db.see[0], "https://example.com/docs");
553    }
554
555    #[test]
556    fn to_markdown_shows_deprecated_banner() {
557        let db = Docblock {
558            deprecated: Some("Use bar() instead".to_string()),
559            description: "Does foo.".to_string(),
560            ..Default::default()
561        };
562        let md = db.to_markdown();
563        assert!(
564            md.contains("> **Deprecated**"),
565            "expected deprecated banner, got: {}",
566            md
567        );
568        assert!(
569            md.contains("Use bar() instead"),
570            "expected deprecation message, got: {}",
571            md
572        );
573    }
574
575    #[test]
576    fn to_markdown_shows_throws() {
577        let db = Docblock {
578            throws: vec![DocThrows {
579                class: "RuntimeException".to_string(),
580                description: "On failure".to_string(),
581            }],
582            ..Default::default()
583        };
584        let md = db.to_markdown();
585        assert!(
586            md.contains("@throws"),
587            "expected @throws in markdown, got: {}",
588            md
589        );
590        assert!(
591            md.contains("RuntimeException"),
592            "expected class name, got: {}",
593            md
594        );
595    }
596
597    #[test]
598    fn to_markdown_shows_see() {
599        let db = Docblock {
600            see: vec!["https://example.com".to_string()],
601            ..Default::default()
602        };
603        let md = db.to_markdown();
604        assert!(
605            md.contains("@see"),
606            "expected @see in markdown, got: {}",
607            md
608        );
609        assert!(
610            md.contains("https://example.com"),
611            "expected url, got: {}",
612            md
613        );
614    }
615
616    #[test]
617    fn parses_template_tag() {
618        let raw = "/**\n * @template T\n */";
619        let db = parse_docblock(raw);
620        assert_eq!(db.templates.len(), 1);
621        assert_eq!(db.templates[0].name, "T");
622        assert!(db.templates[0].bound.is_none());
623    }
624
625    #[test]
626    fn parses_template_with_bound() {
627        let raw = "/**\n * @template T of BaseClass\n */";
628        let db = parse_docblock(raw);
629        assert_eq!(db.templates.len(), 1);
630        assert_eq!(db.templates[0].name, "T");
631        assert_eq!(db.templates[0].bound.as_deref(), Some("BaseClass"));
632    }
633
634    #[test]
635    fn parses_mixin_tag() {
636        let raw = "/**\n * @mixin SomeTrait\n */";
637        let db = parse_docblock(raw);
638        assert_eq!(db.mixins.len(), 1);
639        assert_eq!(db.mixins[0], "SomeTrait");
640    }
641
642    #[test]
643    fn parses_callable_param() {
644        let raw = "/**\n * @param callable(int, string): void $fn The callback\n */";
645        let db = parse_docblock(raw);
646        assert_eq!(db.params.len(), 1);
647        assert_eq!(db.params[0].type_hint, "callable(int, string): void");
648        assert_eq!(db.params[0].name, "$fn");
649        assert_eq!(db.params[0].description, "The callback");
650    }
651
652    #[test]
653    fn to_markdown_shows_template() {
654        let db = Docblock {
655            templates: vec![DocTemplate {
656                name: "T".to_string(),
657                bound: Some("Base".to_string()),
658            }],
659            ..Default::default()
660        };
661        let md = db.to_markdown();
662        assert!(
663            md.contains("@template"),
664            "expected @template in markdown, got: {}",
665            md
666        );
667        assert!(md.contains("T"), "expected T in markdown");
668        assert!(md.contains("Base"), "expected Base in markdown");
669    }
670
671    #[test]
672    fn to_markdown_shows_mixin() {
673        let db = Docblock {
674            mixins: vec!["SomeTrait".to_string()],
675            ..Default::default()
676        };
677        let md = db.to_markdown();
678        assert!(
679            md.contains("@mixin"),
680            "expected @mixin in markdown, got: {}",
681            md
682        );
683        assert!(md.contains("SomeTrait"), "expected SomeTrait in markdown");
684    }
685
686    #[test]
687    fn parses_psalm_type_alias() {
688        let raw = "/**\n * @psalm-type UserId = string|int\n */";
689        let db = parse_docblock(raw);
690        assert_eq!(db.type_aliases.len(), 1);
691        assert_eq!(db.type_aliases[0].name, "UserId");
692        assert_eq!(db.type_aliases[0].type_expr, "string|int");
693    }
694
695    #[test]
696    fn parses_phpstan_type_alias() {
697        let raw = "/** @phpstan-type Row = array{id: int, name: string} */";
698        let db = parse_docblock(raw);
699        assert_eq!(db.type_aliases.len(), 1);
700        assert_eq!(db.type_aliases[0].name, "Row");
701        assert!(db.type_aliases[0].type_expr.contains("array"));
702    }
703
704    #[test]
705    fn to_markdown_shows_type_alias() {
706        let db = Docblock {
707            type_aliases: vec![DocTypeAlias {
708                name: "Status".to_string(),
709                type_expr: "string".to_string(),
710            }],
711            ..Default::default()
712        };
713        let md = db.to_markdown();
714        assert!(md.contains("Status"), "expected alias name in markdown");
715        assert!(md.contains("string"), "expected type expr in markdown");
716    }
717
718    #[test]
719    fn parses_property_tag() {
720        let src = "/** @property string $name */";
721        let db = parse_docblock(src);
722        assert_eq!(db.properties.len(), 1);
723        assert_eq!(db.properties[0].name, "name");
724        assert_eq!(db.properties[0].type_hint, "string");
725        assert!(!db.properties[0].read_only);
726    }
727
728    #[test]
729    fn parses_property_read_tag() {
730        let src = "/** @property-read Carbon $createdAt */";
731        let db = parse_docblock(src);
732        assert_eq!(db.properties[0].name, "createdAt");
733        assert!(db.properties[0].read_only);
734    }
735
736    #[test]
737    fn parses_method_tag() {
738        let src = "/** @method User find(int $id) */";
739        let db = parse_docblock(src);
740        assert_eq!(db.methods.len(), 1);
741        assert_eq!(db.methods[0].name, "find");
742        assert_eq!(db.methods[0].return_type, "User");
743        assert!(!db.methods[0].is_static);
744    }
745
746    #[test]
747    fn parses_static_method_tag() {
748        let src = "/** @method static Builder where(string $col, mixed $val) */";
749        let db = parse_docblock(src);
750        assert!(db.methods[0].is_static);
751        assert_eq!(db.methods[0].name, "where");
752    }
753
754    #[test]
755    fn psalm_param_alias_parsed_as_param() {
756        let raw = "/**\n * @psalm-param string $x The value\n */";
757        let db = parse_docblock(raw);
758        assert_eq!(db.params.len(), 1);
759        assert_eq!(db.params[0].type_hint, "string");
760        assert_eq!(db.params[0].name, "$x");
761    }
762
763    #[test]
764    fn phpstan_param_alias_parsed_as_param() {
765        let raw = "/**\n * @phpstan-param int $count\n */";
766        let db = parse_docblock(raw);
767        assert_eq!(db.params.len(), 1);
768        assert_eq!(db.params[0].type_hint, "int");
769        assert_eq!(db.params[0].name, "$count");
770    }
771
772    #[test]
773    fn psalm_return_alias_parsed_as_return() {
774        let raw = "/**\n * @psalm-return non-empty-string\n */";
775        let db = parse_docblock(raw);
776        assert_eq!(
777            db.return_type.as_ref().map(|r| r.type_hint.as_str()),
778            Some("non-empty-string")
779        );
780    }
781
782    #[test]
783    fn phpstan_return_alias_parsed_as_return() {
784        let raw = "/**\n * @phpstan-return array<int, string>\n */";
785        let db = parse_docblock(raw);
786        assert_eq!(
787            db.return_type.as_ref().map(|r| r.type_hint.as_str()),
788            Some("array<int, string>")
789        );
790    }
791
792    #[test]
793    fn psalm_var_alias_parsed_as_var() {
794        let raw = "/** @psalm-var Foo $item */";
795        let db = parse_docblock(raw);
796        assert_eq!(db.var_type.as_deref(), Some("Foo"));
797        assert_eq!(db.var_name.as_deref(), Some("item"));
798    }
799
800    #[test]
801    fn phpstan_var_alias_parsed_as_var() {
802        let raw = "/** @phpstan-var string */";
803        let db = parse_docblock(raw);
804        assert_eq!(db.var_type.as_deref(), Some("string"));
805    }
806
807    #[test]
808    fn param_without_description_parses_correctly() {
809        let raw = "/**\n * @param string $x\n */";
810        let db = parse_docblock(raw);
811        assert_eq!(db.params.len(), 1);
812        assert_eq!(
813            db.params[0].type_hint, "string",
814            "type_hint should be 'string'"
815        );
816        assert_eq!(db.params[0].name, "$x", "name should be '$x'");
817        assert_eq!(
818            db.params[0].description, "",
819            "description should be empty when absent"
820        );
821    }
822
823    #[test]
824    fn union_type_param_parsed() {
825        let raw = "/**\n * @param Foo|Bar $x Some value\n */";
826        let db = parse_docblock(raw);
827        assert_eq!(db.params.len(), 1);
828        assert_eq!(
829            db.params[0].type_hint, "Foo|Bar",
830            "union type should be 'Foo|Bar', got: {}",
831            db.params[0].type_hint
832        );
833        assert_eq!(db.params[0].name, "$x");
834    }
835
836    #[test]
837    fn nullable_type_param_parsed() {
838        // `?Foo` is normalized to the canonical `Foo|null` form.
839        let raw = "/**\n * @param ?Foo $x\n */";
840        let db = parse_docblock(raw);
841        assert_eq!(db.params.len(), 1);
842        assert_eq!(
843            db.params[0].type_hint, "Foo|null",
844            "nullable type should be 'Foo|null', got: {}",
845            db.params[0].type_hint
846        );
847        assert_eq!(db.params[0].name, "$x");
848    }
849
850    #[test]
851    fn method_tag_extracts_return_type() {
852        let raw = "/**\n * @method string getName()\n */";
853        let db = parse_docblock(raw);
854        assert_eq!(db.methods.len(), 1);
855        assert_eq!(
856            db.methods[0].return_type, "string",
857            "return_type should be 'string', got: {}",
858            db.methods[0].return_type
859        );
860        assert_eq!(
861            db.methods[0].name, "getName",
862            "name should be 'getName', got: {}",
863            db.methods[0].name
864        );
865        assert!(!db.methods[0].is_static, "should not be static");
866    }
867
868    #[test]
869    fn advanced_type_non_empty_string() {
870        // mir resolves psalm/phpstan special types; non-empty-string must round-trip.
871        let raw = "/**\n * @return non-empty-string\n */";
872        let db = parse_docblock(raw);
873        assert_eq!(
874            db.return_type.as_ref().map(|r| r.type_hint.as_str()),
875            Some("non-empty-string"),
876            "non-empty-string should be preserved, got: {:?}",
877            db.return_type
878        );
879    }
880
881    #[test]
882    fn advanced_type_generic_array() {
883        // array<K, V> generic syntax must round-trip through mir's Union display.
884        let raw = "/**\n * @param array<int, string> $map\n */";
885        let db = parse_docblock(raw);
886        assert_eq!(db.params.len(), 1);
887        assert_eq!(
888            db.params[0].type_hint, "array<int, string>",
889            "generic array type should be preserved, got: {}",
890            db.params[0].type_hint
891        );
892    }
893
894    #[test]
895    fn param_and_return_descriptions_preserved() {
896        // Descriptions from @param and @return are captured via php-rs-parser
897        // (mir discards them). Verify they survive the full parse_docblock() call.
898        let raw = "/**\n * @param string $name The user name\n * @return int The age\n */";
899        let db = parse_docblock(raw);
900        assert_eq!(
901            db.params[0].description, "The user name",
902            "param description should be preserved"
903        );
904        assert_eq!(
905            db.return_type.as_ref().map(|r| r.description.as_str()),
906            Some("The age"),
907            "return description should be preserved"
908        );
909    }
910
911    #[test]
912    fn throws_description_preserved() {
913        // @throws description must survive the adapter (mir only stores the class).
914        let raw = "/**\n * @throws RuntimeException When the server is down\n */";
915        let db = parse_docblock(raw);
916        assert_eq!(db.throws.len(), 1);
917        assert_eq!(db.throws[0].class, "RuntimeException");
918        assert_eq!(
919            db.throws[0].description, "When the server is down",
920            "throws description should be preserved"
921        );
922    }
923}