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