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