diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 1ea0dd3b..4e240bca 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -271,6 +271,16 @@ jobs: asset_name: completion.elv asset_content_type: text/plain + - name: Upload Man Page + uses: actions/upload-release-asset@v1.0.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create_release.outputs.upload_url }} + asset_path: ./exports/pdu.1 + asset_name: pdu.1 + asset_content_type: text/plain + upload_release_assets: name: Upload Release Assets diff --git a/Cargo.toml b/Cargo.toml index f408062c..db569865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,11 @@ name = "pdu-completions" path = "cli/completions.rs" required-features = ["cli-completions"] +[[bin]] +name = "pdu-man-page" +path = "cli/man_page.rs" +required-features = ["cli"] + [[bin]] name = "pdu-usage-md" path = "cli/usage_md.rs" diff --git a/ci/github-actions/generate-pkgbuild.py3 b/ci/github-actions/generate-pkgbuild.py3 index 42ffcc50..f258261f 100755 --- a/ci/github-actions/generate-pkgbuild.py3 +++ b/ci/github-actions/generate-pkgbuild.py3 @@ -50,7 +50,8 @@ with open('./pkgbuild/parallel-disk-usage-bin/PKGBUILD', 'w') as pkgbuild: f'completion.{release_tag}.{ext}::{source_url_prefix}/completion.{ext}' for ext in supported_completions ) - content += f'source=(pdu-{checksum}::{source_url} {completion_source} {readme_url} {license_url})\n' + man_page_source = f'pdu.{release_tag}.1::{source_url_prefix}/pdu.1' + content += f'source=(pdu-{checksum}::{source_url} {completion_source} {man_page_source} {readme_url} {license_url})\n' content += f'_checksum={checksum}\n' completion_checksums = ' '.join('SKIP' for _ in supported_completions) content += f'_completion_checksums=({completion_checksums})\n' diff --git a/cli/man_page.rs b/cli/man_page.rs new file mode 100644 index 00000000..a1419240 --- /dev/null +++ b/cli/man_page.rs @@ -0,0 +1,5 @@ +use parallel_disk_usage::man_page::render_man_page; + +fn main() { + print!("{}", render_man_page()); +} diff --git a/exports/pdu.1 b/exports/pdu.1 new file mode 100644 index 00000000..5d76c63f --- /dev/null +++ b/exports/pdu.1 @@ -0,0 +1,179 @@ +.TH pdu 1 "pdu 0.21.1" +.SH NAME +pdu \- Summarize disk usage of the set of files, recursively for directories. +.SH SYNOPSIS +\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR \fIBYTES_FORMAT\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR \fIQUANTITY\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR \fIMAX_DEPTH\fR] [\fB\-w\fR|\fB\-\-total\-width\fR \fITOTAL_WIDTH\fR] [\fB\-\-column\-width\fR \fITREE_WIDTH\fR \fIBAR_WIDTH\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR \fIMIN_RATIO\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR \fITHREADS\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIFILES\fR]... +.SH DESCRIPTION +Summarize disk usage of the set of files, recursively for directories. +.PP +Copyright: Apache\-2.0 © 2021 Hoàng Văn Khải +.br +Sponsor: https://github.com/sponsors/KSXGitHub +.SH OPTIONS +.TP +[\fIFILES\fR]... +List of files and/or directories +.TP +\fB\-\-json\-input\fR +Read JSON data from stdin +.RS +.PP +Cannot be used with \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-one\-file\-system\fR, \fB\-\-quantity\fR. +.RE +.TP +\fB\-\-json\-output\fR +Print JSON data instead of an ASCII chart +.TP +\fB\-b\fR, \fB\-\-bytes\-format\fR \fI\fR [default: metric] +How to display the numbers of bytes +.RS +.TP +\fB\-\-bytes\-format plain\fR +Display plain number of bytes without units +.TP +\fB\-\-bytes\-format metric\fR +Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on +.TP +\fB\-\-bytes\-format binary\fR +Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on +.RE +.TP +\fB\-H\fR, \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-detect\-links\fR, \fB\-\-dedupe\-links\fR +Detect and subtract the sizes of hardlinks from their parent directory totals +.RS +.PP +Cannot be used with \fB\-\-json\-input\fR. +.RE +.TP +\fB\-x\fR, \fB\-\-one\-file\-system\fR +Skip directories on different filesystems +.RS +.PP +Cannot be used with \fB\-\-json\-input\fR. +.RE +.TP +\fB\-\-top\-down\fR +Print the tree top\-down instead of bottom\-up +.TP +\fB\-\-align\-right\fR +Set the root of the bars to the right +.TP +\fB\-q\fR, \fB\-\-quantity\fR \fI\fR [default: block\-size] +Aspect of the files/directories to be measured +.RS +.TP +\fB\-\-quantity apparent\-size\fR +Measure apparent sizes +.TP +\fB\-\-quantity block\-size\fR +Measure block sizes (block\-count * 512B) +.TP +\fB\-\-quantity block\-count\fR +Count numbers of blocks +.RE +.RS +.PP +Cannot be used with \fB\-\-json\-input\fR. +.RE +.TP +\fB\-d\fR, \fB\-\-max\-depth\fR, \fB\-\-depth\fR \fI\fR [default: 10] +Maximum depth to display the data. Could be either "inf" or a positive integer +.TP +\fB\-w\fR, \fB\-\-total\-width\fR, \fB\-\-width\fR \fI\fR +Width of the visualization +.RS +.PP +Cannot be used with \fB\-\-column\-width\fR. +.RE +.TP +\fB\-\-column\-width\fR \fI\fR \fI\fR +Maximum widths of the tree column and width of the bar column +.RS +.PP +Cannot be used with \fB\-\-total\-width\fR. +.RE +.TP +\fB\-m\fR, \fB\-\-min\-ratio\fR \fI\fR [default: 0.01] +Minimal size proportion required to appear +.TP +\fB\-\-no\-sort\fR +Do not sort the branches in the tree +.TP +\fB\-s\fR, \fB\-\-silent\-errors\fR, \fB\-\-no\-errors\fR +Prevent filesystem error messages from appearing in stderr +.TP +\fB\-p\fR, \fB\-\-progress\fR +Report progress being made at the expense of performance +.TP +\fB\-\-threads\fR \fI\fR [default: auto] +Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer +.TP +\fB\-\-omit\-json\-shared\-details\fR +Do not output `.shared.details` in the JSON output +.TP +\fB\-\-omit\-json\-shared\-summary\fR +Do not output `.shared.summary` in the JSON output +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help (see a summary with '\-h') +.TP +\fB\-V\fR, \fB\-\-version\fR +Print version +.SH EXAMPLES +.TP +Show disk usage chart of current working directory +.nf +\fB$ pdu\fR +.fi +.TP +Show disk usage chart of a single file or directory +.nf +\fB$ pdu path/to/file/or/directory\fR +.fi +.TP +Compare disk usages of multiple files and/or directories +.nf +\fB$ pdu file.txt dir/\fR +.fi +.TP +Show chart in apparent sizes instead of block sizes +.nf +\fB$ pdu \-\-quantity=apparent\-size\fR +.fi +.TP +Detect and subtract the sizes of hardlinks from their parent nodes +.nf +\fB$ pdu \-\-deduplicate\-hardlinks\fR +.fi +.TP +Show sizes in plain numbers instead of metric units +.nf +\fB$ pdu \-\-bytes\-format=plain\fR +.fi +.TP +Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) +.nf +\fB$ pdu \-\-bytes\-format=binary\fR +.fi +.TP +Show disk usage chart of all entries regardless of size +.nf +\fB$ pdu \-\-min\-ratio=0\fR +.fi +.TP +Only show disk usage chart of entries whose size is at least 5% of total +.nf +\fB$ pdu \-\-min\-ratio=0.05\fR +.fi +.TP +Show disk usage data as JSON instead of chart +.nf +\fB$ pdu \-\-min\-ratio=0 \-\-max\-depth=inf \-\-json\-output | jq\fR +.fi +.TP +Visualize existing JSON representation of disk usage data +.nf +\fB$ pdu \-\-json\-input < disk\-usage.json\fR +.fi +.SH VERSION +v0.21.1 diff --git a/generate-completions.sh b/generate-completions.sh index e031cb1f..c3d8bf59 100755 --- a/generate-completions.sh +++ b/generate-completions.sh @@ -17,3 +17,4 @@ gen elvish completion.elv ./run.sh pdu --help | sed 's/[[:space:]]*$//' > exports/long.help ./run.sh pdu -h | sed 's/[[:space:]]*$//' > exports/short.help ./run.sh pdu-usage-md > USAGE.md +./run.sh pdu-man-page > exports/pdu.1 diff --git a/src/lib.rs b/src/lib.rs index 7aeb6e90..f5a4d044 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,8 @@ pub mod app; #[cfg(feature = "cli")] pub mod args; #[cfg(feature = "cli")] +pub mod man_page; +#[cfg(feature = "cli")] pub mod runtime_error; #[cfg(feature = "cli")] pub mod usage_md; diff --git a/src/man_page.rs b/src/man_page.rs new file mode 100644 index 00000000..40791a52 --- /dev/null +++ b/src/man_page.rs @@ -0,0 +1,354 @@ +use crate::args::Args; +use clap::{Arg, ArgAction, Command, CommandFactory}; +use itertools::Itertools; +use std::{collections::BTreeMap, fmt::Write}; + +/// A map from argument ID to the set of argument IDs it conflicts with (bidirectional). +type ConflictMap = BTreeMap>; + +/// Renders the man page for `pdu` as a string in roff format. +pub fn render_man_page() -> String { + let mut command = Args::command(); + command.build(); + let conflict_map = build_conflict_map(&command); + let mut out = String::new(); + render_title(&mut out, &command); + render_name_section(&mut out, &command); + render_synopsis_section(&mut out, &command); + render_description_section(&mut out, &command); + render_options_section(&mut out, &command, &conflict_map); + render_examples_section(&mut out, &command); + render_version_section(&mut out, &command); + out +} + +/// Builds a bidirectional conflict map from clap's one-directional conflict declarations. +/// +/// Hidden args are excluded so the man page doesn't reference options +/// that are not listed on the current platform. +fn build_conflict_map(command: &Command) -> ConflictMap { + let mut map = ConflictMap::new(); + for arg in command.get_arguments() { + if arg.is_hide_set() { + continue; + } + let arg_id = arg.get_id().to_string(); + for conflict in command.get_arg_conflicts_with(arg) { + if conflict.is_hide_set() { + continue; + } + let conflict_id = conflict.get_id().to_string(); + map.entry(arg_id.clone()) + .or_default() + .push(conflict_id.clone()); + map.entry(conflict_id).or_default().push(arg_id.clone()); + } + } + for conflicts in map.values_mut() { + conflicts.sort(); + conflicts.dedup(); + } + map +} + +/// Resolves an argument ID to its `--long` flag name for display. +fn resolve_flag_name(command: &Command, arg_id: &str) -> Option { + command + .get_arguments() + .find(|arg| arg.get_id().as_str() == arg_id) + .and_then(|arg| arg.get_long()) + .map(|long| format!("\\fB\\-\\-{}\\fR", roff_escape(long))) +} + +/// Escapes a string for roff by replacing hyphens with `\-`. +fn roff_escape(text: &str) -> String { + text.replace('-', r"\-") +} + +fn render_title(out: &mut String, command: &Command) { + let name = command.get_name(); + let version = command.get_version().unwrap_or_default(); + writeln!(out, ".TH {name} 1 \"{name} {version}\"").unwrap(); +} + +fn render_name_section(out: &mut String, command: &Command) { + let name = command.get_name(); + let about = command + .get_about() + .map(ToString::to_string) + .unwrap_or_default(); + writeln!(out, ".SH NAME").unwrap(); + writeln!(out, "{name} \\- {}", roff_escape(&about)).unwrap(); +} + +fn render_synopsis_section(out: &mut String, command: &Command) { + out.push_str(".SH SYNOPSIS\n"); + write!(out, "\\fB{}\\fR", command.get_name()).unwrap(); + let options = command + .get_arguments() + .filter(|arg| !arg.is_positional()) + .filter(|arg| !arg.is_hide_set()); + for arg in options { + out.push(' '); + render_synopsis_option(out, arg); + } + let positionals = command + .get_arguments() + .filter(|arg| arg.is_positional()) + .filter(|arg| !arg.is_hide_set()); + for arg in positionals { + out.push(' '); + render_synopsis_positional(out, arg); + } + out.push('\n'); +} + +fn render_synopsis_option(out: &mut String, arg: &Arg) { + out.push('['); + if let Some(short) = arg.get_short() { + write!(out, "\\fB\\-{}\\fR", roff_escape(&short.to_string())).unwrap(); + if arg.get_long().is_some() { + out.push('|'); + } + } + if let Some(long) = arg.get_long() { + write!(out, "\\fB\\-\\-{}\\fR", roff_escape(long)).unwrap(); + } + if arg.get_action().takes_values() { + if let Some(value_names) = arg.get_value_names() { + for name in value_names { + write!(out, " \\fI{}\\fR", roff_escape(name)).unwrap(); + } + } + } + out.push(']'); +} + +fn is_multiple(arg: &Arg) -> bool { + arg.get_num_args() + .map(|range| range.max_values() > 1) + .unwrap_or(false) +} + +fn render_synopsis_positional(out: &mut String, arg: &Arg) { + let name = arg + .get_value_names() + .and_then(|names| names.first()) + .map(|name| name.as_str()) + .unwrap_or_else(|| arg.get_id().as_str()); + let ellipsis = if is_multiple(arg) { "..." } else { "" }; + if arg.is_required_set() { + write!(out, "\\fI{}\\fR{ellipsis}", roff_escape(name)).unwrap(); + } else { + write!(out, "[\\fI{}\\fR]{ellipsis}", roff_escape(name)).unwrap(); + } +} + +fn render_description_section(out: &mut String, command: &Command) { + out.push_str(".SH DESCRIPTION\n"); + let text = command + .get_long_about() + .or_else(|| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(); + render_paragraph_text(out, &text); +} + +/// Renders multi-line text with proper roff paragraph breaks. +/// +/// Empty lines in the input produce `.PP` (new paragraph) in the output. +/// Consecutive non-empty lines are joined with `.br` (line break). +fn render_paragraph_text(out: &mut String, text: &str) { + let mut need_paragraph = false; + let mut first = true; + for line in text.lines() { + if line.is_empty() { + need_paragraph = true; + continue; + } + if need_paragraph && !first { + out.push_str(".PP\n"); + } else if !first { + out.push_str(".br\n"); + } + need_paragraph = false; + first = false; + writeln!(out, "{}", roff_escape(line)).unwrap(); + } +} + +fn render_options_section(out: &mut String, command: &Command, conflict_map: &ConflictMap) { + out.push_str(".SH OPTIONS\n"); + for arg in command.get_arguments() { + if arg.is_hide_set() { + continue; + } + render_option_entry(out, command, arg, conflict_map); + } +} + +fn render_option_entry(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) { + out.push_str(".TP\n"); + if arg.is_positional() { + render_option_header_positional(out, arg); + } else { + render_option_header_flag(out, arg); + } + let help = arg + .get_long_help() + .or_else(|| arg.get_help()) + .map(ToString::to_string) + .unwrap_or_default(); + writeln!(out, "{}", roff_escape(&help)).unwrap(); + render_possible_values(out, arg); + render_conflicts(out, command, arg, conflict_map); +} + +fn render_option_header_positional(out: &mut String, arg: &Arg) { + let name = arg + .get_value_names() + .and_then(|names| names.first()) + .map(|name| name.as_str()) + .unwrap_or_else(|| arg.get_id().as_str()); + let ellipsis = if is_multiple(arg) { "..." } else { "" }; + if arg.is_required_set() { + writeln!(out, "\\fI{name}\\fR{ellipsis}").unwrap(); + } else { + writeln!(out, "[\\fI{name}\\fR]{ellipsis}").unwrap(); + } +} + +fn render_option_header_flag(out: &mut String, arg: &Arg) { + let short = arg + .get_short() + .map(|short| roff_escape(&short.to_string())) + .map(|short| format!("\\fB\\-{short}\\fR")); + let long = arg + .get_long() + .map(roff_escape) + .map(|long| format!("\\fB\\-\\-{long}\\fR")); + let aliases = arg + .get_visible_aliases() + .into_iter() + .flatten() + .map(roff_escape) + .map(|alias| format!("\\fB\\-\\-{alias}\\fR")); + let header = short.into_iter().chain(long).chain(aliases).join(", "); + if arg.get_action().takes_values() { + let value_str = render_value_hint(arg); + writeln!(out, "{header} {value_str}").unwrap(); + } else { + writeln!(out, "{header}").unwrap(); + } +} + +fn render_value_hint(arg: &Arg) -> String { + let value_part = arg + .get_value_names() + .map(<[_]>::iter) + .map(|names| names.map(|name| name.as_str())) + .map(Vec::from_iter) + .unwrap_or_else(|| vec![arg.get_id().as_str()]) + .into_iter() + .map(roff_escape) + .map(|name| format!("\\fI<{name}>\\fR")) + .join(" "); + let defaults = arg + .get_default_values() + .iter() + .map(|value| value.to_string_lossy()) + .map(|value| roff_escape(&value)) + .join(", "); + let hide_defaults = defaults.is_empty() + || arg.is_hide_default_value_set() + || matches!(arg.get_action(), ArgAction::SetTrue); + if hide_defaults { + value_part + } else { + format!("{value_part} [default: {defaults}]") + } +} + +fn render_possible_values(out: &mut String, arg: &Arg) { + if arg.is_hide_possible_values_set() { + return; + } + if matches!( + arg.get_action(), + ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count + ) { + return; + } + let possible_values: Vec<_> = arg + .get_possible_values() + .into_iter() + .filter(|value| !value.is_hide_set()) + .collect(); + if possible_values.is_empty() { + return; + } + let flag = arg + .get_long() + .map(roff_escape) + .map(|long| format!("\\-\\-{long}")) + .unwrap_or_default(); + out.push_str(".RS\n"); + for value in &possible_values { + let name = roff_escape(value.get_name()); + let help = value + .get_help() + .map(|help| format!("\n{}", roff_escape(&help.to_string()))) + .unwrap_or_default(); + writeln!(out, ".TP\n\\fB{flag} {name}\\fR{help}").unwrap(); + } + out.push_str(".RE\n"); +} + +fn render_conflicts(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) { + let arg_id = arg.get_id().as_str(); + let conflicts = conflict_map + .get(arg_id) + .into_iter() + .flatten() + .filter_map(|id| resolve_flag_name(command, id)) + .join(", "); + if !conflicts.is_empty() { + writeln!(out, ".RS\n.PP\nCannot be used with {conflicts}.\n.RE").unwrap(); + } +} + +fn render_examples_section(out: &mut String, command: &Command) { + let text = match command.get_after_long_help() { + Some(text) => text.to_string(), + None => return, + }; + let mut lines = text.lines(); + let mut has_examples = false; + for line in lines.by_ref() { + if line.trim() == "Examples:" { + has_examples = true; + break; + } + } + if !has_examples { + return; + } + out.push_str(".SH EXAMPLES\n"); + for line in lines { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(example_command) = line.strip_prefix("$ ") { + writeln!(out, ".nf\n\\fB$ {}\\fR\n.fi", roff_escape(example_command)).unwrap(); + } else { + writeln!(out, ".TP\n{}", roff_escape(line)).unwrap(); + } + } +} + +fn render_version_section(out: &mut String, command: &Command) { + if let Some(version) = command.get_version() { + writeln!(out, ".SH VERSION\nv{version}").unwrap(); + } +} diff --git a/template/parallel-disk-usage-bin/PKGBUILD b/template/parallel-disk-usage-bin/PKGBUILD index 83691380..d28a116f 100644 --- a/template/parallel-disk-usage-bin/PKGBUILD +++ b/template/parallel-disk-usage-bin/PKGBUILD @@ -10,6 +10,7 @@ conflicts=(parallel-disk-usage) sha1sums=( "$_checksum" # for the pdu binary "${_completion_checksums[@]}" # for the completion files + SKIP # for the man page SKIP # for the readme file SKIP # for the license file ) @@ -21,4 +22,5 @@ package() { install -Dm644 "completion.$pkgver.bash" "$pkgdir/usr/share/bash-completion/completions/pdu" install -Dm644 "completion.$pkgver.fish" "$pkgdir/usr/share/fish/completions/pdu.fish" install -Dm644 "completion.$pkgver.zsh" "$pkgdir/usr/share/zsh/site-functions/_pdu" + install -Dm644 "pdu.$pkgver.1" "$pkgdir/usr/share/man/man1/pdu.1" } diff --git a/template/parallel-disk-usage/PKGBUILD b/template/parallel-disk-usage/PKGBUILD index 88387f8f..7e3c59aa 100644 --- a/template/parallel-disk-usage/PKGBUILD +++ b/template/parallel-disk-usage/PKGBUILD @@ -20,4 +20,5 @@ package() { install -Dm644 exports/completion.bash "$pkgdir/usr/share/bash-completion/completions/pdu" install -Dm644 exports/completion.fish "$pkgdir/usr/share/fish/completions/pdu.fish" install -Dm644 exports/completion.zsh "$pkgdir/usr/share/zsh/site-functions/_pdu" + install -Dm644 exports/pdu.1 "$pkgdir/usr/share/man/man1/pdu.1" } diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs new file mode 100644 index 00000000..f4a6db43 --- /dev/null +++ b/tests/sync_man_page.rs @@ -0,0 +1,20 @@ +//! The following test checks whether the man page file is outdated. +//! +//! If the test fails, run `./generate-completions.sh` on the root of the repo to update the man page. + +// Since the CLI in Windows looks a little different, and I am way too lazy to make two versions +// of man page files, the following test would only run in UNIX-like environment. +#![cfg(feature = "cli")] + +use parallel_disk_usage::man_page::render_man_page; + +#[test] +#[cfg_attr(not(unix), ignore = "man page test only runs on Unix-like platforms")] +fn man_page() { + let received = render_man_page(); + let expected = include_str!("../exports/pdu.1"); + assert!( + received == expected, + "man page is outdated, run ./generate-completions.sh to update it", + ); +}