Skip to main content

shape_lsp/
doc_render.rs

1use crate::doc_links::{render_doc_link_target, resolve_doc_link};
2use crate::module_cache::ModuleCache;
3use shape_ast::ast::{DocComment, DocTag, DocTagKind, Program};
4use std::path::Path;
5
6pub fn render_doc_comment(
7    program: &Program,
8    comment: &DocComment,
9    module_cache: Option<&ModuleCache>,
10    current_file: Option<&Path>,
11    workspace_root: Option<&Path>,
12) -> String {
13    let mut sections = Vec::new();
14
15    if !comment.body.is_empty() {
16        sections.push(comment.body.clone());
17    } else if !comment.summary.is_empty() {
18        sections.push(comment.summary.clone());
19    }
20
21    push_named_section(
22        &mut sections,
23        "Type Parameters",
24        comment
25            .tags
26            .iter()
27            .filter(|tag| matches!(tag.kind, DocTagKind::TypeParam))
28            .collect(),
29    );
30    push_named_section(
31        &mut sections,
32        "Parameters",
33        comment
34            .tags
35            .iter()
36            .filter(|tag| matches!(tag.kind, DocTagKind::Param))
37            .collect(),
38    );
39    push_singleton_section(
40        &mut sections,
41        "Returns",
42        tag_body(comment, DocTagKind::Returns),
43    );
44    push_singleton_section(
45        &mut sections,
46        "Deprecated",
47        tag_body(comment, DocTagKind::Deprecated),
48    );
49    push_singleton_section(&mut sections, "Since", tag_body(comment, DocTagKind::Since));
50
51    let notes = comment
52        .tags
53        .iter()
54        .filter(|tag| matches!(tag.kind, DocTagKind::Note))
55        .map(|tag| tag.body.trim())
56        .filter(|body| !body.is_empty())
57        .map(|body| format!("- {body}"))
58        .collect::<Vec<_>>();
59    if !notes.is_empty() {
60        sections.push(format!("**Notes**\n{}", notes.join("\n")));
61    }
62
63    let related = comment
64        .tags
65        .iter()
66        .filter(|tag| matches!(tag.kind, DocTagKind::See | DocTagKind::Link))
67        .filter_map(|tag| {
68            let link = tag.link.as_ref()?;
69            let resolved = resolve_doc_link(
70                program,
71                &link.target,
72                module_cache,
73                current_file,
74                workspace_root,
75            );
76            let rendered =
77                render_doc_link_target(&link.target, link.label.as_deref(), resolved.as_ref());
78            Some(format!("- {rendered}"))
79        })
80        .collect::<Vec<_>>();
81    if !related.is_empty() {
82        sections.push(format!("**See Also**\n{}", related.join("\n")));
83    }
84
85    let examples = comment
86        .tags
87        .iter()
88        .filter(|tag| matches!(tag.kind, DocTagKind::Example))
89        .map(|tag| tag.body.trim())
90        .filter(|body| !body.is_empty())
91        .map(|body| format!("```shape\n{body}\n```"))
92        .collect::<Vec<_>>();
93    if !examples.is_empty() {
94        sections.push(format!("**Examples**\n{}", examples.join("\n\n")));
95    }
96
97    sections
98        .into_iter()
99        .filter(|section| !section.trim().is_empty())
100        .collect::<Vec<_>>()
101        .join("\n\n")
102}
103
104fn push_named_section(sections: &mut Vec<String>, title: &str, tags: Vec<&DocTag>) {
105    if tags.is_empty() {
106        return;
107    }
108    let lines = tags
109        .into_iter()
110        .map(|tag| {
111            let name = tag.name.as_deref().unwrap_or("_");
112            format!("- `{name}`: {}", tag.body)
113        })
114        .collect::<Vec<_>>()
115        .join("\n");
116    sections.push(format!("**{title}**\n{lines}"));
117}
118
119fn push_singleton_section(sections: &mut Vec<String>, title: &str, body: Option<&str>) {
120    let Some(body) = body.filter(|body| !body.trim().is_empty()) else {
121        return;
122    };
123    sections.push(format!("**{title}**\n{body}"));
124}
125
126fn tag_body(comment: &DocComment, kind: DocTagKind) -> Option<&str> {
127    comment.tags.iter().find_map(|tag| {
128        if tag.kind == kind {
129            Some(tag.body.as_str())
130        } else {
131            None
132        }
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use shape_ast::parser::parse_program;
140
141    #[test]
142    fn renders_multiple_examples() {
143        let program = parse_program(
144            "/// Summary\n/// @example\n/// one()\n/// @example\n/// two()\nfn sample() {}\n",
145        )
146        .expect("program");
147        let comment = program.docs.comment_for_path("sample").expect("doc");
148        let markdown = render_doc_comment(&program, comment, None, None, None);
149        assert!(markdown.contains("one()"));
150        assert!(markdown.contains("two()"));
151    }
152}