Skip to main content

gruel_doc/
markdown.rs

1//! ADR-0089 Phase 3: Markdown rendering.
2
3use std::fmt::Write;
4
5use crate::links::{LinkTable, rewrite};
6use crate::{DocFile, DocItem, ItemKind, NamedDoc};
7
8/// Render the per-file index page (`<file>/index.md`) listing every
9/// item with a one-line lead pulled from the first line of its docs.
10pub fn render_index(file: &DocFile) -> String {
11    render_index_with(file, &LinkTable::new())
12}
13
14/// Render the per-file index page, rewriting intra-doc links against
15/// the given `LinkTable` (Phase 5).
16pub fn render_index_with(file: &DocFile, table: &LinkTable) -> String {
17    let mut out = String::new();
18    write!(out, "# {}\n\n", file.stem).unwrap();
19    if let Some(module_doc) = &file.module_doc {
20        out.push_str(&rewrite(&module_doc.body, table, ".md"));
21        out.push_str("\n\n");
22    }
23    if file.items.is_empty() {
24        return out;
25    }
26    out.push_str("## Items\n\n");
27    for item in &file.items {
28        write!(
29            out,
30            "- [`{} {}`]({}.md)",
31            item.kind.label(),
32            item.name,
33            item.slug
34        )
35        .unwrap();
36        if let Some(summary) = first_line(&item.doc) {
37            let rewritten = rewrite(summary, table, ".md");
38            write!(out, " — {}", rewritten).unwrap();
39        }
40        out.push('\n');
41    }
42    out
43}
44
45/// Render a single item's Markdown page (`<slug>.md`).
46pub fn render_markdown(item: &DocItem) -> String {
47    render_markdown_with(item, &LinkTable::new())
48}
49
50/// Render a single item's Markdown page, rewriting intra-doc links
51/// against the given `LinkTable` (Phase 5).
52pub fn render_markdown_with(item: &DocItem, table: &LinkTable) -> String {
53    let mut out = String::new();
54    write!(out, "# `{} {}`\n\n", item.kind.label(), item.name).unwrap();
55    if let Some(doc) = &item.doc {
56        out.push_str(&rewrite(&doc.body, table, ".md"));
57        out.push_str("\n\n");
58    }
59    render_sections(&mut out, item, table);
60    out
61}
62
63fn render_sections(out: &mut String, item: &DocItem, table: &LinkTable) {
64    render_named_section(out, "Fields", &item.detail.fields, table);
65    render_named_section(out, "Variants", &item.detail.variants, table);
66    render_named_section(out, "Methods", &item.detail.methods, table);
67    if !item.detail.extern_fns.is_empty() {
68        match item.kind {
69            ItemKind::LinkExtern => {
70                render_named_section(out, "Extern functions", &item.detail.extern_fns, table)
71            }
72            _ => render_named_section(out, "Functions", &item.detail.extern_fns, table),
73        }
74    }
75}
76
77fn render_named_section(out: &mut String, heading: &str, items: &[NamedDoc], table: &LinkTable) {
78    if items.is_empty() {
79        return;
80    }
81    write!(out, "## {}\n\n", heading).unwrap();
82    for it in items {
83        write!(out, "- `{}`", it.name).unwrap();
84        if let Some(summary) = first_line(&it.doc) {
85            let rewritten = rewrite(summary, table, ".md");
86            write!(out, " — {}", rewritten).unwrap();
87        }
88        out.push('\n');
89    }
90    out.push('\n');
91}
92
93fn first_line(doc: &Option<gruel_parser::ast::Doc>) -> Option<&str> {
94    doc.as_ref().and_then(|d| d.body.lines().next())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use gruel_parser::ast::Doc;
101    use gruel_util::Span;
102
103    fn make_doc(body: &str) -> Doc {
104        Doc {
105            body: body.to_string(),
106            span: Span::default(),
107        }
108    }
109
110    #[test]
111    fn render_fn_page() {
112        let item = DocItem {
113            slug: "fn.foo".into(),
114            name: "foo".into(),
115            kind: ItemKind::Function,
116            doc: Some(make_doc("Does the foo.\n\nMore details.")),
117            detail: Default::default(),
118        };
119        let md = render_markdown(&item);
120        assert!(md.contains("# `fn foo`"));
121        assert!(md.contains("Does the foo."));
122        assert!(md.contains("More details."));
123    }
124
125    #[test]
126    fn render_index_page() {
127        let file = DocFile {
128            stem: "math".into(),
129            module_doc: Some(make_doc("Module-level docs.")),
130            items: vec![
131                DocItem {
132                    slug: "fn.add".into(),
133                    name: "add".into(),
134                    kind: ItemKind::Function,
135                    doc: Some(make_doc("Adds two ints.")),
136                    detail: Default::default(),
137                },
138                DocItem {
139                    slug: "fn.sub".into(),
140                    name: "sub".into(),
141                    kind: ItemKind::Function,
142                    doc: None,
143                    detail: Default::default(),
144                },
145            ],
146        };
147        let md = render_index(&file);
148        assert!(md.contains("# math"));
149        assert!(md.contains("Module-level docs."));
150        // Items now appear as Markdown links to their own slug page.
151        assert!(md.contains("- [`fn add`](fn.add.md) — Adds two ints."));
152        assert!(md.contains("- [`fn sub`](fn.sub.md)"));
153    }
154
155    #[test]
156    fn intra_doc_link_rewritten_in_body() {
157        let mut table = LinkTable::new();
158        table.insert("Vec", "struct", "struct.Vec");
159        let item = DocItem {
160            slug: "fn.push".into(),
161            name: "push".into(),
162            kind: ItemKind::Function,
163            doc: Some(make_doc("Push to a [Vec].")),
164            detail: Default::default(),
165        };
166        let md = render_markdown_with(&item, &table);
167        assert!(md.contains("[Vec](struct.Vec.md)"));
168    }
169
170    #[test]
171    fn struct_page_lists_fields() {
172        let item = DocItem {
173            slug: "struct.Point".into(),
174            name: "Point".into(),
175            kind: ItemKind::Struct,
176            doc: Some(make_doc("A 2D point.")),
177            detail: crate::ItemDetail {
178                fields: vec![
179                    NamedDoc {
180                        name: "x".into(),
181                        doc: Some(make_doc("x coordinate")),
182                    },
183                    NamedDoc {
184                        name: "y".into(),
185                        doc: None,
186                    },
187                ],
188                ..Default::default()
189            },
190        };
191        let md = render_markdown(&item);
192        assert!(md.contains("## Fields"));
193        assert!(md.contains("- `x` — x coordinate"));
194        assert!(md.contains("- `y`\n"));
195    }
196}