ron2_doc/
generator.rs

1//! Core markdown documentation generation.
2
3use ahash::HashMap;
4use ron2::schema::{Field, Schema, TypeKind, Variant, VariantKind};
5
6use crate::{
7    config::{DocConfig, OutputFormat, OutputMode},
8    discovery::DiscoveredSchema,
9    example::{build_schema_map, generate_example},
10    link::{LinkResolver, type_path_short_name},
11};
12
13/// Link mode for type formatting.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum LinkMode {
16    /// Generate links to separate markdown files
17    File,
18    /// Generate anchor links within the same page
19    Anchor,
20}
21
22impl From<OutputMode> for LinkMode {
23    fn from(mode: OutputMode) -> Self {
24        match mode {
25            OutputMode::MultiPage => LinkMode::File,
26            OutputMode::SinglePage => LinkMode::Anchor,
27        }
28    }
29}
30
31/// Generate markdown documentation for a schema.
32pub fn generate_markdown(
33    schema: &DiscoveredSchema,
34    config: &DocConfig,
35    all_schemas: &[DiscoveredSchema],
36) -> String {
37    let mut output = String::new();
38    let short_name = type_path_short_name(&schema.type_path);
39    let resolver = LinkResolver::new(all_schemas, config.base_url.as_deref());
40    let link_mode = LinkMode::from(config.output_mode);
41
42    // Header (frontmatter for Starlight)
43    write_header(&mut output, short_name, &schema.schema, config.format);
44
45    // Type description
46    if let Some(doc) = &schema.schema.doc {
47        output.push_str(doc);
48        output.push_str("\n\n");
49    }
50
51    // Main content based on TypeKind (level 2 headings for multi-page)
52    match &schema.schema.kind {
53        TypeKind::Struct { fields } => {
54            write_struct_docs(&mut output, fields, &resolver, link_mode, 2);
55        }
56        TypeKind::Enum { variants } => {
57            write_enum_docs(&mut output, variants, &resolver, link_mode, 2);
58        }
59        _ => {
60            // For primitives, options, etc., just show the type
61            output.push_str("## Type\n\n");
62            output.push_str(&format_type(&resolver, &schema.schema.kind, link_mode));
63            output.push_str("\n\n");
64        }
65    }
66
67    // RON example
68    let schema_map = build_schema_map(all_schemas);
69    write_example(
70        &mut output,
71        &schema.schema,
72        config.example_depth,
73        &schema_map,
74    );
75
76    output
77}
78
79/// Generate type content for a schema (description + fields/variants).
80/// Used by single-page generator to embed type docs.
81///
82/// `heading_level` is the level for section headings (Fields, Variants).
83/// - For multi-page mode: use 2 (## Fields)
84/// - For single-page mode: use 4 (#### Fields) since type is at level 3
85pub fn generate_type_content(
86    schema: &DiscoveredSchema,
87    resolver: &LinkResolver,
88    link_mode: LinkMode,
89    heading_level: u8,
90) -> String {
91    let mut output = String::new();
92
93    // Type description
94    if let Some(doc) = &schema.schema.doc {
95        output.push_str(doc);
96        output.push_str("\n\n");
97    }
98
99    // Main content based on TypeKind
100    match &schema.schema.kind {
101        TypeKind::Struct { fields } => {
102            write_struct_docs(&mut output, fields, resolver, link_mode, heading_level);
103        }
104        TypeKind::Enum { variants } => {
105            write_enum_docs(&mut output, variants, resolver, link_mode, heading_level);
106        }
107        _ => {
108            output.push_str("**Type:** ");
109            output.push_str(&format_type(resolver, &schema.schema.kind, link_mode));
110            output.push_str("\n\n");
111        }
112    }
113
114    output
115}
116
117/// Generate a markdown heading at the given level.
118fn heading(level: u8, text: &str) -> String {
119    let hashes = "#".repeat(level as usize);
120    format!("{} {}\n\n", hashes, text)
121}
122
123/// Generate a RON example for a schema.
124pub fn generate_type_example(
125    schema: &Schema,
126    max_depth: usize,
127    all_schemas: &[DiscoveredSchema],
128) -> String {
129    let schema_map = build_schema_map(all_schemas);
130    let mut output = String::new();
131    output.push_str("```ron\n");
132    output.push_str(&generate_example(schema, max_depth, &schema_map));
133    output.push_str("\n```\n");
134    output
135}
136
137fn write_header(output: &mut String, name: &str, schema: &Schema, format: OutputFormat) {
138    match format {
139        OutputFormat::Starlight => {
140            output.push_str("---\n");
141            output.push_str(&format!("title: {}\n", name));
142            if let Some(doc) = &schema.doc {
143                let first_line = doc.lines().next().unwrap_or("");
144                let escaped = first_line.replace('"', "\\\"");
145                output.push_str(&format!("description: \"{}\"\n", escaped));
146            }
147            output.push_str("---\n\n");
148        }
149        OutputFormat::Plain => {
150            output.push_str(&format!("# {}\n\n", name));
151        }
152    }
153}
154
155/// Format a type based on the link mode.
156fn format_type(resolver: &LinkResolver, ty: &TypeKind, link_mode: LinkMode) -> String {
157    match link_mode {
158        LinkMode::File => resolver.type_to_markdown(ty),
159        LinkMode::Anchor => resolver.type_to_markdown_anchor(ty),
160    }
161}
162
163fn write_struct_docs(
164    output: &mut String,
165    fields: &[Field],
166    resolver: &LinkResolver,
167    link_mode: LinkMode,
168    heading_level: u8,
169) {
170    if fields.is_empty() {
171        output.push_str("This is a unit struct with no fields.\n\n");
172        return;
173    }
174
175    output.push_str(&heading(heading_level, "Fields"));
176    output.push_str("| Field | Type | Required | Description |\n");
177    output.push_str("|-------|------|----------|-------------|\n");
178
179    for field in fields {
180        let type_str = format_type(resolver, &field.ty, link_mode);
181        let required = if field.optional { "No" } else { "Yes" };
182        let doc = field.doc.as_deref().unwrap_or("—");
183        // Escape pipe characters in doc and replace newlines
184        let doc_escaped = doc.replace('|', "\\|").replace('\n', " ");
185
186        let flattened_note = if field.flattened {
187            " *(flattened)*"
188        } else {
189            ""
190        };
191
192        output.push_str(&format!(
193            "| `{}` | {} | {} | {}{} |\n",
194            field.name, type_str, required, doc_escaped, flattened_note
195        ));
196    }
197
198    output.push('\n');
199}
200
201fn write_enum_docs(
202    output: &mut String,
203    variants: &[Variant],
204    resolver: &LinkResolver,
205    link_mode: LinkMode,
206    heading_level: u8,
207) {
208    if variants.is_empty() {
209        output.push_str("This enum has no variants.\n\n");
210        return;
211    }
212
213    output.push_str(&heading(heading_level, "Variants"));
214
215    // Check if all variants are unit variants (simple enum)
216    let all_unit = variants.iter().all(|v| matches!(v.kind, VariantKind::Unit));
217    let has_complex = variants
218        .iter()
219        .any(|v| !matches!(v.kind, VariantKind::Unit));
220
221    if all_unit {
222        // Compact table for unit-only enums
223        output.push_str("| Variant | Description |\n");
224        output.push_str("|---------|-------------|\n");
225
226        for variant in variants {
227            let doc = variant.doc.as_deref().unwrap_or("—");
228            let doc_escaped = doc.replace('|', "\\|").replace('\n', " ");
229            output.push_str(&format!("| `{}` | {} |\n", variant.name, doc_escaped));
230        }
231        output.push('\n');
232    } else {
233        // Overview table with links to complex variant sections
234        output.push_str("| Variant | Kind | Description |\n");
235        output.push_str("|---------|------|-------------|\n");
236
237        for variant in variants {
238            let doc = variant
239                .doc
240                .as_ref()
241                .map(|d| d.lines().next().unwrap_or(""))
242                .unwrap_or("—");
243            let doc_escaped = doc.replace('|', "\\|");
244
245            let kind_label = match &variant.kind {
246                VariantKind::Unit => "Unit",
247                VariantKind::Tuple(_) => "Tuple",
248                VariantKind::Struct(_) => "Struct",
249            };
250
251            // Link to section for complex variants
252            let variant_cell = if matches!(variant.kind, VariantKind::Unit) {
253                format!("`{}`", variant.name)
254            } else {
255                let anchor = variant.name.to_lowercase();
256                format!("[`{}`](#{})", variant.name, anchor)
257            };
258
259            output.push_str(&format!(
260                "| {} | {} | {} |\n",
261                variant_cell, kind_label, doc_escaped
262            ));
263        }
264        output.push('\n');
265
266        // Detailed sections only for complex variants (one level deeper)
267        let variant_heading_level = heading_level + 1;
268        if has_complex {
269            for variant in variants {
270                match &variant.kind {
271                    VariantKind::Unit => {
272                        // Skip unit variants - already in table
273                    }
274                    VariantKind::Tuple(types) => {
275                        output.push_str(&heading(
276                            variant_heading_level,
277                            &format!("`{}`", variant.name),
278                        ));
279                        if let Some(doc) = &variant.doc {
280                            output.push_str(doc);
281                            output.push_str("\n\n");
282                        }
283                        let type_strs: Vec<_> = types
284                            .iter()
285                            .map(|t| format_type(resolver, t, link_mode))
286                            .collect();
287                        output.push_str(&format!("**Type:** `({})`\n\n", type_strs.join(", ")));
288                    }
289                    VariantKind::Struct(fields) => {
290                        output.push_str(&heading(
291                            variant_heading_level,
292                            &format!("`{}`", variant.name),
293                        ));
294                        if let Some(doc) = &variant.doc {
295                            output.push_str(doc);
296                            output.push_str("\n\n");
297                        }
298                        output.push_str("**Fields:**\n\n");
299                        output.push_str("| Field | Type | Required | Description |\n");
300                        output.push_str("|-------|------|----------|-------------|\n");
301
302                        for field in fields {
303                            let type_str = format_type(resolver, &field.ty, link_mode);
304                            let required = if field.optional { "No" } else { "Yes" };
305                            let doc = field.doc.as_deref().unwrap_or("—");
306                            let doc_escaped = doc.replace('|', "\\|").replace('\n', " ");
307
308                            output.push_str(&format!(
309                                "| `{}` | {} | {} | {} |\n",
310                                field.name, type_str, required, doc_escaped
311                            ));
312                        }
313                        output.push('\n');
314                    }
315                }
316            }
317        }
318    }
319}
320
321fn write_example(
322    output: &mut String,
323    schema: &Schema,
324    max_depth: usize,
325    schemas: &HashMap<&str, &Schema>,
326) {
327    output.push_str("## Example\n\n");
328    output.push_str("```ron\n");
329    output.push_str(&generate_example(schema, max_depth, schemas));
330    output.push_str("\n```\n");
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_write_header_plain() {
339        let mut output = String::new();
340        let schema = Schema::with_doc("Test description", TypeKind::Unit);
341        write_header(&mut output, "TestType", &schema, OutputFormat::Plain);
342        assert_eq!(output, "# TestType\n\n");
343    }
344
345    #[test]
346    fn test_write_header_starlight() {
347        let mut output = String::new();
348        let schema = Schema::with_doc("Test description", TypeKind::Unit);
349        write_header(&mut output, "TestType", &schema, OutputFormat::Starlight);
350        assert!(output.contains("---"));
351        assert!(output.contains("title: TestType"));
352        assert!(output.contains("description: \"Test description\""));
353    }
354
355    #[test]
356    fn test_write_struct_docs() {
357        let mut output = String::new();
358        let fields = vec![
359            Field::new("name", TypeKind::String).with_doc("The name"),
360            Field::optional("age", TypeKind::I32).with_doc("The age"),
361        ];
362        let schemas = vec![];
363        let resolver = LinkResolver::new(&schemas, None);
364        write_struct_docs(&mut output, &fields, &resolver, LinkMode::File, 2);
365
366        assert!(output.contains("## Fields"));
367        assert!(output.contains("| `name` |"));
368        assert!(output.contains("| Yes |"));
369        assert!(output.contains("| `age` |"));
370        assert!(output.contains("| No |"));
371    }
372}