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#[derive(Debug, Clone)]
15#[allow(clippy::struct_excessive_bools)]
16#[non_exhaustive]
17pub struct MarkdownOptions {
18 pub title: Option<String>,
20 pub heading_level: u8,
22 pub include_table_of_contents: bool,
24 pub include_rust_types: bool,
26 pub include_aliases: bool,
28 pub include_env_vars: Option<EnvVarOptions>,
30 pub include_examples: bool,
32 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#[derive(Debug, Clone, Default)]
53#[non_exhaustive]
54pub struct EnvVarOptions {
55 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 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(|¶m_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(¶m_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 ¶m_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}