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}