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`.
475pub fn find_docblock(
476    source: &str,
477    stmts: &[php_ast::Stmt<'_, '_>],
478    word: &str,
479) -> 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                let raw = docblock_before(source, stmt.span.start)?;
485                return Some(parse_docblock(&raw));
486            }
487            StmtKind::Class(c)
488                if c.name.as_ref().map(|n| n.to_string()) == Some(word.to_string()) =>
489            {
490                let raw = docblock_before(source, stmt.span.start)?;
491                return Some(parse_docblock(&raw));
492            }
493            StmtKind::Interface(i) if i.name == word => {
494                let raw = docblock_before(source, stmt.span.start)?;
495                return Some(parse_docblock(&raw));
496            }
497            StmtKind::Trait(t) if t.name == word => {
498                let raw = docblock_before(source, stmt.span.start)?;
499                return Some(parse_docblock(&raw));
500            }
501            StmtKind::Enum(e) if e.name == word => {
502                let raw = docblock_before(source, stmt.span.start)?;
503                return Some(parse_docblock(&raw));
504            }
505            StmtKind::Class(c) => {
506                for member in c.members.iter() {
507                    match &member.kind {
508                        ClassMemberKind::Method(m) if m.name == word => {
509                            let raw = docblock_before(source, member.span.start)?;
510                            return Some(parse_docblock(&raw));
511                        }
512                        ClassMemberKind::ClassConst(k) if k.name == word => {
513                            let raw = docblock_before(source, member.span.start)?;
514                            return Some(parse_docblock(&raw));
515                        }
516                        _ => {}
517                    }
518                }
519            }
520            StmtKind::Interface(i) => {
521                for member in i.members.iter() {
522                    match &member.kind {
523                        ClassMemberKind::Method(m) if m.name == word => {
524                            let raw = docblock_before(source, member.span.start)?;
525                            return Some(parse_docblock(&raw));
526                        }
527                        ClassMemberKind::ClassConst(k) if k.name == word => {
528                            let raw = docblock_before(source, member.span.start)?;
529                            return Some(parse_docblock(&raw));
530                        }
531                        _ => {}
532                    }
533                }
534            }
535            StmtKind::Trait(t) => {
536                for member in t.members.iter() {
537                    if let ClassMemberKind::Method(m) = &member.kind
538                        && m.name == word
539                    {
540                        let raw = docblock_before(source, member.span.start)?;
541                        return Some(parse_docblock(&raw));
542                    }
543                }
544            }
545            StmtKind::Enum(e) => {
546                for member in e.members.iter() {
547                    match &member.kind {
548                        EnumMemberKind::Method(m) if m.name == word => {
549                            let raw = docblock_before(source, member.span.start)?;
550                            return Some(parse_docblock(&raw));
551                        }
552                        EnumMemberKind::Case(c) if c.name == word => {
553                            let raw = docblock_before(source, member.span.start)?;
554                            return Some(parse_docblock(&raw));
555                        }
556                        EnumMemberKind::ClassConst(k) if k.name == word => {
557                            let raw = docblock_before(source, member.span.start)?;
558                            return Some(parse_docblock(&raw));
559                        }
560                        _ => {}
561                    }
562                }
563            }
564            StmtKind::Namespace(ns) => {
565                if let NamespaceBody::Braced(inner) = &ns.body
566                    && let Some(db) = find_docblock(source, inner, word)
567                {
568                    return Some(db);
569                }
570            }
571            _ => {}
572        }
573    }
574    None
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn parses_description() {
583        let raw = "/** Does something useful. */";
584        let db = parse_docblock(raw);
585        assert_eq!(db.description, "Does something useful.");
586    }
587
588    #[test]
589    fn parses_return_tag() {
590        let raw = "/**\n * @return string The greeting\n */";
591        let db = parse_docblock(raw);
592        let ret = db.return_type.unwrap();
593        assert_eq!(ret.type_hint, "string");
594        assert_eq!(ret.description, "The greeting");
595    }
596
597    #[test]
598    fn parses_param_tag() {
599        let raw = "/**\n * @param string $name The user name\n */";
600        let db = parse_docblock(raw);
601        assert_eq!(db.params.len(), 1);
602        assert_eq!(db.params[0].type_hint, "string");
603        assert_eq!(db.params[0].name, "$name");
604        assert_eq!(db.params[0].description, "The user name");
605    }
606
607    #[test]
608    fn parses_var_tag() {
609        let raw = "/** @var string */";
610        let db = parse_docblock(raw);
611        assert_eq!(db.var_type.as_deref(), Some("string"));
612    }
613
614    #[test]
615    fn parses_var_tag_with_description() {
616        let raw = "/** @var string The user's name */";
617        let db = parse_docblock(raw);
618        assert_eq!(db.var_type.as_deref(), Some("string"));
619        assert_eq!(db.var_description.as_deref(), Some("The user's name"));
620    }
621
622    #[test]
623    fn to_markdown_shows_var_type() {
624        let db = Docblock {
625            var_type: Some("string".to_string()),
626            ..Default::default()
627        };
628        let md = db.to_markdown();
629        assert!(
630            md.contains("@var"),
631            "expected @var in markdown, got: {}",
632            md
633        );
634        assert!(
635            md.contains("string"),
636            "expected type in markdown, got: {}",
637            md
638        );
639    }
640
641    #[test]
642    fn to_markdown_shows_var_type_with_description() {
643        let db = Docblock {
644            var_type: Some("string".to_string()),
645            var_description: Some("The user's name".to_string()),
646            ..Default::default()
647        };
648        let md = db.to_markdown();
649        assert!(
650            md.contains("@var"),
651            "expected @var in markdown, got: {}",
652            md
653        );
654        assert!(
655            md.contains("string"),
656            "expected type in markdown, got: {}",
657            md
658        );
659        assert!(
660            md.contains("The user's name"),
661            "expected description in markdown, got: {}",
662            md
663        );
664    }
665
666    #[test]
667    fn multiple_params() {
668        let raw = "/**\n * @param int $a First\n * @param int $b Second\n */";
669        let db = parse_docblock(raw);
670        assert_eq!(db.params.len(), 2);
671        assert_eq!(db.params[0].name, "$a");
672        assert_eq!(db.params[1].name, "$b");
673    }
674
675    #[test]
676    fn to_markdown_includes_description_and_return() {
677        let db = Docblock {
678            description: "Greets the user.".to_string(),
679            params: vec![],
680            return_type: Some(DocReturn {
681                type_hint: "string".to_string(),
682                description: "The greeting".to_string(),
683            }),
684            var_type: None,
685            ..Default::default()
686        };
687        let md = db.to_markdown();
688        assert!(md.contains("Greets the user."));
689        assert!(md.contains("@return"));
690        assert!(md.contains("string"));
691    }
692
693    #[test]
694    fn find_docblock_from_ast() {
695        use crate::ast::ParsedDoc;
696        let src = "<?php\n/** Greets someone. */\nfunction greet() {}";
697        let doc = ParsedDoc::parse(src.to_string());
698        let db = find_docblock(src, &doc.program().stmts, "greet");
699        assert!(db.is_some(), "expected docblock for greet");
700        assert!(db.unwrap().description.contains("Greets"));
701    }
702
703    #[test]
704    fn find_docblock_returns_none_without_docblock() {
705        use crate::ast::ParsedDoc;
706        let src = "<?php\nfunction greet() {}";
707        let doc = ParsedDoc::parse(src.to_string());
708        let db = find_docblock(src, &doc.program().stmts, "greet");
709        assert!(db.is_none());
710    }
711
712    #[test]
713    fn empty_docblock_gives_defaults() {
714        let db = parse_docblock("/** */");
715        assert_eq!(db.description, "");
716        assert!(db.return_type.is_none());
717        assert!(db.params.is_empty());
718    }
719
720    #[test]
721    fn parses_deprecated_with_message() {
722        let raw = "/**\n * @deprecated Use newMethod() instead\n */";
723        let db = parse_docblock(raw);
724        assert_eq!(db.deprecated.as_deref(), Some("Use newMethod() instead"));
725        assert!(db.is_deprecated());
726    }
727
728    #[test]
729    fn parses_deprecated_without_message() {
730        let raw = "/** @deprecated */";
731        let db = parse_docblock(raw);
732        assert_eq!(db.deprecated.as_deref(), Some(""));
733        assert!(db.is_deprecated());
734    }
735
736    #[test]
737    fn not_deprecated_when_tag_absent() {
738        let raw = "/** Does stuff. */";
739        let db = parse_docblock(raw);
740        assert!(!db.is_deprecated());
741    }
742
743    #[test]
744    fn parses_throws_tag() {
745        let raw = "/**\n * @throws RuntimeException When something fails\n */";
746        let db = parse_docblock(raw);
747        assert_eq!(db.throws.len(), 1);
748        assert_eq!(db.throws[0].class, "RuntimeException");
749        assert_eq!(db.throws[0].description, "When something fails");
750    }
751
752    #[test]
753    fn parses_multiple_throws() {
754        let raw =
755            "/**\n * @throws InvalidArgumentException\n * @throws RuntimeException Bad state\n */";
756        let db = parse_docblock(raw);
757        assert_eq!(db.throws.len(), 2);
758        assert_eq!(db.throws[0].class, "InvalidArgumentException");
759        assert_eq!(db.throws[1].class, "RuntimeException");
760    }
761
762    #[test]
763    fn parses_see_tag() {
764        let raw = "/**\n * @see OtherClass::method()\n */";
765        let db = parse_docblock(raw);
766        assert_eq!(db.see.len(), 1);
767        assert_eq!(db.see[0], "OtherClass::method()");
768    }
769
770    #[test]
771    fn parses_link_tag() {
772        let raw = "/**\n * @link https://example.com/docs\n */";
773        let db = parse_docblock(raw);
774        assert_eq!(db.see.len(), 1);
775        assert_eq!(db.see[0], "https://example.com/docs");
776    }
777
778    #[test]
779    fn to_markdown_shows_deprecated_banner() {
780        let db = Docblock {
781            deprecated: Some("Use bar() instead".to_string()),
782            description: "Does foo.".to_string(),
783            ..Default::default()
784        };
785        let md = db.to_markdown();
786        assert!(
787            md.contains("> **Deprecated**"),
788            "expected deprecated banner, got: {}",
789            md
790        );
791        assert!(
792            md.contains("Use bar() instead"),
793            "expected deprecation message, got: {}",
794            md
795        );
796    }
797
798    #[test]
799    fn to_markdown_shows_throws() {
800        let db = Docblock {
801            throws: vec![DocThrows {
802                class: "RuntimeException".to_string(),
803                description: "On failure".to_string(),
804            }],
805            ..Default::default()
806        };
807        let md = db.to_markdown();
808        assert!(
809            md.contains("@throws"),
810            "expected @throws in markdown, got: {}",
811            md
812        );
813        assert!(
814            md.contains("RuntimeException"),
815            "expected class name, got: {}",
816            md
817        );
818    }
819
820    #[test]
821    fn to_markdown_shows_see() {
822        let db = Docblock {
823            see: vec!["https://example.com".to_string()],
824            ..Default::default()
825        };
826        let md = db.to_markdown();
827        assert!(
828            md.contains("@see"),
829            "expected @see in markdown, got: {}",
830            md
831        );
832        assert!(
833            md.contains("https://example.com"),
834            "expected url, got: {}",
835            md
836        );
837    }
838
839    #[test]
840    fn parses_template_tag() {
841        let raw = "/**\n * @template T\n */";
842        let db = parse_docblock(raw);
843        assert_eq!(db.templates.len(), 1);
844        assert_eq!(db.templates[0].name, "T");
845        assert!(db.templates[0].bound.is_none());
846    }
847
848    #[test]
849    fn parses_template_with_bound() {
850        let raw = "/**\n * @template T of BaseClass\n */";
851        let db = parse_docblock(raw);
852        assert_eq!(db.templates.len(), 1);
853        assert_eq!(db.templates[0].name, "T");
854        assert_eq!(db.templates[0].bound.as_deref(), Some("BaseClass"));
855    }
856
857    #[test]
858    fn parses_mixin_tag() {
859        let raw = "/**\n * @mixin SomeTrait\n */";
860        let db = parse_docblock(raw);
861        assert_eq!(db.mixins.len(), 1);
862        assert_eq!(db.mixins[0], "SomeTrait");
863    }
864
865    #[test]
866    fn parses_callable_param() {
867        let raw = "/**\n * @param callable(int, string): void $fn The callback\n */";
868        let db = parse_docblock(raw);
869        assert_eq!(db.params.len(), 1);
870        assert_eq!(db.params[0].type_hint, "callable(int, string): void");
871        assert_eq!(db.params[0].name, "$fn");
872        assert_eq!(db.params[0].description, "The callback");
873    }
874
875    #[test]
876    fn to_markdown_shows_template() {
877        let db = Docblock {
878            templates: vec![DocTemplate {
879                name: "T".to_string(),
880                bound: Some("Base".to_string()),
881            }],
882            ..Default::default()
883        };
884        let md = db.to_markdown();
885        assert!(
886            md.contains("@template"),
887            "expected @template in markdown, got: {}",
888            md
889        );
890        assert!(md.contains("T"), "expected T in markdown");
891        assert!(md.contains("Base"), "expected Base in markdown");
892    }
893
894    #[test]
895    fn to_markdown_shows_mixin() {
896        let db = Docblock {
897            mixins: vec!["SomeTrait".to_string()],
898            ..Default::default()
899        };
900        let md = db.to_markdown();
901        assert!(
902            md.contains("@mixin"),
903            "expected @mixin in markdown, got: {}",
904            md
905        );
906        assert!(md.contains("SomeTrait"), "expected SomeTrait in markdown");
907    }
908
909    #[test]
910    fn parses_psalm_type_alias() {
911        let raw = "/**\n * @psalm-type UserId = string|int\n */";
912        let db = parse_docblock(raw);
913        assert_eq!(db.type_aliases.len(), 1);
914        assert_eq!(db.type_aliases[0].name, "UserId");
915        assert_eq!(db.type_aliases[0].type_expr, "string|int");
916    }
917
918    #[test]
919    fn parses_phpstan_type_alias() {
920        let raw = "/** @phpstan-type Row = array{id: int, name: string} */";
921        let db = parse_docblock(raw);
922        assert_eq!(db.type_aliases.len(), 1);
923        assert_eq!(db.type_aliases[0].name, "Row");
924        assert!(db.type_aliases[0].type_expr.contains("array"));
925    }
926
927    #[test]
928    fn to_markdown_shows_type_alias() {
929        let db = Docblock {
930            type_aliases: vec![DocTypeAlias {
931                name: "Status".to_string(),
932                type_expr: "string".to_string(),
933            }],
934            ..Default::default()
935        };
936        let md = db.to_markdown();
937        assert!(md.contains("Status"), "expected alias name in markdown");
938        assert!(md.contains("string"), "expected type expr in markdown");
939    }
940
941    #[test]
942    fn parses_property_tag() {
943        let src = "/** @property string $name */";
944        let db = parse_docblock(src);
945        assert_eq!(db.properties.len(), 1);
946        assert_eq!(db.properties[0].name, "name");
947        assert_eq!(db.properties[0].type_hint, "string");
948        assert!(!db.properties[0].read_only);
949    }
950
951    #[test]
952    fn parses_property_read_tag() {
953        let src = "/** @property-read Carbon $createdAt */";
954        let db = parse_docblock(src);
955        assert_eq!(db.properties[0].name, "createdAt");
956        assert!(db.properties[0].read_only);
957    }
958
959    #[test]
960    fn parses_method_tag() {
961        let src = "/** @method User find(int $id) */";
962        let db = parse_docblock(src);
963        assert_eq!(db.methods.len(), 1);
964        assert_eq!(db.methods[0].name, "find");
965        assert_eq!(db.methods[0].return_type, "User");
966        assert!(!db.methods[0].is_static);
967    }
968
969    #[test]
970    fn parses_static_method_tag() {
971        let src = "/** @method static Builder where(string $col, mixed $val) */";
972        let db = parse_docblock(src);
973        assert!(db.methods[0].is_static);
974        assert_eq!(db.methods[0].name, "where");
975    }
976
977    #[test]
978    fn psalm_param_alias_parsed_as_param() {
979        let raw = "/**\n * @psalm-param string $x The value\n */";
980        let db = parse_docblock(raw);
981        assert_eq!(db.params.len(), 1);
982        assert_eq!(db.params[0].type_hint, "string");
983        assert_eq!(db.params[0].name, "$x");
984    }
985
986    #[test]
987    fn phpstan_param_alias_parsed_as_param() {
988        let raw = "/**\n * @phpstan-param int $count\n */";
989        let db = parse_docblock(raw);
990        assert_eq!(db.params.len(), 1);
991        assert_eq!(db.params[0].type_hint, "int");
992        assert_eq!(db.params[0].name, "$count");
993    }
994
995    #[test]
996    fn psalm_return_alias_parsed_as_return() {
997        let raw = "/**\n * @psalm-return non-empty-string\n */";
998        let db = parse_docblock(raw);
999        assert_eq!(
1000            db.return_type.as_ref().map(|r| r.type_hint.as_str()),
1001            Some("non-empty-string")
1002        );
1003    }
1004
1005    #[test]
1006    fn phpstan_return_alias_parsed_as_return() {
1007        let raw = "/**\n * @phpstan-return array<int, string>\n */";
1008        let db = parse_docblock(raw);
1009        assert_eq!(
1010            db.return_type.as_ref().map(|r| r.type_hint.as_str()),
1011            Some("array<int, string>")
1012        );
1013    }
1014
1015    #[test]
1016    fn psalm_var_alias_parsed_as_var() {
1017        let raw = "/** @psalm-var Foo $item */";
1018        let db = parse_docblock(raw);
1019        assert_eq!(db.var_type.as_deref(), Some("Foo"));
1020        assert_eq!(db.var_name.as_deref(), Some("item"));
1021    }
1022
1023    #[test]
1024    fn phpstan_var_alias_parsed_as_var() {
1025        let raw = "/** @phpstan-var string */";
1026        let db = parse_docblock(raw);
1027        assert_eq!(db.var_type.as_deref(), Some("string"));
1028    }
1029
1030    #[test]
1031    fn param_without_description_parses_correctly() {
1032        let raw = "/**\n * @param string $x\n */";
1033        let db = parse_docblock(raw);
1034        assert_eq!(db.params.len(), 1);
1035        assert_eq!(
1036            db.params[0].type_hint, "string",
1037            "type_hint should be 'string'"
1038        );
1039        assert_eq!(db.params[0].name, "$x", "name should be '$x'");
1040        assert_eq!(
1041            db.params[0].description, "",
1042            "description should be empty when absent"
1043        );
1044    }
1045
1046    #[test]
1047    fn union_type_param_parsed() {
1048        let raw = "/**\n * @param Foo|Bar $x Some value\n */";
1049        let db = parse_docblock(raw);
1050        assert_eq!(db.params.len(), 1);
1051        assert_eq!(
1052            db.params[0].type_hint, "Foo|Bar",
1053            "union type should be 'Foo|Bar', got: {}",
1054            db.params[0].type_hint
1055        );
1056        assert_eq!(db.params[0].name, "$x");
1057    }
1058
1059    #[test]
1060    fn nullable_type_param_parsed() {
1061        // `?Foo` is normalized to the canonical `Foo|null` form.
1062        let raw = "/**\n * @param ?Foo $x\n */";
1063        let db = parse_docblock(raw);
1064        assert_eq!(db.params.len(), 1);
1065        assert_eq!(
1066            db.params[0].type_hint, "Foo|null",
1067            "nullable type should be 'Foo|null', got: {}",
1068            db.params[0].type_hint
1069        );
1070        assert_eq!(db.params[0].name, "$x");
1071    }
1072
1073    #[test]
1074    fn method_tag_extracts_return_type() {
1075        let raw = "/**\n * @method string getName()\n */";
1076        let db = parse_docblock(raw);
1077        assert_eq!(db.methods.len(), 1);
1078        assert_eq!(
1079            db.methods[0].return_type, "string",
1080            "return_type should be 'string', got: {}",
1081            db.methods[0].return_type
1082        );
1083        assert_eq!(
1084            db.methods[0].name, "getName",
1085            "name should be 'getName', got: {}",
1086            db.methods[0].name
1087        );
1088        assert!(!db.methods[0].is_static, "should not be static");
1089    }
1090
1091    #[test]
1092    fn advanced_type_non_empty_string() {
1093        // mir resolves psalm/phpstan special types; non-empty-string must round-trip.
1094        let raw = "/**\n * @return non-empty-string\n */";
1095        let db = parse_docblock(raw);
1096        assert_eq!(
1097            db.return_type.as_ref().map(|r| r.type_hint.as_str()),
1098            Some("non-empty-string"),
1099            "non-empty-string should be preserved, got: {:?}",
1100            db.return_type
1101        );
1102    }
1103
1104    #[test]
1105    fn advanced_type_generic_array() {
1106        // array<K, V> generic syntax must round-trip through mir's Union display.
1107        let raw = "/**\n * @param array<int, string> $map\n */";
1108        let db = parse_docblock(raw);
1109        assert_eq!(db.params.len(), 1);
1110        assert_eq!(
1111            db.params[0].type_hint, "array<int, string>",
1112            "generic array type should be preserved, got: {}",
1113            db.params[0].type_hint
1114        );
1115    }
1116
1117    #[test]
1118    fn param_and_return_descriptions_preserved() {
1119        // Descriptions from @param and @return are captured via php-rs-parser
1120        // (mir discards them). Verify they survive the full parse_docblock() call.
1121        let raw = "/**\n * @param string $name The user name\n * @return int The age\n */";
1122        let db = parse_docblock(raw);
1123        assert_eq!(
1124            db.params[0].description, "The user name",
1125            "param description should be preserved"
1126        );
1127        assert_eq!(
1128            db.return_type.as_ref().map(|r| r.description.as_str()),
1129            Some("The age"),
1130            "return description should be preserved"
1131        );
1132    }
1133
1134    #[test]
1135    fn throws_description_preserved() {
1136        // @throws description must survive the adapter (mir only stores the class).
1137        let raw = "/**\n * @throws RuntimeException When the server is down\n */";
1138        let db = parse_docblock(raw);
1139        assert_eq!(db.throws.len(), 1);
1140        assert_eq!(db.throws[0].class, "RuntimeException");
1141        assert_eq!(
1142            db.throws[0].description, "When the server is down",
1143            "throws description should be preserved"
1144        );
1145    }
1146}