diff --git a/.github/workflows/manpage-lint.yml b/.github/workflows/manpage-lint.yml new file mode 100644 index 00000000000..fe30d79afa7 --- /dev/null +++ b/.github/workflows/manpage-lint.yml @@ -0,0 +1,109 @@ +# spell-checker:ignore mandoc uudoc manpages dtolnay libsystemd libattr libcap DESTDIR + +name: Manpage Validation + +on: + pull_request: + paths: + - 'src/bin/uudoc.rs' + - 'src/uu/*/locales/*.ftl' + - 'src/uu/*/src/*.rs' + - 'Cargo.toml' + - 'GNUmakefile' + - '.github/workflows/manpage-lint.yml' + push: + branches: + - main + paths: + - 'src/bin/uudoc.rs' + - 'src/uu/*/locales/*.ftl' + - 'src/uu/*/src/*.rs' + - 'Cargo.toml' + - 'GNUmakefile' + - '.github/workflows/manpage-lint.yml' + +jobs: + manpage-lint: + name: Validate manpages with mandoc + runs-on: ubuntu-latest + strategy: + matrix: + locale: [en_US.UTF-8, fr_FR.UTF-8] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install prerequisites + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y mandoc locales-all + sudo apt-get install -y libselinux1-dev libsystemd-dev libacl1-dev libattr1-dev libcap-dev + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build manpages (${{ matrix.locale }}) + run: | + # Create temporary directory for manpages + MANPAGE_DIR=$(mktemp -d) + echo "MANPAGE_DIR=${MANPAGE_DIR}" >> $GITHUB_ENV + + # Set locale for manpage generation + export LANG=${{ matrix.locale }} + + # Build and install manpages to temporary directory + make install-manpages DESTDIR="${MANPAGE_DIR}" + + - name: Validate manpages with mandoc (${{ matrix.locale }}) + run: | + # Find all generated manpages + MANPAGE_PATH="${MANPAGE_DIR}/usr/local/share/man/man1" + + # Check if manpages were generated + if [ ! -d "${MANPAGE_PATH}" ]; then + echo "Error: No manpages found at ${MANPAGE_PATH}" + exit 1 + fi + + # Initialize error tracking + ERRORS_FOUND=0 + ERROR_LOG=$(mktemp) + + echo "Validating ${{ matrix.locale }} manpages with mandoc..." + echo "==========================================" + + # Validate each manpage + for manpage in "${MANPAGE_PATH}"/*.1; do + if [ -f "$manpage" ]; then + filename=$(basename "$manpage") + + # Run mandoc lint and capture output (only errors, not style warnings) + if ! mandoc -T lint -W error "$manpage" 2>&1 | tee -a "$ERROR_LOG"; then + echo "Errors found in $filename" + ERRORS_FOUND=1 + else + # Check if mandoc produced any output (errors only, not style warnings) + if mandoc -T lint -W error "$manpage" 2>&1 | grep -q .; then + echo "Warnings found in $filename" + ERRORS_FOUND=1 + else + echo "$filename is valid" + fi + fi + fi + done + + echo "" + echo "==================================" + + # Summary and exit + if [ "$ERRORS_FOUND" -eq 1 ]; then + echo "Manpage validation failed. Issues found:" + echo "" + cat "$ERROR_LOG" + exit 1 + else + echo "All manpages validated successfully!" + fi diff --git a/Cargo.lock b/Cargo.lock index c5bd7b89b36..e41116809cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1716,7 +1716,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2019,7 +2019,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2601,7 +2601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2901,7 +2901,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4689,7 +4689,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f37b1b28fe5..acf962c3e76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,14 @@ expensive_tests = [] # "test_risky_names" == enable tests that create problematic file names (would make a network share inaccessible to Windows, breaks SVN on Mac OS, etc.) test_risky_names = [] # * only build `uudoc` when `--feature uudoc` is activated -uudoc = ["dep:clap_complete", "dep:clap_mangen", "dep:fluent-syntax", "dep:zip"] +uudoc = [ + "dep:clap_complete", + "dep:clap_mangen", + "dep:fluent-syntax", + "dep:jiff", + "dep:regex", + "dep:zip", +] ## features ## Optional feature for stdbuf # "feat_external_libstdbuf" == use an external libstdbuf.so for stdbuf instead of embedding it @@ -475,6 +482,8 @@ clap_complete = { workspace = true, optional = true } clap_mangen = { workspace = true, optional = true } clap.workspace = true fluent-syntax = { workspace = true, optional = true } +jiff = { workspace = true, optional = true } +regex = { workspace = true, optional = true } itertools.workspace = true phf.workspace = true selinux = { workspace = true, optional = true } diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index 76f04774ec7..459437bf2cc 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -3,10 +3,10 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore mangen tldr +// spell-checker:ignore mangen tldr mandoc uppercasing uppercased manpages DESTDIR use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, ffi::OsString, fs::File, io::{self, Read, Seek, Write}, @@ -18,6 +18,8 @@ use clap_complete::Shell; use clap_mangen::Man; use fluent_syntax::ast::{Entry, Message, Pattern}; use fluent_syntax::parser; +use jiff::Zoned; +use regex::Regex; use textwrap::{fill, indent, termwidth}; use zip::ZipArchive; @@ -26,6 +28,84 @@ use uucore::Args; include!(concat!(env!("OUT_DIR"), "/uutils_map.rs")); +/// Post-process a generated manpage to fix mandoc lint issues +/// +/// This function: +/// - Fixes the TH header by uppercasing command names and adding a proper date +/// - Removes trailing whitespace from all lines +/// - Fixes redundant .br paragraph macros that cause mandoc warnings +/// - Removes .br before empty lines to avoid "br before sp" warnings +/// - Removes .br after empty lines to avoid "br after sp" warnings +/// - Fixes escape sequences (e.g., \\\\0 to \\0) to avoid "undefined escape" warnings +fn post_process_manpage(manpage: String, date: Option<&str>) -> String { + // Only match TH headers that have at least a command name on the same line + // Use [ \t] instead of \s to avoid matching newlines + // Use a date format that satisfies mandoc (YYYY-MM-DD) + let date = date.map_or_else( + || Zoned::now().strftime("%Y-%m-%d").to_string(), + str::to_string, + ); + + let th_regex = Regex::new(r"(?m)^\.TH[ \t]+([^ \t\n]+)(?:[ \t]+[^\n]*)?$").unwrap(); + let mut result = th_regex + .replace_all(&manpage, |caps: ®ex::Captures| { + // Add date to satisfy mandoc - date must be quoted + format!(".TH {} 1 \"{date}\"", caps[1].to_uppercase()) + }) + .to_string(); + + // Process lines: remove trailing whitespace and fix .br issues in a single pass + let lines: Vec<&str> = result.lines().collect(); + let mut fixed_lines = Vec::with_capacity(lines.len()); + let mut skip_indices = HashSet::new(); + + // First pass: identify lines to skip (redundant .br macros) + for i in 0..lines.len() { + let line = lines[i].trim_end(); + + if line == ".br" && !skip_indices.contains(&i) { + // Check for .br followed by empty line + if i + 1 < lines.len() && lines[i + 1].trim().is_empty() { + // Remove the .br when it's followed by an empty line + // This prevents "WARNING: skipping paragraph macro: br before sp" + skip_indices.insert(i); + + // Also check if there's another .br after the empty line (common pattern) + if i + 2 < lines.len() && lines[i + 2].trim_end() == ".br" { + skip_indices.insert(i + 2); + } + } + // Check for .br preceded by empty line or another .br + // This prevents "WARNING: skipping paragraph macro: br after sp" and consecutive .br + else if i > 0 && (lines[i - 1].trim().is_empty() || lines[i - 1].trim_end() == ".br") + { + skip_indices.insert(i); + } + } + } + + // Second pass: build the final output + for (i, line) in lines.iter().enumerate() { + if !skip_indices.contains(&i) { + fixed_lines.push(line.trim_end()); + } + } + + result = fixed_lines.join("\n"); + + // Fix escape sequence issues + // \\\\0 appears when trying to represent literal \0 string + // In man pages, use \e for literal backslash + result = result.replace("\\\\\\\\0", "\\e0"); + result = result.replace("\\\\0", "\\e0"); + + if !result.ends_with('\n') { + result.push('\n'); + } + + result +} + /// Print usage information for uudoc fn usage(utils: &UtilityMap) { println!("uudoc - Documentation generator for uutils coreutils"); @@ -94,9 +174,21 @@ fn gen_manpage( cmd }; + // Generate the manpage to a buffer first so we can post-process it + let mut buffer = Vec::new(); let man = Man::new(command); - man.render(&mut io::stdout()) - .expect("Man page generation failed"); + man.render(&mut buffer).expect("Man page generation failed"); + + // Convert to string for processing + let manpage = String::from_utf8(buffer).expect("Invalid UTF-8 in manpage"); + + // Post-process the manpage to fix mandoc lint issues + let processed_manpage = post_process_manpage(manpage, None); + + // Write the processed manpage to stdout + io::stdout() + .write_all(processed_manpage.as_bytes()) + .unwrap(); io::stdout().flush().unwrap(); process::exit(0); } @@ -631,3 +723,159 @@ fn format_examples(content: String, output_markdown: bool) -> Result