smart_config_commands/
markdown.rs

1use std::{collections::BTreeMap, io, io::Write as _};
2
3use anstream::stream::{AsLockedWrite, RawStream};
4use cmark_writer::{CommonMarkWriter, ListItem, Node};
5use smart_config::{
6    ConfigRef, ConfigSchema,
7    metadata::{BasicTypes, ConfigTag, ConfigVariant, TypeDescription, TypeSuffixes},
8    pat::PatternDisplay,
9};
10
11use crate::{ParamRef, Printer, schema_ref::collect_conditions};
12
13/// Options controlling Markdown reference generation.
14#[derive(Debug, Clone)]
15#[allow(clippy::struct_excessive_bools)]
16#[non_exhaustive]
17pub struct MarkdownOptions {
18    /// Optional top-level title to emit before the generated reference.
19    pub title: Option<String>,
20    /// Markdown heading level for [`Self::title`]. Nested headings are derived from this level.
21    pub heading_level: u8,
22    /// Whether to include a generated table of contents.
23    pub include_table_of_contents: bool,
24    /// Whether to include Rust type names in the generated reference.
25    pub include_rust_types: bool,
26    /// Whether to include config and param aliases.
27    pub include_aliases: bool,
28    /// Whether to include environment variable names for canonical param paths.
29    pub include_env_vars: Option<EnvVarOptions>,
30    /// Whether to include example values.
31    pub include_examples: bool,
32    /// Whether to mark secret params and child values.
33    pub include_secret_marker: bool,
34}
35
36impl Default for MarkdownOptions {
37    fn default() -> Self {
38        Self {
39            title: Some("Configuration Reference".to_owned()),
40            heading_level: 1,
41            include_table_of_contents: false,
42            include_rust_types: true,
43            include_aliases: true,
44            include_env_vars: None,
45            include_examples: true,
46            include_secret_marker: true,
47        }
48    }
49}
50
51/// Options for rendering environment variable names.
52#[derive(Debug, Clone, Default)]
53#[non_exhaustive]
54pub struct EnvVarOptions {
55    /// Prefix prepended to generated env var names, e.g. `APP_`.
56    pub prefix: String,
57}
58
59#[derive(Debug)]
60struct MarkdownDoc<'a> {
61    config: ConfigRef<'a>,
62    conditions: Vec<(ParamRef<'a>, &'a ConfigVariant)>,
63    params: Vec<ParamRef<'a>>,
64}
65
66#[derive(Debug)]
67struct DetailDoc {
68    label: String,
69    summary: Vec<Node>,
70    children: Vec<DetailDoc>,
71}
72
73impl DetailDoc {
74    fn new(label: impl Into<String>, summary: Vec<Node>) -> Self {
75        Self {
76            label: label.into(),
77            summary,
78            children: vec![],
79        }
80    }
81}
82
83impl<W: RawStream + AsLockedWrite> Printer<W> {
84    /// Prints a Markdown reference for config params in the provided `schema`.
85    ///
86    /// Params can be filtered by the supplied predicate. Enum tag params are rendered together with
87    /// other matching params in the same config so that conditional fields have useful context.
88    ///
89    /// # Errors
90    ///
91    /// Propagates I/O errors.
92    pub fn print_markdown_reference(
93        self,
94        schema: &ConfigSchema,
95        options: &MarkdownOptions,
96        mut filter: impl FnMut(ParamRef<'_>) -> bool,
97    ) -> io::Result<()> {
98        let docs = collect_markdown_docs(schema, &mut filter);
99        let document = render_document(options, &docs)?;
100
101        let mut markdown = CommonMarkWriter::new();
102        markdown.write(&document).map_err(io::Error::other)?;
103        let markdown = trim_trailing_spaces(markdown.into_string().as_ref());
104
105        let mut writer = self.writer;
106        writer.write_all(markdown.as_bytes())
107    }
108}
109
110fn collect_markdown_docs<'a>(
111    schema: &'a ConfigSchema,
112    filter: &mut impl FnMut(ParamRef<'_>) -> bool,
113) -> Vec<MarkdownDoc<'a>> {
114    schema
115        .iter()
116        .filter_map(|config| {
117            let conditions = collect_conditions(config);
118            let params: Vec<_> = config
119                .metadata()
120                .params
121                .iter()
122                .map(|param| ParamRef { config, param })
123                .filter(|&param_ref| filter(param_ref))
124                .collect();
125            (!params.is_empty()).then_some(MarkdownDoc {
126                config,
127                conditions,
128                params,
129            })
130        })
131        .collect()
132}
133
134fn render_document(options: &MarkdownOptions, docs: &[MarkdownDoc<'_>]) -> io::Result<Node> {
135    let mut nodes = vec![];
136    if let Some(title) = &options.title {
137        nodes.push(heading(options.heading_level, vec![text(title)]));
138    }
139    if options.include_table_of_contents {
140        render_table_of_contents(&mut nodes, options, docs);
141    }
142
143    for doc in docs {
144        render_config_reference(&mut nodes, options, doc)?;
145    }
146    Ok(Node::Document(nodes))
147}
148
149fn render_table_of_contents(
150    nodes: &mut Vec<Node>,
151    options: &MarkdownOptions,
152    docs: &[MarkdownDoc<'_>],
153) {
154    nodes.push(heading(options.heading_level + 1, vec![text("Contents")]));
155
156    let mut anchors = BTreeMap::new();
157    let mut items = vec![];
158    for doc in docs {
159        let config_name = config_heading_text(doc.config);
160        let anchor = unique_anchor(slugify_heading(&config_name), &mut anchors);
161        let mut children = vec![];
162
163        if let Some(tag) = doc.config.metadata().tag {
164            let tag_ref = ParamRef {
165                config: doc.config,
166                param: tag.param,
167            };
168            let tag_path = tag_ref.canonical_path();
169            let anchor = unique_anchor(slugify_heading(&tag_path), &mut anchors);
170            children.push(list_item(vec![paragraph(vec![link(
171                format!("#{anchor}"),
172                vec![code(&tag_path)],
173            )])]));
174        }
175
176        for param_ref in filtered_params_without_tag(doc) {
177            let path = param_ref.canonical_path();
178            let anchor = unique_anchor(slugify_heading(&path), &mut anchors);
179            children.push(list_item(vec![paragraph(vec![link(
180                format!("#{anchor}"),
181                vec![code(&path)],
182            )])]));
183        }
184
185        let mut content = vec![paragraph(vec![link(
186            format!("#{anchor}"),
187            vec![text(&config_name)],
188        )])];
189        if !children.is_empty() {
190            content.push(Node::UnorderedList(children));
191        }
192        items.push(list_item(content));
193    }
194    nodes.push(Node::UnorderedList(items));
195}
196
197fn render_config_reference(
198    nodes: &mut Vec<Node>,
199    options: &MarkdownOptions,
200    doc: &MarkdownDoc<'_>,
201) -> io::Result<()> {
202    let config = doc.config;
203    nodes.push(heading(
204        options.heading_level + 1,
205        vec![code(&config_heading_text(config))],
206    ));
207
208    render_help(nodes, config.metadata().help);
209    if options.include_rust_types {
210        nodes.push(labeled_paragraph(
211            "Rust config",
212            vec![code(config.metadata().ty.name_in_code())],
213        ));
214    }
215    if options.include_aliases {
216        let aliases = format_aliases(config.aliases());
217        if !aliases.is_empty() {
218            nodes.push(labeled_paragraph("Aliases", aliases));
219        }
220    }
221    if !config.metadata().validations.is_empty() {
222        nodes.push(labeled_paragraph("Validations", vec![]));
223        nodes.push(Node::UnorderedList(
224            config
225                .metadata()
226                .validations
227                .iter()
228                .map(|validation| list_item(vec![paragraph(vec![text(&validation.to_string())])]))
229                .collect(),
230        ));
231    }
232
233    if let Some(tag) = config.metadata().tag {
234        render_tag_reference(nodes, options, config, &tag, &doc.conditions);
235    }
236    for param_ref in filtered_params_without_tag(doc) {
237        render_param_reference(nodes, options, param_ref, &doc.conditions)?;
238    }
239    Ok(())
240}
241
242fn render_tag_reference(
243    nodes: &mut Vec<Node>,
244    options: &MarkdownOptions,
245    config: ConfigRef<'_>,
246    tag: &ConfigTag,
247    conditions: &[(ParamRef<'_>, &ConfigVariant)],
248) {
249    let tag_ref = ParamRef {
250        config,
251        param: tag.param,
252    };
253    nodes.push(heading(
254        options.heading_level + 2,
255        vec![code(&tag_ref.canonical_path())],
256    ));
257
258    if options.include_aliases {
259        let aliases = format_aliases(tag_ref.all_paths().skip(1));
260        if !aliases.is_empty() {
261            nodes.push(labeled_paragraph("Aliases", aliases));
262        }
263    }
264    if let Some(env_options) = &options.include_env_vars {
265        nodes.push(labeled_paragraph(
266            "Environment variable",
267            vec![code(&env_var_name(
268                &env_options.prefix,
269                &tag_ref.canonical_path(),
270            ))],
271        ));
272    }
273
274    nodes.push(labeled_paragraph(
275        "Type",
276        vec![text("string tag with variants:")],
277    ));
278    nodes.push(Node::UnorderedList(
279        tag.variants
280            .iter()
281            .map(|variant| render_variant(config, tag, variant))
282            .collect(),
283    ));
284    render_conditions(nodes, conditions.iter().rev().copied());
285}
286
287fn render_variant(config: ConfigRef<'_>, tag: &ConfigTag, variant: &ConfigVariant) -> ListItem {
288    let mut content = vec![
289        code(variant.name),
290        text(" (Rust: "),
291        code(&format!(
292            "{}::{}",
293            config.metadata().ty.name_in_code(),
294            variant.rust_name
295        )),
296        text(")"),
297    ];
298    if tag
299        .default_variant
300        .is_some_and(|default| default.rust_name == variant.rust_name)
301    {
302        content.push(text(" "));
303        content.push(strong(vec![text("(default)")]));
304    }
305    if !variant.help.is_empty() {
306        content.push(text(" - "));
307        content.push(text(variant.help));
308    }
309
310    let mut blocks = vec![paragraph(content)];
311    if !variant.aliases.is_empty() {
312        let aliases = format_strs_as_code(variant.aliases.iter().copied());
313        blocks.push(Node::UnorderedList(vec![list_item(vec![
314            labeled_paragraph("Aliases", aliases),
315        ])]));
316    }
317    list_item(blocks)
318}
319
320fn render_param_reference(
321    nodes: &mut Vec<Node>,
322    options: &MarkdownOptions,
323    param_ref: ParamRef<'_>,
324    conditions: &[(ParamRef<'_>, &ConfigVariant)],
325) -> io::Result<()> {
326    nodes.push(heading(
327        options.heading_level + 2,
328        vec![code(&param_ref.canonical_path())],
329    ));
330
331    if options.include_aliases {
332        let aliases = format_aliases(param_ref.all_paths().skip(1));
333        if !aliases.is_empty() {
334            nodes.push(labeled_paragraph("Aliases", aliases));
335        }
336    }
337    if let Some(env_options) = &options.include_env_vars {
338        nodes.push(labeled_paragraph(
339            "Environment variable",
340            vec![code(&env_var_name(
341                &env_options.prefix,
342                &param_ref.canonical_path(),
343            ))],
344        ));
345    }
346
347    let description = param_ref.param.type_description();
348    let type_doc = render_type_doc(
349        "Type",
350        param_ref.param.expecting,
351        &description,
352        options,
353        true,
354    );
355    nodes.push(labeled_paragraph("Type", type_doc.summary));
356    if !type_doc.children.is_empty() {
357        nodes.push(Node::UnorderedList(
358            type_doc.children.iter().map(render_detail).collect(),
359        ));
360    }
361
362    let full_conditions = conditions.iter().rev().copied().chain(
363        param_ref
364            .param
365            .tag_variant
366            .map(|variant| (ParamRef::for_tag(param_ref.config), variant)),
367    );
368    render_conditions(nodes, full_conditions);
369
370    let default = param_ref.param.default_value_json();
371    if let Some(default) = &default {
372        render_json_value(nodes, "Default", default)?;
373    }
374    let example = param_ref
375        .param
376        .example_value_json()
377        .filter(|val| Some(val) != default.as_ref());
378    if options.include_examples
379        && let Some(example) = example
380    {
381        render_json_value(nodes, "Example", &example)?;
382    }
383    if let Some(fallback) = param_ref.param.fallback {
384        render_multiline_value(nodes, "Fallbacks", &fallback.to_string());
385    }
386    render_help(nodes, param_ref.param.help);
387    Ok(())
388}
389
390fn render_type_doc(
391    field: &str,
392    expecting: BasicTypes,
393    description: &TypeDescription,
394    options: &MarkdownOptions,
395    is_top_level: bool,
396) -> DetailDoc {
397    let mut summary = vec![];
398    if options.include_secret_marker && description.contains_secrets() {
399        summary.push(text("secret "));
400    }
401    summary.push(text(&format_basic_types(expecting)));
402    if options.include_rust_types && !description.rust_type().is_empty() {
403        summary.push(text(" (Rust: "));
404        summary.push(code(description.rust_type()));
405        summary.push(text(")"));
406    }
407    if let Some(details) = description.details() {
408        summary.push(text("; "));
409        summary.push(text(details));
410    }
411    if let Some(unit) = description.unit() {
412        summary.push(text("; unit: "));
413        summary.push(code(&unit.to_string()));
414    }
415
416    let mut doc = DetailDoc::new(field, summary);
417    if is_top_level && let Some(suffixes) = description.suffixes().and_then(format_suffixes) {
418        doc.children.push(DetailDoc::new("Name suffixes", suffixes));
419    }
420    if !description.validations().is_empty() {
421        doc.children.push(DetailDoc {
422            label: format!("{field} validations"),
423            summary: vec![],
424            children: description
425                .validations()
426                .iter()
427                .map(|validation| DetailDoc::new("", vec![text(validation)]))
428                .collect(),
429        });
430    }
431    if let Some(condition) = description.deserialize_if() {
432        doc.children.push(DetailDoc::new(
433            format!("{field} filtering"),
434            vec![text(condition), text(", otherwise set to "), code("null")],
435        ));
436    }
437
438    if let Some((expecting, item)) = description.items() {
439        doc.children.push(render_type_doc(
440            "Array items",
441            expecting,
442            item,
443            options,
444            false,
445        ));
446    }
447    if let Some(separator) = description.item_separator() {
448        doc.children.push(DetailDoc::new(
449            "Item separator",
450            format_separator(separator),
451        ));
452    }
453
454    if let Some((expecting, key)) = description.keys() {
455        doc.children
456            .push(render_type_doc("Map keys", expecting, key, options, false));
457    }
458    if let Some((expecting, value)) = description.values() {
459        doc.children.push(render_type_doc(
460            "Map values",
461            expecting,
462            value,
463            options,
464            false,
465        ));
466    }
467    if let Some((entry_sep, kv_sep)) = description.entry_separators() {
468        doc.children.push(DetailDoc::new(
469            "Entries separator",
470            format_separator(entry_sep),
471        ));
472        doc.children.push(DetailDoc::new(
473            "Key-value separator",
474            format_separator(kv_sep),
475        ));
476    }
477
478    if let Some((expecting, fallback)) = description.fallback() {
479        doc.children.push(render_type_doc(
480            "Fallback", expecting, fallback, options, false,
481        ));
482    }
483    doc
484}
485
486fn render_detail(detail: &DetailDoc) -> ListItem {
487    let mut content = if detail.label.is_empty() {
488        detail.summary.clone()
489    } else if detail.summary.is_empty() {
490        vec![strong(vec![text(&format!("{}:", detail.label))])]
491    } else {
492        let mut content = vec![strong(vec![text(&format!("{}:", detail.label))]), text(" ")];
493        content.extend(detail.summary.clone());
494        content
495    };
496
497    let mut blocks = vec![paragraph(std::mem::take(&mut content))];
498    if !detail.children.is_empty() {
499        blocks.push(Node::UnorderedList(
500            detail.children.iter().map(render_detail).collect(),
501        ));
502    }
503    list_item(blocks)
504}
505
506fn render_conditions<'a>(
507    nodes: &mut Vec<Node>,
508    conditions: impl Iterator<Item = (ParamRef<'a>, &'a ConfigVariant)>,
509) {
510    let conditions: Vec<_> = conditions
511        .map(|(tag_ref, variant)| format!("{} == '{}'", tag_ref.canonical_path(), variant.name))
512        .collect();
513    if !conditions.is_empty() {
514        let field = if conditions.len() == 1 { "Tag" } else { "Tags" };
515        nodes.push(labeled_paragraph(
516            field,
517            vec![code(&conditions.join(" && "))],
518        ));
519    }
520}
521
522fn render_json_value(
523    nodes: &mut Vec<Node>,
524    label: &str,
525    value: &serde_json::Value,
526) -> io::Result<()> {
527    let pretty = serde_json::to_string_pretty(value).map_err(io::Error::other)?;
528    if pretty.lines().count() <= 1 {
529        nodes.push(labeled_paragraph(label, vec![code(&pretty)]));
530    } else {
531        nodes.push(labeled_paragraph(label, vec![]));
532        nodes.push(Node::code_block(Some("json".into()), pretty.into()));
533    }
534    Ok(())
535}
536
537fn render_multiline_value(nodes: &mut Vec<Node>, label: &str, value: &str) {
538    let mut lines = value.lines();
539    let Some(first_line) = lines.next() else {
540        return;
541    };
542    nodes.push(labeled_paragraph(label, vec![text(first_line)]));
543    let extra_lines: Vec<_> = lines.collect();
544    if !extra_lines.is_empty() {
545        nodes.push(Node::UnorderedList(
546            extra_lines
547                .into_iter()
548                .map(|line| list_item(vec![paragraph(vec![text(line)])]))
549                .collect(),
550        ));
551    }
552}
553
554fn render_help(nodes: &mut Vec<Node>, help: &str) {
555    nodes.extend(
556        help.lines()
557            .filter(|line| !line.trim().is_empty())
558            .map(|line| paragraph(vec![text(line)])),
559    );
560}
561
562fn filtered_params_without_tag<'a>(doc: &'a MarkdownDoc<'a>) -> impl Iterator<Item = ParamRef<'a>> {
563    let tag_field = doc
564        .config
565        .metadata()
566        .tag
567        .map(|tag| tag.param.rust_field_name);
568    doc.params
569        .iter()
570        .copied()
571        .filter(move |param| Some(param.param.rust_field_name) != tag_field)
572}
573
574fn format_aliases<A: AsRef<str>>(
575    aliases: impl Iterator<Item = (A, smart_config::metadata::AliasOptions)>,
576) -> Vec<Node> {
577    let mut rendered = vec![];
578    for (i, (alias, options)) in aliases.enumerate() {
579        if i > 0 {
580            rendered.push(text(", "));
581        }
582        rendered.push(code(alias.as_ref()));
583        if options.is_deprecated {
584            rendered.push(text(" (deprecated)"));
585        }
586    }
587    rendered
588}
589
590fn format_strs_as_code<'a>(values: impl Iterator<Item = &'a str>) -> Vec<Node> {
591    let mut rendered = vec![];
592    for (i, value) in values.enumerate() {
593        if i > 0 {
594            rendered.push(text(", "));
595        }
596        rendered.push(code(value));
597    }
598    rendered
599}
600
601fn format_suffixes(suffixes: TypeSuffixes) -> Option<Vec<Node>> {
602    match suffixes {
603        TypeSuffixes::DurationUnits => Some(vec![
604            text("duration units from millis to weeks, for example "),
605            code("_ms"),
606            text(" or "),
607            code("_in_sec"),
608        ]),
609        TypeSuffixes::SizeUnits => Some(vec![
610            text("byte size units up to gigabytes, for example "),
611            code("_mb"),
612            text(" or "),
613            code("_in_kib"),
614        ]),
615        TypeSuffixes::EtherUnits => Some(vec![
616            text("ether value units, for example "),
617            code("_gwei"),
618            text(" or "),
619            code("_in_ether"),
620        ]),
621        _ => None,
622    }
623}
624
625fn format_separator(separator: &PatternDisplay) -> Vec<Node> {
626    match separator {
627        PatternDisplay::Exact(s) => vec![text("exact match: "), code(&format!("{s:?}"))],
628        PatternDisplay::Regex(regex) => vec![text("regex: "), code(regex)],
629        PatternDisplay::Generic(display) => vec![text(display)],
630        _ => vec![text(&separator.to_string())],
631    }
632}
633
634fn format_basic_types(expecting: BasicTypes) -> String {
635    expecting.to_string().replace(" | ", " or ").to_lowercase()
636}
637
638fn labeled_paragraph(label: &str, value: Vec<Node>) -> Node {
639    let mut content = vec![strong(vec![text(&format!("{label}:"))])];
640    if !value.is_empty() {
641        content.push(text(" "));
642        content.extend(value);
643    }
644    paragraph(content)
645}
646
647fn heading(level: u8, content: Vec<Node>) -> Node {
648    Node::heading(level.clamp(1, 6), content)
649}
650
651fn paragraph(content: Vec<Node>) -> Node {
652    Node::Paragraph(content)
653}
654
655fn list_item(content: Vec<Node>) -> ListItem {
656    ListItem::Unordered { content }
657}
658
659fn text(value: &str) -> Node {
660    Node::Text(value.into())
661}
662
663fn code(value: &str) -> Node {
664    Node::InlineCode(value.into())
665}
666
667fn strong(content: Vec<Node>) -> Node {
668    Node::Strong(content)
669}
670
671fn link(url: String, content: Vec<Node>) -> Node {
672    Node::Link {
673        url: url.into(),
674        title: None,
675        content,
676    }
677}
678
679fn config_heading_text(config: ConfigRef<'_>) -> String {
680    if config.prefix().is_empty() {
681        "root".to_owned()
682    } else {
683        config.prefix().to_owned()
684    }
685}
686
687fn env_var_name(prefix: &str, path: &str) -> String {
688    let mut var_name = path.replace('.', "_").to_uppercase();
689    var_name.insert_str(0, prefix);
690    var_name
691}
692
693fn slugify_heading(value: &str) -> String {
694    let mut slug = String::new();
695    let mut prev_dash = false;
696    for ch in value.chars().flat_map(char::to_lowercase) {
697        if ch.is_ascii_alphanumeric() {
698            slug.push(ch);
699            prev_dash = false;
700        } else if !prev_dash && !slug.is_empty() {
701            slug.push('-');
702            prev_dash = true;
703        }
704    }
705    if slug.ends_with('-') {
706        slug.pop();
707    }
708    slug
709}
710
711fn unique_anchor(slug: String, anchors: &mut BTreeMap<String, usize>) -> String {
712    let count = anchors.entry(slug.clone()).or_default();
713    let anchor = if *count == 0 {
714        slug
715    } else {
716        format!("{slug}-{count}")
717    };
718    *count += 1;
719    anchor
720}
721
722fn trim_trailing_spaces(markdown: &str) -> String {
723    let mut trimmed = markdown
724        .lines()
725        .map(str::trim_end)
726        .collect::<Vec<_>>()
727        .join("\n");
728    if markdown.ends_with('\n') {
729        trimmed.push('\n');
730    }
731    trimmed
732}