rhai_autodocs/
item.rs

1use crate::{
2    custom_types,
3    export::{ItemsOrder, Options, RHAI_ITEM_INDEX_PATTERN},
4    function,
5    module::Error,
6};
7use serde::ser::SerializeStruct;
8
9/// Generic representation of documentation for a specific item. (a function, a custom type, etc.)
10#[derive(Debug, Clone)]
11pub enum Item {
12    Function {
13        root_metadata: function::Metadata,
14        metadata: Vec<function::Metadata>,
15        name: String,
16        index: usize,
17    },
18    CustomType {
19        metadata: custom_types::Metadata,
20        index: usize,
21    },
22}
23
24impl serde::Serialize for Item {
25    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
26    where
27        S: serde::Serializer,
28    {
29        match self {
30            Self::Function {
31                root_metadata,
32                name,
33                metadata,
34                ..
35            } => {
36                let mut state = serializer.serialize_struct("item", 4)?;
37                state.serialize_field(
38                    "type",
39                    root_metadata.generate_function_definition().type_to_str(),
40                )?;
41                state.serialize_field("heading_id", &self.heading_id())?;
42                state.serialize_field("name", name)?;
43                state.serialize_field(
44                    "signatures",
45                    metadata
46                        .iter()
47                        .map(|metadata| metadata.generate_function_definition().display())
48                        .collect::<Vec<_>>()
49                        .join("\n")
50                        .as_str(),
51                )?;
52                state.serialize_field("sections", {
53                    &Section::extract_sections(
54                        &root_metadata
55                            .doc_comments
56                            .clone()
57                            .unwrap_or_default()
58                            .join("\n"),
59                    )
60                })?;
61                state.end()
62            }
63            Self::CustomType { metadata, .. } => {
64                let mut state = serializer.serialize_struct("item", 2)?;
65                state.serialize_field("name", &metadata.display_name)?;
66                state.serialize_field("heading_id", &self.heading_id())?;
67                state.serialize_field(
68                    "sections",
69                    &Section::extract_sections(
70                        &metadata.doc_comments.clone().unwrap_or_default().join("\n"),
71                    ),
72                )?;
73                state.end()
74            }
75        }
76    }
77}
78
79impl Item {
80    pub(crate) fn new_function(
81        metadata: &[function::Metadata],
82        name: &str,
83        options: &Options,
84    ) -> Result<Option<Self>, Error> {
85        // Takes the first valid comments found for a function group.
86        let root = metadata
87            .iter()
88            .find(|metadata| metadata.doc_comments.is_some());
89
90        match root {
91            // Anonymous functions are ignored.
92            Some(root) if !name.starts_with("anon$") => {
93                if matches!(options.items_order, ItemsOrder::ByIndex) {
94                    Self::find_index(root.doc_comments.as_ref().unwrap_or(&vec![]))?
95                } else {
96                    Some(0)
97                }
98                .map_or_else(
99                    || Ok(None),
100                    |index| {
101                        Ok(Some(Self::Function {
102                            root_metadata: root.clone(),
103                            metadata: metadata.to_vec(),
104                            name: name.to_string(),
105                            index,
106                        }))
107                    },
108                )
109            }
110            _ => Ok(None),
111        }
112    }
113
114    pub(crate) fn new_custom_type(
115        metadata: custom_types::Metadata,
116        options: &Options,
117    ) -> Result<Option<Self>, Error> {
118        if matches!(options.items_order, ItemsOrder::ByIndex) {
119            Self::find_index(metadata.doc_comments.as_ref().unwrap_or(&vec![]))?
120        } else {
121            Some(0)
122        }
123        .map_or_else(
124            || Ok(None),
125            |index| Ok(Some(Self::CustomType { metadata, index })),
126        )
127    }
128
129    /// Get the index of the item, extracted from the `# rhai-autodocs:index` directive.
130    #[must_use]
131    pub const fn index(&self) -> usize {
132        match self {
133            Self::CustomType { index, .. } | Self::Function { index, .. } => *index,
134        }
135    }
136
137    /// Get the name of the item.
138    #[must_use]
139    pub fn name(&self) -> &str {
140        match self {
141            Self::CustomType { metadata, .. } => metadata.display_name.as_str(),
142            Self::Function { name, .. } => name,
143        }
144    }
145
146    /// Generate a heading id for mardown, using the type and name of the item.
147    #[must_use]
148    pub fn heading_id(&self) -> String {
149        let prefix = match self {
150            Self::Function { root_metadata, .. } => root_metadata
151                .generate_function_definition()
152                .type_to_str()
153                .replace(['/', ' '], ""),
154            Self::CustomType { .. } => "type".to_string(),
155        };
156
157        format!("{prefix}-{}", self.name())
158    }
159
160    /// Find the order index of the item by searching for the index pattern.
161    pub(crate) fn find_index(doc_comments: &[String]) -> Result<Option<usize>, Error> {
162        for line in doc_comments {
163            if let Some((_, index)) = line.rsplit_once(RHAI_ITEM_INDEX_PATTERN) {
164                return index
165                    .parse::<usize>()
166                    .map_err(Error::ParseOrderMetadata)
167                    .map(Some);
168            }
169        }
170
171        Ok(None)
172    }
173
174    /// Format the function doc comments to make them
175    /// into readable markdown.
176    pub(crate) fn format_comments(doc_comments: &[String]) -> String {
177        let doc_comments = doc_comments.to_vec();
178        let removed_extra_tokens = Self::remove_extra_tokens(doc_comments).join("\n");
179        let remove_comments = Self::fmt_doc_comments(&removed_extra_tokens);
180
181        Self::remove_test_code(&remove_comments)
182    }
183
184    /// Remove crate specific comments, like `rhai-autodocs:index`.
185    pub(crate) fn remove_extra_tokens(dc: Vec<String>) -> Vec<String> {
186        dc.into_iter()
187            .map(|s| {
188                s.lines()
189                    .filter(|l| !l.contains(RHAI_ITEM_INDEX_PATTERN))
190                    .collect::<Vec<_>>()
191                    .join("\n")
192            })
193            .collect::<Vec<_>>()
194    }
195
196    /// Remove doc comments identifiers.
197    pub(crate) fn fmt_doc_comments(dc: &str) -> String {
198        dc.replace("/// ", "")
199            .replace("///", "")
200            .replace("/**", "")
201            .replace("**/", "")
202            .replace("**/", "")
203    }
204
205    /// NOTE: mdbook handles this automatically, but other
206    ///       markdown processors might not.
207    /// Remove lines of code that starts with the '#' token,
208    /// which are removed on rust docs automatically.
209    pub(crate) fn remove_test_code(doc_comments: &str) -> String {
210        let mut formatted = vec![];
211        let mut in_code_block = false;
212        for line in doc_comments.lines() {
213            if line.starts_with("```") {
214                in_code_block = !in_code_block;
215                formatted.push(line);
216                continue;
217            }
218
219            if !(in_code_block && line.starts_with("# ")) {
220                formatted.push(line);
221            }
222        }
223
224        formatted.join("\n")
225    }
226}
227
228#[derive(Default, Clone, serde::Serialize)]
229struct Section {
230    pub name: String,
231    pub body: String,
232}
233
234impl Section {
235    fn extract_sections(docs: &str) -> Vec<Self> {
236        let mut sections = vec![];
237        let mut current_name = "Description".to_string();
238        let mut current_body = vec![];
239        let mut in_code_block = false;
240
241        // Start by extracting all sections from markdown comments.
242        docs.lines().for_each(|line| {
243            if line.split_once("```").is_some() {
244                in_code_block = !in_code_block;
245            }
246
247            match line.split_once("# ") {
248                Some((_prefix, name))
249                    if !in_code_block && !line.contains(RHAI_ITEM_INDEX_PATTERN) =>
250                {
251                    sections.push(Self {
252                        name: std::mem::take(&mut current_name),
253                        body: Item::format_comments(&current_body[..]),
254                    });
255
256                    current_name = name.to_string();
257                    current_body = vec![];
258                }
259                Some(_) | None => current_body.push(format!("{line}\n")),
260            }
261        });
262
263        if !current_body.is_empty() {
264            sections.push(Self {
265                name: std::mem::take(&mut current_name),
266                body: Item::format_comments(&current_body[..]),
267            });
268        }
269
270        sections
271    }
272}
273
274#[cfg(test)]
275pub mod test {
276    use super::*;
277
278    #[test]
279    fn test_remove_test_code_simple() {
280        pretty_assertions::assert_eq!(
281            Item::remove_test_code(
282                r"
283# Not removed.
284```
285fn my_func(a: int) -> () {}
286do stuff ...
287# Please hide this.
288do something else ...
289# Also this.
290```
291# Not removed either.
292",
293            ),
294            r"
295# Not removed.
296```
297fn my_func(a: int) -> () {}
298do stuff ...
299do something else ...
300```
301# Not removed either.",
302        );
303    }
304
305    #[test]
306    fn test_remove_test_code_multiple_blocks() {
307        pretty_assertions::assert_eq!(
308            Item::remove_test_code(
309                r"
310```ignore
311block 1
312# Please hide this.
313```
314
315# A title
316
317```
318block 2
319# Please hide this.
320john
321doe
322# To hide.
323```
324",
325            ),
326            r"
327```ignore
328block 1
329```
330
331# A title
332
333```
334block 2
335john
336doe
337```",
338        );
339    }
340
341    #[test]
342    fn test_remove_test_code_with_rhai_map() {
343        pretty_assertions::assert_eq!(
344            Item::remove_test_code(
345                r#"
346```rhai
347#{
348    "a": 1,
349    "b": 2,
350    "c": 3,
351};
352# Please hide this.
353```
354
355# A title
356
357```
358# Please hide this.
359let map = #{
360    "hello": "world"
361# To hide.
362};
363# To hide.
364```
365"#,
366            ),
367            r#"
368```rhai
369#{
370    "a": 1,
371    "b": 2,
372    "c": 3,
373};
374```
375
376# A title
377
378```
379let map = #{
380    "hello": "world"
381};
382```"#,
383        );
384    }
385}