Skip to main content

parallel_disk_usage/
man_page.rs

1use crate::args::Args;
2use clap::{Arg, ArgAction, Command, CommandFactory};
3use itertools::Itertools;
4use std::{collections::BTreeMap, fmt::Write};
5
6/// A map from argument ID to the set of argument IDs it conflicts with (bidirectional).
7type ConflictMap = BTreeMap<String, Vec<String>>;
8
9/// Renders the man page for `pdu` as a string in roff format.
10pub fn render_man_page() -> String {
11    let mut command = Args::command();
12    command.build();
13    let conflict_map = build_conflict_map(&command);
14    let mut out = String::new();
15    render_title(&mut out, &command);
16    render_name_section(&mut out, &command);
17    render_synopsis_section(&mut out, &command);
18    render_description_section(&mut out, &command);
19    render_options_section(&mut out, &command, &conflict_map);
20    render_examples_section(&mut out, &command);
21    render_version_section(&mut out, &command);
22    out
23}
24
25/// Builds a bidirectional conflict map from clap's one-directional conflict declarations.
26///
27/// Hidden args are excluded so the man page doesn't reference options
28/// that are not listed on the current platform.
29fn build_conflict_map(command: &Command) -> ConflictMap {
30    let mut map = ConflictMap::new();
31    for arg in command.get_arguments() {
32        if arg.is_hide_set() {
33            continue;
34        }
35        let arg_id = arg.get_id().to_string();
36        for conflict in command.get_arg_conflicts_with(arg) {
37            if conflict.is_hide_set() {
38                continue;
39            }
40            let conflict_id = conflict.get_id().to_string();
41            map.entry(arg_id.clone())
42                .or_default()
43                .push(conflict_id.clone());
44            map.entry(conflict_id).or_default().push(arg_id.clone());
45        }
46    }
47    for conflicts in map.values_mut() {
48        conflicts.sort();
49        conflicts.dedup();
50    }
51    map
52}
53
54/// Resolves an argument ID to its `--long` flag name for display.
55fn resolve_flag_name(command: &Command, arg_id: &str) -> Option<String> {
56    command
57        .get_arguments()
58        .find(|arg| arg.get_id().as_str() == arg_id)
59        .and_then(|arg| arg.get_long())
60        .map(|long| format!("\\fB\\-\\-{}\\fR", roff_escape(long)))
61}
62
63/// Escapes a string for roff by replacing hyphens with `\-`.
64fn roff_escape(text: &str) -> String {
65    text.replace('-', r"\-")
66}
67
68fn render_title(out: &mut String, command: &Command) {
69    let name = command.get_name();
70    let version = command.get_version().unwrap_or_default();
71    writeln!(out, ".TH {name} 1 \"{name} {version}\"").unwrap();
72}
73
74fn render_name_section(out: &mut String, command: &Command) {
75    let name = command.get_name();
76    let about = command
77        .get_about()
78        .map(ToString::to_string)
79        .unwrap_or_default();
80    writeln!(out, ".SH NAME").unwrap();
81    writeln!(out, "{name} \\- {}", roff_escape(&about)).unwrap();
82}
83
84fn render_synopsis_section(out: &mut String, command: &Command) {
85    out.push_str(".SH SYNOPSIS\n");
86    write!(out, "\\fB{}\\fR", command.get_name()).unwrap();
87    let options = command
88        .get_arguments()
89        .filter(|arg| !arg.is_positional())
90        .filter(|arg| !arg.is_hide_set());
91    for arg in options {
92        out.push(' ');
93        render_synopsis_option(out, arg);
94    }
95    let positionals = command
96        .get_arguments()
97        .filter(|arg| arg.is_positional())
98        .filter(|arg| !arg.is_hide_set());
99    for arg in positionals {
100        out.push(' ');
101        render_synopsis_positional(out, arg);
102    }
103    out.push('\n');
104}
105
106fn render_synopsis_option(out: &mut String, arg: &Arg) {
107    out.push('[');
108    if let Some(short) = arg.get_short() {
109        write!(out, "\\fB\\-{}\\fR", roff_escape(&short.to_string())).unwrap();
110        if arg.get_long().is_some() {
111            out.push('|');
112        }
113    }
114    if let Some(long) = arg.get_long() {
115        write!(out, "\\fB\\-\\-{}\\fR", roff_escape(long)).unwrap();
116    }
117    if arg.get_action().takes_values()
118        && let Some(value_names) = arg.get_value_names()
119    {
120        for name in value_names {
121            write!(out, " \\fI{}\\fR", roff_escape(name)).unwrap();
122        }
123    }
124    out.push(']');
125}
126
127fn is_multiple(arg: &Arg) -> bool {
128    arg.get_num_args()
129        .map(|range| range.max_values() > 1)
130        .unwrap_or(false)
131}
132
133fn render_synopsis_positional(out: &mut String, arg: &Arg) {
134    let name = arg
135        .get_value_names()
136        .and_then(|names| names.first())
137        .map(|name| name.as_str())
138        .unwrap_or_else(|| arg.get_id().as_str());
139    let ellipsis = if is_multiple(arg) { "..." } else { "" };
140    if arg.is_required_set() {
141        write!(out, "\\fI{}\\fR{ellipsis}", roff_escape(name)).unwrap();
142    } else {
143        write!(out, "[\\fI{}\\fR]{ellipsis}", roff_escape(name)).unwrap();
144    }
145}
146
147fn render_description_section(out: &mut String, command: &Command) {
148    out.push_str(".SH DESCRIPTION\n");
149    let text = command
150        .get_long_about()
151        .or_else(|| command.get_about())
152        .map(ToString::to_string)
153        .unwrap_or_default();
154    render_paragraph_text(out, &text);
155}
156
157/// Renders multi-line text with proper roff paragraph breaks.
158///
159/// Empty lines in the input produce `.PP` (new paragraph) in the output.
160/// Consecutive non-empty lines are joined with `.br` (line break).
161fn render_paragraph_text(out: &mut String, text: &str) {
162    let mut need_paragraph = false;
163    let mut first = true;
164    for line in text.lines() {
165        if line.is_empty() {
166            need_paragraph = true;
167            continue;
168        }
169        if need_paragraph && !first {
170            out.push_str(".PP\n");
171        } else if !first {
172            out.push_str(".br\n");
173        }
174        need_paragraph = false;
175        first = false;
176        writeln!(out, "{}", roff_escape(line)).unwrap();
177    }
178}
179
180fn render_options_section(out: &mut String, command: &Command, conflict_map: &ConflictMap) {
181    out.push_str(".SH OPTIONS\n");
182    for arg in command.get_arguments() {
183        if arg.is_hide_set() {
184            continue;
185        }
186        render_option_entry(out, command, arg, conflict_map);
187    }
188}
189
190fn render_option_entry(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) {
191    out.push_str(".TP\n");
192    if arg.is_positional() {
193        render_option_header_positional(out, arg);
194    } else {
195        render_option_header_flag(out, arg);
196    }
197    let help = arg
198        .get_long_help()
199        .or_else(|| arg.get_help())
200        .map(ToString::to_string)
201        .unwrap_or_default();
202    writeln!(out, "{}", roff_escape(&help)).unwrap();
203    render_possible_values(out, arg);
204    render_conflicts(out, command, arg, conflict_map);
205}
206
207fn render_option_header_positional(out: &mut String, arg: &Arg) {
208    let name = arg
209        .get_value_names()
210        .and_then(|names| names.first())
211        .map(|name| name.as_str())
212        .unwrap_or_else(|| arg.get_id().as_str());
213    let ellipsis = if is_multiple(arg) { "..." } else { "" };
214    if arg.is_required_set() {
215        writeln!(out, "\\fI{name}\\fR{ellipsis}").unwrap();
216    } else {
217        writeln!(out, "[\\fI{name}\\fR]{ellipsis}").unwrap();
218    }
219}
220
221fn render_option_header_flag(out: &mut String, arg: &Arg) {
222    let short = arg
223        .get_short()
224        .map(|short| roff_escape(&short.to_string()))
225        .map(|short| format!("\\fB\\-{short}\\fR"));
226    let long = arg
227        .get_long()
228        .map(roff_escape)
229        .map(|long| format!("\\fB\\-\\-{long}\\fR"));
230    let aliases = arg
231        .get_visible_aliases()
232        .into_iter()
233        .flatten()
234        .map(roff_escape)
235        .map(|alias| format!("\\fB\\-\\-{alias}\\fR"));
236    let header = short.into_iter().chain(long).chain(aliases).join(", ");
237    if arg.get_action().takes_values() {
238        let value_str = render_value_hint(arg);
239        writeln!(out, "{header} {value_str}").unwrap();
240    } else {
241        writeln!(out, "{header}").unwrap();
242    }
243}
244
245fn render_value_hint(arg: &Arg) -> String {
246    let value_part = arg
247        .get_value_names()
248        .map(<[_]>::iter)
249        .map(|names| names.map(|name| name.as_str()))
250        .map(Vec::from_iter)
251        .unwrap_or_else(|| vec![arg.get_id().as_str()])
252        .into_iter()
253        .map(roff_escape)
254        .map(|name| format!("\\fI<{name}>\\fR"))
255        .join(" ");
256    let defaults = arg
257        .get_default_values()
258        .iter()
259        .map(|value| value.to_string_lossy())
260        .map(|value| roff_escape(&value))
261        .join(", ");
262    let hide_defaults = defaults.is_empty()
263        || arg.is_hide_default_value_set()
264        || matches!(arg.get_action(), ArgAction::SetTrue);
265    if hide_defaults {
266        value_part
267    } else {
268        format!("{value_part} [default: {defaults}]")
269    }
270}
271
272fn render_possible_values(out: &mut String, arg: &Arg) {
273    if arg.is_hide_possible_values_set() {
274        return;
275    }
276    if matches!(
277        arg.get_action(),
278        ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count
279    ) {
280        return;
281    }
282    let possible_values: Vec<_> = arg
283        .get_possible_values()
284        .into_iter()
285        .filter(|value| !value.is_hide_set())
286        .collect();
287    if possible_values.is_empty() {
288        return;
289    }
290    let flag = arg
291        .get_long()
292        .map(roff_escape)
293        .map(|long| format!("\\-\\-{long}"))
294        .unwrap_or_default();
295    out.push_str(".RS\n");
296    for value in &possible_values {
297        let name = roff_escape(value.get_name());
298        let help = value
299            .get_help()
300            .map(|help| format!("\n{}", roff_escape(&help.to_string())))
301            .unwrap_or_default();
302        writeln!(out, ".TP\n\\fB{flag} {name}\\fR{help}").unwrap();
303    }
304    out.push_str(".RE\n");
305}
306
307fn render_conflicts(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) {
308    let arg_id = arg.get_id().as_str();
309    let conflicts = conflict_map
310        .get(arg_id)
311        .into_iter()
312        .flatten()
313        .filter_map(|id| resolve_flag_name(command, id))
314        .join(", ");
315    if !conflicts.is_empty() {
316        writeln!(out, ".RS\n.PP\nCannot be used with {conflicts}.\n.RE").unwrap();
317    }
318}
319
320fn render_examples_section(out: &mut String, command: &Command) {
321    let text = match command.get_after_long_help() {
322        Some(text) => text.to_string(),
323        None => return,
324    };
325    let mut lines = text.lines();
326    let mut has_examples = false;
327    for line in lines.by_ref() {
328        if line.trim() == "Examples:" {
329            has_examples = true;
330            break;
331        }
332    }
333    if !has_examples {
334        return;
335    }
336    out.push_str(".SH EXAMPLES\n");
337    for line in lines {
338        let line = line.trim();
339        if line.is_empty() {
340            continue;
341        }
342        if let Some(example_command) = line.strip_prefix("$ ") {
343            writeln!(out, ".nf\n\\fB$ {}\\fR\n.fi", roff_escape(example_command)).unwrap();
344        } else {
345            writeln!(out, ".TP\n{}", roff_escape(line)).unwrap();
346        }
347    }
348}
349
350fn render_version_section(out: &mut String, command: &Command) {
351    if let Some(version) = command.get_version() {
352        writeln!(out, ".SH VERSION\nv{version}").unwrap();
353    }
354}