diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 4660b077cd6..fa297be27d0 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -705,19 +705,10 @@ jobs: *) tar czf '${{ steps.vars.outputs.PKG_NAME }}' '${{ steps.vars.outputs.PKG_BASENAME }}'/* ;; esac popd >/dev/null - - name: Package manpages and completions - if: matrix.job.target == 'x86_64-unknown-linux-gnu' && matrix.job.features == 'feat_os_unix,uudoc' + - name: Package manpages, Markdown, and completions + if: matrix.job.target == 'x86_64-unknown-linux-gnu' && contains(matrix.job.features, 'uudoc') run: | - mkdir -p share/{man/man1,bash-completion/completions,fish/vendor_completions.d,zsh/site-functions,elvish/lib} - _uudoc=target/${{ matrix.job.target }}/release/uudoc - for bin in $('target/${{ matrix.job.target }}/release/coreutils' --list) coreutils;do - ${_uudoc} manpage ${bin} > share/man/man1/${bin}.1 - ${_uudoc} completion ${bin} bash > share/bash-completion/completions/${bin}.bash - ${_uudoc} completion ${bin} fish > share/fish/vendor_completions.d/${bin}.fish - ${_uudoc} completion ${bin} zsh > share/zsh/site-functions/_${bin} - ${_uudoc} completion ${bin} elvish > share/elvish/lib/${bin}.elv - done - rm share/zsh/site-functions/_[ # not supported + target/${{ matrix.job.target }}/release/uudoc manpages md completions --usr . tar --zstd -cf docs.tar.zst share - name: Publish uses: softprops/action-gh-release@v3 diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml index 6c424e4c65f..bf591d24db7 100644 --- a/.github/workflows/make.yml +++ b/.github/workflows/make.yml @@ -1,6 +1,6 @@ name: make # spell-checker:ignore (abbrev/names) CACHEDIR taiki -# spell-checker:ignore (env/flags) CHERE RUSTDOCFLAGS RUSTFLAGS CARGOFLAGS CLEVEL relocs EAGAIN +# spell-checker:ignore (env/flags) CHERE RUSTDOCFLAGS RUSTFLAGS CARGOFLAGS CLEVEL DATAROOTDIR relocs EAGAIN # spell-checker:ignore (jargon) deps softprops toolchain # spell-checker:ignore (people) dawidd # spell-checker:ignore (shell/tools) bsdtar nextest pacman sccache zstd @@ -71,7 +71,12 @@ jobs: mv -T target target.cache fi # Check that we don't cross-build uudoc - env CARGO_BUILD_TARGET=invalid-unknown-linux-gnu make install-manpages PREFIX=/tmp/usr UTILS=true + env CARGO_BUILD_TARGET=invalid-unknown-linux-gnu make install-docs PREFIX=/tmp/usr DATAROOTDIR=/tmp/uudoc-share UTILS=true + test -f /tmp/uudoc-share/man/man1/coreutils.1 + test -f /tmp/uudoc-share/bash-completion/completions/true.bash + make uninstall PREFIX=/tmp/usr DATAROOTDIR=/tmp/uudoc-share UTILS=true + ! test -e /tmp/uudoc-share/man/man1/coreutils.1 + ! test -e /tmp/uudoc-share/bash-completion/completions/true.bash # We don't build coreutils without MULTICALL=y ! test -e target/debug/coreutils # build (host) @@ -317,12 +322,12 @@ jobs: mingw-w64-ucrt-x86_64-openssl mingw-w64-ucrt-x86_64-rust env: CHERE_INVOKING: 1 - - name: "`(x86_64-pc-cygwin) make install PROG_PREFIX=uu- PROFILE=release-small COMPLETIONS=n MANPAGES=n LOCALES=n`" + - name: "`(x86_64-pc-cygwin) make install PROG_PREFIX=uu- PROFILE=release-small UUDOC=n LOCALES=n`" shell: 'C:\msys64\usr\bin\bash.exe --login -eo pipefail {0}' run: | set -x export CARGOFLAGS="--features openssl" - DESTDIR=/tmp/c make install PROG_PREFIX=uu- PROFILE=release-small COMPLETIONS=n MANPAGES=n LOCALES=n + DESTDIR=/tmp/c make install PROG_PREFIX=uu- PROFILE=release-small UUDOC=n LOCALES=n # Check that cksum is linked with openssl (e.g. msys-crypto-*.dll) # Also check that utils are built with given profile ldd target/release-small/cksum | grep crypto @@ -366,7 +371,7 @@ jobs: export CARGOFLAGS="--features openssl" # Check that we exclude unix programs to avoid build failure # Windows's stdbuf depends on Cygwin's libstdbuf. So make should not try to install libstdbuf - make install DESTDIR=/tmp/w PREFIX=/tmp/usr MULTICALL=y COMPLETIONS=n MANPAGES=n LOCALES=n \ + make install DESTDIR=/tmp/w PREFIX=/tmp/usr MULTICALL=y UUDOC=n LOCALES=n \ SKIP_UTILS="arch b2sum base32 base64 basename basenc cat comm cp csplit cut date dd df dir dircolors dirname du echo env expand expr factor false fmt fold head hostname hostid join link ln ls md5sum mkdir mktemp more mv nl nproc numfmt od paste pathchk pr printenv printf ptx pwd readlink realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum sha512sum shred shuf sleep sort split sum sync tac tail tee test touch tr true truncate tsort tty uname unexpand uniq unlink vdir wc whoami yes" target/release/coreutils.exe stdbuf --version diff --git a/.github/workflows/manpage-lint.yml b/.github/workflows/manpage-lint.yml index 58ad8eb9b04..99854515522 100644 --- a/.github/workflows/manpage-lint.yml +++ b/.github/workflows/manpage-lint.yml @@ -56,7 +56,7 @@ jobs: export LANG=${{ matrix.locale }} # Build and install manpages to temporary directory - make install-manpages DESTDIR="${MANPAGE_DIR}" + make install-docs DESTDIR="${MANPAGE_DIR}" - name: Validate manpages with mandoc (${{ matrix.locale }}) run: | diff --git a/GNUmakefile b/GNUmakefile index 6da9a80b20e..918dcc3d81d 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -6,8 +6,7 @@ ifneq (,$(filter install, $(MAKECMDGOALS))) endif PROFILE ?= debug MULTICALL ?= n -COMPLETIONS ?= y -MANPAGES ?= y +UUDOC ?= y LOCALES ?= y # allow bootstrapping by our install. mkdir, chmod and cp should be avoided INSTALL ?= install @@ -127,6 +126,10 @@ ifeq (${MULTICALL}, y) INSTALLEES := ${INSTALLEES} coreutils endif +# uudoc also documents the multicall command and the test alias. +DOC_UTILS := $(sort $(EXES) coreutils $(if $(filter test,$(EXES)),[)) +BUILDDIR_DOCS := $(BUILDDIR_UUDOC)/docs + all: build build-pkgs: @@ -199,33 +202,22 @@ clean: distclean: clean $(CARGO) clean $(CARGOFLAGS) && $(CARGO) update $(CARGOFLAGS) -ifeq ($(MANPAGES),y) # Do not cross-build uudoc build-uudoc: @unset CARGO_BUILD_TARGET && ${CARGO} build ${CARGOFLAGS} --bin uudoc --features "uudoc ${EXES}" ${PROFILE_CMD} --no-default-features -install-manpages: build-uudoc - $(INSTALL) -d $(DESTDIR)$(DATAROOTDIR)/man/man1 - $(foreach prog, $(INSTALLEES), \ - $(BUILDDIR_UUDOC)/uudoc manpage $(prog) > $(DESTDIR)$(DATAROOTDIR)/man/man1/$(PROG_PREFIX)$(prog).1 $(newline) \ - ) -else -install-manpages: -endif - -ifeq ($(COMPLETIONS),y) - -install-completions: build-uudoc - $(INSTALL) -d $(DESTDIR)$(DATAROOTDIR)/zsh/site-functions - $(INSTALL) -d $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions - $(INSTALL) -d $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d - $(foreach prog, $(INSTALLEES), \ - $(BUILDDIR_UUDOC)/uudoc completion $(prog) zsh > $(DESTDIR)$(DATAROOTDIR)/zsh/site-functions/_$(PROG_PREFIX)$(prog) $(newline) \ - $(BUILDDIR_UUDOC)/uudoc completion $(prog) bash > $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions/$(PROG_PREFIX)$(prog).bash $(newline) \ - $(BUILDDIR_UUDOC)/uudoc completion $(prog) fish > $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d/$(PROG_PREFIX)$(prog).fish $(newline) \ - ) +ifeq ($(UUDOC),y) +install-docs: build-uudoc + PROG_PREFIX="$(PROG_PREFIX)" $(BUILDDIR_UUDOC)/uudoc manpages completions --usr "$(BUILDDIR_DOCS)" + $(INSTALL) -d $(addprefix $(DESTDIR)$(DATAROOTDIR)/,man/man1 bash-completion/completions elvish/lib fish/vendor_completions.d powershell zsh/site-functions) + $(INSTALL) -m 644 $(addprefix $(BUILDDIR_DOCS)/share/man/man1/$(PROG_PREFIX),$(addsuffix .1,$(DOC_UTILS))) $(DESTDIR)$(DATAROOTDIR)/man/man1 + $(INSTALL) -m 644 $(addprefix $(BUILDDIR_DOCS)/share/bash-completion/completions/$(PROG_PREFIX),$(addsuffix .bash,$(DOC_UTILS))) $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions + $(INSTALL) -m 644 $(addprefix $(BUILDDIR_DOCS)/share/elvish/lib/$(PROG_PREFIX),$(addsuffix .elv,$(DOC_UTILS))) $(DESTDIR)$(DATAROOTDIR)/elvish/lib + $(INSTALL) -m 644 $(addprefix $(BUILDDIR_DOCS)/share/fish/vendor_completions.d/$(PROG_PREFIX),$(addsuffix .fish,$(DOC_UTILS))) $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d + $(INSTALL) -m 644 $(addprefix $(BUILDDIR_DOCS)/share/powershell/_$(PROG_PREFIX),$(addsuffix .ps1,$(DOC_UTILS))) $(DESTDIR)$(DATAROOTDIR)/powershell + $(INSTALL) -m 644 $(addprefix $(BUILDDIR_DOCS)/share/zsh/site-functions/_$(PROG_PREFIX),$(filter-out [,$(DOC_UTILS))) $(DESTDIR)$(DATAROOTDIR)/zsh/site-functions else -install-completions: +install-docs: endif ifeq ($(LOCALES),y) @@ -294,7 +286,7 @@ locales: install-locales: endif -install: build install-manpages install-completions install-locales +install: build install-docs install-locales $(INSTALL) -d $(INSTALLDIR_BIN) ifneq (,$(and $(findstring stdbuf,$(UTILS)),$(findstring feat_external_libstdbuf,$(CARGOFLAGS)))) ifneq (,$(findstring cygwin,$(OS))) @@ -330,9 +322,11 @@ ifeq (${MULTICALL}, y) endif rm -f $(addprefix $(INSTALLDIR_BIN)/$(PROG_PREFIX),$(PROGS)) rm -f $(INSTALLDIR_BIN)/$(PROG_PREFIX)[ - rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/zsh/site-functions/_$(PROG_PREFIX),$(PROGS)) - rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions/$(PROG_PREFIX),$(PROGS).bash) - rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d/$(PROG_PREFIX),$(addsuffix .fish,$(PROGS))) - rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/man/man1/$(PROG_PREFIX),$(addsuffix .1,$(PROGS))) - -.PHONY: all build build-coreutils build-pkgs build-uudoc test distclean clean busytest install uninstall + rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/zsh/site-functions/_$(PROG_PREFIX),$(filter-out [,$(DOC_UTILS))) + rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions/$(PROG_PREFIX),$(addsuffix .bash,$(DOC_UTILS))) + rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d/$(PROG_PREFIX),$(addsuffix .fish,$(DOC_UTILS))) + rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/elvish/lib/$(PROG_PREFIX),$(addsuffix .elv,$(DOC_UTILS))) + rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/powershell/_$(PROG_PREFIX),$(addsuffix .ps1,$(DOC_UTILS))) + rm -f $(addprefix $(DESTDIR)$(DATAROOTDIR)/man/man1/$(PROG_PREFIX),$(addsuffix .1,$(DOC_UTILS))) + +.PHONY: all build build-coreutils build-pkgs build-uudoc test distclean clean busytest install install-docs uninstall diff --git a/README.md b/README.md index fea644f011c..a53cbad1f1b 100644 --- a/README.md +++ b/README.md @@ -262,55 +262,47 @@ make PREFIX=/my/path install ``` Installing with `make` installs shell completions for all installed utilities -for `bash`, `fish` and `zsh`. Completions for `elvish` and `powershell` can also -be generated; See `Manually install shell completions`. +for `bash`, `elvish`, `fish`, `powershell` and `zsh`. To skip installation of completions and manpages: ```shell -make COMPLETIONS=n MANPAGES=n install +make UUDOC=n install ``` -### Manually install shell completions +### Generate documentation -The `uudoc` binary generates completions for the `bash`, `elvish`, -`fish`, `powershell` and `zsh` shells to stdout. +Install the documentation generator with +`cargo install --bin uudoc --features uudoc --path .`, then run: -Install `uudoc` by ```shell -cargo install --bin uudoc --features uudoc --path . +uudoc manpages md completions --usr /usr/local ``` -Then use the installed binary: -```shell -uudoc completion -``` +List any combination of `manpages`, `md`, and `completions` to generate those +formats for every compiled utility, including `coreutils`. `--usr` selects the +installation prefix; output goes beneath its `share` directory. Directories +are created if needed, and existing files are overwritten. -So, to install completions for `ls` on `bash` to -`/usr/local/share/bash-completion/completions/ls`, run: +Manpages go in `man/man1` and Markdown files in `docs/coreutils` beneath the +shared-data directory. Completions for `bash`, +`elvish`, `fish`, `powershell` and `zsh` go in `bash-completion/completions`, +`elvish/lib`, `fish/vendor_completions.d`, `powershell`, and `zsh/site-functions`, +respectively. PowerShell scripts must be loaded from the generated files. +The unsupported zsh completion for `[` is omitted. -```shell -uudoc completion ls bash > /usr/local/share/bash-completion/completions/ls.bash -``` +`PROG_PREFIX` applies to all generated filenames. For example: -Completion for prefixed `cp` with `uu-` on `zsh` is generated by ```shell -env PROG_PREFIX=uu- uudoc completion cp zsh +env PROG_PREFIX=uu- uudoc manpages completions --usr /usr/local ``` -### Manually install manpages - -To generate manpages, the syntax is: +This writes prefixed documentation for every compiled utility beneath +`/usr/local/share`, such as `man/man1/uu-ls.1` and +`bash-completion/completions/uu-ls.bash`. -```bash -uudoc manpage -``` - -So, to install the manpage for `ls` to `/usr/local/share/man/man1/ls.1` run: - -```bash -uudoc manpage ls > /usr/local/share/man/man1/ls.1 -``` +Running `uudoc` without arguments still generates the repository's mdbook +documentation in `docs/src`. ## Un-installation diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index 992526b2e17..b021850ce8e 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -8,13 +8,14 @@ use std::{ collections::HashMap, ffi::OsString, - fs::File, + fs::{self, File}, io::{self, Read, Seek, Write}, + path::{Path, PathBuf}, process, }; -use clap::{Arg, Command}; -use clap_complete::Shell; +use clap::{Arg, Command, ValueEnum}; +use clap_complete::{Generator, Shell}; use clap_mangen::Man; use fluent_syntax::{ ast::{ @@ -99,8 +100,7 @@ fn usage(utils: &UtilityMap) { println!(); println!("Commands:"); println!(" (no command) Generate mdbook documentation (default)"); - println!(" manpage Generate manpage for a utility"); - println!(" completion Generate shell completions for a utility"); + println!(" manpages md completions --usr DIR Generate the selected documentation formats"); println!(); println!("Available utilities:"); let all_utilities = validation::get_all_utilities(utils); @@ -109,6 +109,11 @@ fn usage(utils: &UtilityMap) { println!("{}", indent(&fill(&display_list, width), " ")); } +fn write_output(directory: &Path, filename: &str, contents: &[u8]) -> io::Result<()> { + fs::create_dir_all(directory)?; + fs::write(directory.join(filename), contents) +} + /// Generates the coreutils app for the utility map fn gen_coreutils_app(util_map: &UtilityMap) -> Command { let mut command = Command::new("coreutils"); @@ -125,32 +130,20 @@ fn gen_coreutils_app(util_map: &UtilityMap) -> Command { command } -/// Generate the manpage for the utility in the first parameter -fn gen_manpage( +fn write_manpage( tldr: &mut Option>, - args: impl Iterator, + utility: &str, util_map: &UtilityMap, -) -> ! { + directory: &Path, +) -> io::Result<()> { uucore::set_utility_is_second_arg(); - let all_utilities = validation::get_all_utilities(util_map); - - let matches = Command::new("manpage") - .about("Prints manpage to stdout") - .arg( - Arg::new("utility") - .value_parser(clap::builder::PossibleValuesParser::new(&all_utilities)) - .required(true), - ) - .get_matches_from(std::iter::once(OsString::from("manpage")).chain(args)); - - let utility = matches.get_one::("utility").unwrap(); let command = if utility == "coreutils" { gen_coreutils_app(util_map) } else { validation::setup_localization_or_exit(utility); let mut cmd = util_map.get(utility).unwrap().1(); - cmd.set_bin_name(utility.clone()); - let mut cmd = cmd.display_name(utility); + cmd.set_bin_name(utility); + let mut cmd = cmd.display_name(utility.to_owned()); if let Some(zip) = tldr && let Ok(examples) = write_zip_examples(zip, utility, false) { @@ -178,36 +171,20 @@ fn gen_manpage( let date = now.strftime("%Y-%m-%d").to_string(); let processed_manpage = post_process_manpage(manpage, &date); - // Write the processed manpage to stdout - io::stdout() - .write_all(processed_manpage.as_bytes()) - .unwrap(); - io::stdout().flush().unwrap(); - process::exit(0); + let prefix = std::env::var("PROG_PREFIX").unwrap_or_default(); + write_output( + directory, + &format!("{prefix}{utility}.1"), + processed_manpage.as_bytes(), + ) } -/// Generate shell completions for the utility in the first parameter -fn gen_completions(args: impl Iterator, util_map: &UtilityMap) -> ! { - let all_utilities = validation::get_all_utilities(util_map); - - let matches = Command::new("completion") - .about("Prints completions to stdout") - .arg( - Arg::new("utility") - .value_parser(clap::builder::PossibleValuesParser::new(&all_utilities)) - .required(true), - ) - .arg( - Arg::new("shell") - .value_parser(clap::builder::EnumValueParser::::new()) - .required(true), - ) - .get_matches_from(std::iter::once(OsString::from("completion")).chain(args)); - - let utility = matches.get_one::("utility").unwrap(); - let shell = *matches.get_one::("shell").unwrap(); - - let mut command = if utility == "coreutils" { +fn write_completions( + utility: &str, + util_map: &UtilityMap, + directory: &Path, +) -> io::Result<()> { + let command = if utility == "coreutils" { gen_coreutils_app(util_map) } else { validation::setup_localization_or_exit(utility); @@ -215,9 +192,95 @@ fn gen_completions(args: impl Iterator, util_map: &Uti }; let bin_name = std::env::var("PROG_PREFIX").unwrap_or_default() + utility; - clap_complete::generate(shell, &mut command, bin_name, &mut io::stdout()); - io::stdout().flush().unwrap(); - process::exit(0); + for &shell in Shell::value_variants() { + // The completion for '[' is not supported by zsh. + if utility == "[" && shell == Shell::Zsh { + continue; + } + let subdirectory = match shell { + Shell::Bash => "bash-completion/completions", + Shell::Elvish => "elvish/lib", + Shell::Fish => "fish/vendor_completions.d", + Shell::PowerShell => "powershell", + Shell::Zsh => "zsh/site-functions", + _ => unreachable!(), + }; + let output_directory = directory.join(subdirectory); + let mut shell_command = command.clone(); + let mut buffer = Vec::new(); + clap_complete::generate(shell, &mut shell_command, &bin_name, &mut buffer); + write_output(&output_directory, &shell.file_name(&bin_name), &buffer)?; + } + Ok(()) +} + +fn write_markdown( + tldr: &mut Option>, + utility: &str, + util_map: &UtilityMap, + directory: &Path, +) -> io::Result<()> { + let command = if utility == "coreutils" { + gen_coreutils_app(util_map).version(uucore::crate_version!()) + } else { + validation::setup_localization_or_exit(utility); + util_map.get(utility).unwrap().1() + }; + let prefix = std::env::var("PROG_PREFIX").unwrap_or_default(); + fs::create_dir_all(directory)?; + let file = File::create(directory.join(format!("{prefix}{utility}.md")))?; + MDWriter { + w: Box::new(file), + command, + name: utility, + tldr_zip: tldr, + utils_per_platform: None, + fluent: None, + fluent_key: utility.to_string(), + } + .markdown() +} + +/// Generate the requested formats beneath an installation prefix. +fn gen_documentation( + tldr: &mut Option>, + args: impl Iterator, + util_map: &UtilityMap, +) -> io::Result<()> { + let matches = Command::new("uudoc") + .about("Generate manpages, Markdown documentation, and shell completions") + .arg( + Arg::new("formats") + .value_parser(["manpages", "md", "completions"]) + .num_args(1..) + .required(true) + .help("Documentation formats to generate"), + ) + .arg( + Arg::new("usr") + .long("usr") + .value_name("DIR") + .value_parser(clap::value_parser!(PathBuf)) + .required(true) + .help("Install documentation beneath DIR/share"), + ) + .get_matches_from(std::iter::once(OsString::from("uudoc")).chain(args)); + + let directory = matches.get_one::("usr").unwrap().join("share"); + let man_directory = directory.join("man/man1"); + let md_directory = directory.join("docs/coreutils"); + let formats: Vec<_> = matches.get_many::("formats").unwrap().collect(); + for utility in validation::get_all_utilities(util_map) { + for format in &formats { + match format.as_str() { + "manpages" => write_manpage(tldr, utility, util_map, &man_directory)?, + "md" => write_markdown(tldr, utility, util_map, &md_directory)?, + "completions" => write_completions(utility, util_map, &directory)?, + _ => unreachable!(), + } + } + } + Ok(()) } /// # Errors @@ -230,37 +293,27 @@ fn main() -> io::Result<()> { .ok() .and_then(|f| ZipArchive::new(f).ok()); - // Check for manpage/completion commands first + // Check for installation formats before the default mdbook output. if args.len() > 1 { let command = args.get(1).and_then(|s| s.to_str()).unwrap_or_default(); match command { - "manpage" => { - let args_iter = args.into_iter().skip(2); - gen_manpage( - &mut tldr_zip, - args_iter, - &util_map::>>(), - ); - } - "completion" => { - let args_iter = args.into_iter().skip(2); - gen_completions(args_iter, &util_map::>>()); - } "--help" | "-h" => { usage(&util_map::>>()); process::exit(0); } _ => { - eprintln!("Unknown command: {command}"); - eprintln!("Use 'uudoc --help' for usage information."); - process::exit(1); + return gen_documentation( + &mut tldr_zip, + args.into_iter().skip(1), + &util_map::>>(), + ); } } } let utils = util_map::>>(); // Initialize localization for uucore common strings (used by tldr example attribution) let _ = uucore::locale::setup_localization("uudoc"); - match std::fs::create_dir("docs/src/utils/") { + match fs::create_dir("docs/src/utils/") { Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), x => x, }?; @@ -391,7 +444,7 @@ fn main() -> io::Result<()> { command: command(), name: usage_name, tldr_zip: &mut tldr_zip, - utils_per_platform: &utils_per_platform, + utils_per_platform: Some(&utils_per_platform), fluent, fluent_key: utils_name.to_string(), } @@ -431,7 +484,7 @@ struct MDWriter<'a, 'b> { command: Command, name: &'a str, tldr_zip: &'b mut Option>, - utils_per_platform: &'b HashMap<&'b str, Vec>, + utils_per_platform: Option<&'b HashMap<&'b str, Vec>>, fluent: Option, fluent_key: String, } @@ -449,9 +502,12 @@ impl MDWriter<'_, '_> { self.examples() } - /// Extract value for a Fluent key from the .ftl content + /// Resolve a Fluent key from source content or the embedded localization. fn extract_fluent_value(&self, key: &str) -> Option { - let content = self.fluent.as_ref()?; + let Some(content) = self.fluent.as_ref() else { + let message = get_message(key); + return (message != key).then_some(message); + }; let resource = parser::parse(content.clone()).ok()?; for entry in resource.body { @@ -494,6 +550,9 @@ impl MDWriter<'_, '_> { /// # Errors /// Returns an error if the writer fails. fn platforms(&mut self) -> io::Result<()> { + let Some(utils_per_platform) = self.utils_per_platform else { + return Ok(()); + }; writeln!(self.w, "
")?; for (feature, icon) in [ ("linux", "linux"), @@ -504,9 +563,7 @@ impl MDWriter<'_, '_> { ("windows", "windows"), ] { if self.name.contains("sum") - || self.utils_per_platform[feature] - .iter() - .any(|u| u == self.name) + || utils_per_platform[feature].iter().any(|u| u == self.name) { writeln!(self.w, "")?; } @@ -531,14 +588,13 @@ impl MDWriter<'_, '_> { /// # Errors /// Returns an error if the writer fails. fn usage(&mut self) -> io::Result<()> { - if let Some(usage) = self.extract_fluent_value(&format!("{}-usage", self.fluent_key)) { - let usage = fix_usage(self.name, usage); - writeln!(self.w, "\n```")?; - writeln!(self.w, "{usage}")?; - writeln!(self.w, "```") - } else { - Ok(()) - } + let usage = self + .extract_fluent_value(&format!("{}-usage", self.fluent_key)) + .unwrap_or_else(|| self.command.render_usage().to_string()); + let usage = fix_usage(self.name, usage); + writeln!(self.w, "\n```")?; + writeln!(self.w, "{usage}")?; + writeln!(self.w, "```") } /// # Errors diff --git a/tests/uudoc/mod.rs b/tests/uudoc/mod.rs index cf852c65794..59b81472e1a 100644 --- a/tests/uudoc/mod.rs +++ b/tests/uudoc/mod.rs @@ -3,6 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore manpages + use std::{env, path::PathBuf, process::Command, sync::OnceLock}; static UUDOC_BINARY_PATH: OnceLock = OnceLock::new(); @@ -17,9 +19,10 @@ fn get_uudoc_command() -> Command { #[test] fn test_manpage_generation() { + let temp = tempfile::tempdir().unwrap(); let output = get_uudoc_command() - .arg("manpage") - .arg("ls") + .args(["manpages", "--usr"]) + .arg(temp.path()) .output() .expect("Failed to execute command"); @@ -35,7 +38,8 @@ fn test_manpage_generation() { String::from_utf8_lossy(&output.stderr) ); - let output_str = String::from_utf8_lossy(&output.stdout); + assert!(output.stdout.is_empty()); + let output_str = std::fs::read_to_string(temp.path().join("share/man/man1/ls.1")).unwrap(); assert!(output_str.contains("\n.TH LS"), "{output_str}"); assert!(output_str.contains('1'), "{output_str}"); assert!(output_str.contains("\n.SH NAME\nls"), "{output_str}"); @@ -43,9 +47,10 @@ fn test_manpage_generation() { #[test] fn test_manpage_coreutils() { + let temp = tempfile::tempdir().unwrap(); let output = get_uudoc_command() - .arg("manpage") - .arg("coreutils") + .args(["manpages", "--usr"]) + .arg(temp.path()) .output() .expect("Failed to execute command"); @@ -61,30 +66,33 @@ fn test_manpage_coreutils() { String::from_utf8_lossy(&output.stderr) ); - let output_str = String::from_utf8_lossy(&output.stdout); + assert!(output.stdout.is_empty()); + let output_str = + std::fs::read_to_string(temp.path().join("share/man/man1/coreutils.1")).unwrap(); assert!(output_str.contains("\n.TH COREUTILS"), "{output_str}"); assert!(output_str.contains("coreutils"), "{output_str}"); assert!(output_str.contains("\n.SH NAME\ncoreutils"), "{output_str}"); } #[test] -fn test_manpage_invalid_utility() { +fn test_invalid_format() { + let temp = tempfile::tempdir().unwrap(); let output = get_uudoc_command() - .arg("manpage") - .arg("nonexistent_utility") + .args(["manpages", "nonexistent_format", "--usr"]) + .arg(temp.path()) .output() .expect("Failed to execute command"); - // Should fail for invalid utility + // Should fail for an invalid format assert!(!output.status.success(), "Command should have failed"); } #[test] fn test_completion_generation() { + let temp = tempfile::tempdir().unwrap(); let output = get_uudoc_command() - .arg("completion") - .arg("ls") - .arg("powershell") + .args(["completions", "--usr"]) + .arg(temp.path()) .output() .expect("Failed to execute command"); @@ -99,7 +107,8 @@ fn test_completion_generation() { String::from_utf8_lossy(&output.stderr) ); - let output_str = String::from_utf8_lossy(&output.stdout); + assert!(output.stdout.is_empty()); + let output_str = std::fs::read_to_string(temp.path().join("share/powershell/_ls.ps1")).unwrap(); assert!( output_str.contains("using namespace System.Management.Automation"), "{output_str}" @@ -108,16 +117,18 @@ fn test_completion_generation() { // Prevent regression to: // -// ❯ uudoc manpage base64 | rg --fixed-strings -- 'base32' +// ❯ uudoc manpages --usr . +// ❯ rg --fixed-strings -- 'base32' share/man/man1/base64.1 // The data are encoded as described for the base32 alphabet in RFC 4648. // to the bytes of the formal base32 alphabet. Use \-\-ignore\-garbage // The data are encoded as described for the base32 alphabet in RFC 4648. // to the bytes of the formal base32 alphabet. Use \-\-ignore\-garbage #[test] fn test_manpage_base64() { + let temp = tempfile::tempdir().unwrap(); let output = get_uudoc_command() - .arg("manpage") - .arg("base64") + .args(["manpages", "--usr"]) + .arg(temp.path()) .env("LANG", "C") // force locale to english to avoid issues with manpage output .output() .expect("Failed to execute command"); @@ -134,7 +145,8 @@ fn test_manpage_base64() { String::from_utf8_lossy(&output.stderr) ); - let output_str = String::from_utf8_lossy(&output.stdout); + assert!(output.stdout.is_empty()); + let output_str = std::fs::read_to_string(temp.path().join("share/man/man1/base64.1")).unwrap(); assert!(output_str.contains("base64 alphabet")); assert!(!output_str.to_ascii_lowercase().contains("base32")); } @@ -177,9 +189,10 @@ fn test_markdown_header_format() { #[test] fn test_source_date_epoch() { + let temp = tempfile::tempdir().unwrap(); let output = get_uudoc_command() - .arg("manpage") - .arg("ls") + .args(["manpages", "--usr"]) + .arg(temp.path()) .env("SOURCE_DATE_EPOCH", "123456") .output() .expect("Failed to execute command"); @@ -195,10 +208,249 @@ fn test_source_date_epoch() { String::from_utf8_lossy(&output.stderr) ); - let output_str = String::from_utf8_lossy(&output.stdout); + assert!(output.stdout.is_empty()); + let output_str = std::fs::read_to_string(temp.path().join("share/man/man1/ls.1")).unwrap(); let first_line = output_str .lines() .find(|line| line.starts_with(".TH")) .unwrap(); assert_eq!(first_line, ".TH LS 1 \"1970-01-02\""); } + +#[test] +fn test_manpage_output_directory() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("generated docs"); + + for (prefix, filename) in [("", "ls.1"), ("uu-", "uu-ls.1")] { + let output = get_uudoc_command() + .args(["manpages", "--usr"]) + .arg(&directory) + .env("PROG_PREFIX", prefix) + .env("SOURCE_DATE_EPOCH", "123456") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + let manpage = + std::fs::read_to_string(directory.join("share/man/man1").join(filename)).unwrap(); + assert!(manpage.contains(".TH LS 1 \"1970-01-02\""), "{manpage}"); + assert!(manpage.contains("\n.SH NAME\nls"), "{manpage}"); + } +} + +#[test] +fn test_completion_output_directory() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("shell completions"); + + let output = get_uudoc_command() + .args(["completions", "--usr"]) + .arg(&directory) + .env("PROG_PREFIX", "uu-") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + for filename in [ + "bash-completion/completions/uu-ls.bash", + "elvish/lib/uu-ls.elv", + "fish/vendor_completions.d/uu-ls.fish", + "powershell/_uu-ls.ps1", + "zsh/site-functions/_uu-ls", + ] { + let completion = std::fs::read_to_string(directory.join("share").join(filename)).unwrap(); + assert!(completion.contains("uu-ls"), "{completion}"); + } +} + +#[rstest::rstest] +fn test_bulk_generation(#[values("", "uu-")] prefix: &str) { + use std::collections::BTreeSet; + use std::fs; + + let temp = tempfile::tempdir().unwrap(); + let output = get_uudoc_command() + .args(["manpages", "md", "completions", "--usr"]) + .arg(temp.path()) + .env("PROG_PREFIX", prefix) + .env("SOURCE_DATE_EPOCH", "123456") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + + let list = Command::new(env!("CARGO_BIN_EXE_coreutils")) + .arg("--list") + .output() + .unwrap(); + assert!(list.status.success()); + let list = String::from_utf8(list.stdout).unwrap(); + let utilities: Vec<_> = list.lines().chain(["coreutils"]).collect(); + + for (directory, leading, extension) in [ + ("man/man1", "", ".1"), + ("docs/coreutils", "", ".md"), + ("bash-completion/completions", "", ".bash"), + ("elvish/lib", "", ".elv"), + ("fish/vendor_completions.d", "", ".fish"), + ("powershell", "_", ".ps1"), + ("zsh/site-functions", "_", ""), + ] { + let expected: BTreeSet<_> = utilities + .iter() + .filter(|&&utility| directory != "zsh/site-functions" || utility != "[") + .map(|utility| format!("{leading}{prefix}{utility}{extension}")) + .collect(); + let bulk_path = temp.path().join("share").join(directory); + let actual: BTreeSet<_> = fs::read_dir(&bulk_path) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + assert_eq!(actual, expected, "{directory}"); + } +} + +#[test] +fn test_output_directory_required() { + for args in [&["manpages", "completions"][..], &["md"][..]] { + let output = get_uudoc_command().args(args).output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--usr ")); + } +} + +#[test] +fn test_output_directory_errors() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("output"); + std::fs::write(&directory, "keep this file").unwrap(); + + let output = get_uudoc_command() + .args(["manpages", "completions", "--usr"]) + .arg(&directory) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(!output.stderr.is_empty()); + assert_eq!( + std::fs::read_to_string(&directory).unwrap(), + "keep this file" + ); +} + +#[test] +fn test_output_file_errors() { + for filename in [ + "man/man1/ls.1", + "docs/coreutils/ls.md", + "bash-completion/completions/ls.bash", + ] { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("share").join(filename)).unwrap(); + let output = get_uudoc_command() + .args(["manpages", "md", "completions", "--usr"]) + .arg(temp.path()) + .env_remove("PROG_PREFIX") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(!output.stderr.is_empty()); + assert!(temp.path().join("share").join(filename).is_dir()); + } +} + +#[cfg(unix)] +#[test] +fn test_output_directory_non_utf8() { + use std::os::unix::ffi::OsStrExt; + + let temp = tempfile::tempdir().unwrap(); + let directory = temp + .path() + .join(std::ffi::OsStr::from_bytes(b"output_\xff")); + let output = get_uudoc_command() + .args(["manpages", "completions", "--usr"]) + .arg(&directory) + .env_remove("PROG_PREFIX") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + assert!( + std::fs::read_to_string(directory.join("share/man/man1/ls.1")) + .unwrap() + .contains(".TH LS") + ); +} + +#[rstest::rstest] +#[case(&["manpages"], &["man"])] +#[case(&["md"], &["docs"])] +#[case(&["completions"], &["bash-completion", "elvish", "fish", "powershell", "zsh"])] +#[case(&["manpages", "md"], &["man", "docs"])] +#[case(&["manpages", "completions"], &["man", "bash-completion", "elvish", "fish", "powershell", "zsh"])] +#[case(&["md", "completions"], &["docs", "bash-completion", "elvish", "fish", "powershell", "zsh"])] +#[case(&["manpages", "md", "completions"], &["man", "docs", "bash-completion", "elvish", "fish", "powershell", "zsh"])] +fn test_selected_formats(#[case] formats: &[&str], #[case] directories: &[&str]) { + use std::collections::BTreeSet; + + let temp = tempfile::tempdir().unwrap(); + let prefix = temp.path().join("install prefix"); + let output = get_uudoc_command() + .args(formats) + .arg("--usr") + .arg(&prefix) + .env_remove("PROG_PREFIX") + .env("LC_ALL", "C") + .current_dir(temp.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + let share = prefix.join("share"); + let actual: BTreeSet<_> = std::fs::read_dir(&share) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + let expected: BTreeSet<_> = directories.iter().map(|name| (*name).to_string()).collect(); + assert_eq!(actual, expected); + if formats.contains(&"md") { + let markdown = std::fs::read_to_string(share.join("docs/coreutils/ls.md")).unwrap(); + assert!(markdown.starts_with("# ls\n"), "{markdown}"); + assert!(markdown.contains("List directory contents"), "{markdown}"); + assert!(markdown.contains("## Options"), "{markdown}"); + assert!(markdown.contains("--all"), "{markdown}"); + assert!(!markdown.contains("ls-help-"), "{markdown}"); + } +} + +#[test] +fn test_markdown_prefix_and_multicall() { + let temp = tempfile::tempdir().unwrap(); + let output = get_uudoc_command() + .args(["md", "--usr"]) + .arg(temp.path()) + .env("PROG_PREFIX", "uu-") + .env("LC_ALL", "C") + .current_dir(temp.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + let directory = temp.path().join("share/docs/coreutils"); + let multicall = std::fs::read_to_string(directory.join("uu-coreutils.md")).unwrap(); + assert!(multicall.starts_with("# coreutils\n"), "{multicall}"); + let base64 = std::fs::read_to_string(directory.join("uu-base64.md")).unwrap(); + assert!(base64.contains("base64 alphabet"), "{base64}"); + assert!(!base64.to_ascii_lowercase().contains("base32"), "{base64}"); +}