refactor: extract manpage post-processing into a function with tests

- Move manpage post-processing logic into post_process_manpage() function
- Add comprehensive unit tests for all post-processing functionality
- Improve .br macro handling to correctly fix mandoc warnings
- Tests cover TH header fixes, whitespace removal, and .br pattern fixes
This commit is contained in:
Sylvestre Ledru
2026-03-19 16:26:49 +01:00
committed by Daniel Hofstetter
parent cd5af0c279
commit b73d67e9bb
2 changed files with 203 additions and 57 deletions
+8 -1
View File
@@ -31,7 +31,13 @@ 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:regex",
"dep:zip",
]
## features
## Optional feature for stdbuf
# "feat_external_libstdbuf" == use an external libstdbuf.so for stdbuf instead of embedding it
@@ -475,6 +481,7 @@ clap_complete = { workspace = true, optional = true }
clap_mangen = { workspace = true, optional = true }
clap.workspace = true
fluent-syntax = { workspace = true, optional = true }
regex = { workspace = true, optional = true }
itertools.workspace = true
phf.workspace = true
selinux = { workspace = true, optional = true }
+195 -56
View File
@@ -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},
@@ -17,7 +17,9 @@ use clap::{Arg, Command};
use clap_complete::Shell;
use clap_mangen::Man;
use fluent_syntax::ast::{Entry, Message, Pattern};
use jiff::Zoned;
use fluent_syntax::parser;
use regex::Regex;
use textwrap::{fill, indent, termwidth};
use zip::ZipArchive;
@@ -26,6 +28,75 @@ 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 removing invalid date formats
/// - Removes trailing whitespace from all lines
/// - Fixes redundant .br paragraph macros that cause mandoc warnings
fn post_process_manpage(manpage: String) -> 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: &regex::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 consecutive .br macros
if i > 0 && lines[i - 1].trim_end() == ".br" {
skip_indices.insert(i);
}
// Check for .br, empty line, .br pattern
else if i + 2 < lines.len()
&& lines[i + 1].trim().is_empty()
&& lines[i + 2].trim_end() == ".br"
{
skip_indices.insert(i + 2);
}
}
}
// 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<T: Args>(utils: &UtilityMap<T>) {
println!("uudoc - Documentation generator for uutils coreutils");
@@ -100,63 +171,15 @@ fn gen_manpage<T: Args>(
man.render(&mut buffer).expect("Man page generation failed");
// Convert to string for processing
let mut manpage = String::from_utf8(buffer).expect("Invalid UTF-8 in manpage");
let manpage = String::from_utf8(buffer).expect("Invalid UTF-8 in manpage");
// Fix the TH line: remove version info from date field and uppercase the command name
if let Some(th_pos) = manpage.find(".TH ") {
if let Some(line_end) = manpage[th_pos..].find('\n') {
let th_line = &manpage[th_pos..th_pos + line_end];
// Parse the TH line parts
let parts: Vec<&str> = th_line.split_whitespace().collect();
if parts.len() >= 2 {
let cmd_name = parts[1].to_uppercase();
// Reconstruct TH line with uppercase command name and no date
let new_th = format!(".TH {} 1", cmd_name);
manpage.replace_range(th_pos..th_pos + line_end, &new_th);
}
}
}
// Remove trailing whitespace from all lines and fix .br issues
let lines: Vec<String> = manpage
.lines()
.map(|line| line.trim_end().to_string())
.collect();
// Fix .br paragraph macro issues
let mut fixed_lines = Vec::new();
let mut skip_next_br = false;
for i in 0..lines.len() {
let line = &lines[i];
if line == ".br" {
// Check for problematic patterns with .br
let prev_is_br = i > 0 && lines[i - 1] == ".br";
let next_is_empty_then_br =
i + 2 < lines.len() && lines[i + 1].is_empty() && lines[i + 2] == ".br";
let prev_is_empty_with_br = i >= 2 && lines[i - 1].is_empty() && lines[i - 2] == ".br";
// Skip redundant .br in these patterns
if skip_next_br || prev_is_br || next_is_empty_then_br || prev_is_empty_with_br {
skip_next_br = false;
continue;
}
// If this .br is followed by empty line and another .br, skip the second one
if next_is_empty_then_br {
skip_next_br = true;
}
}
fixed_lines.push(line.clone());
}
manpage = fixed_lines.join("\n");
manpage.push('\n');
// 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(manpage.as_bytes()).unwrap();
io::stdout()
.write_all(processed_manpage.as_bytes())
.unwrap();
io::stdout().flush().unwrap();
process::exit(0);
}
@@ -691,3 +714,119 @@ fn format_examples(content: String, output_markdown: bool) -> Result<String, std
)?;
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_post_process_manpage_fixes_th_header() {
// Test that command names are uppercased and date is removed
let input =
".TH cat 1 \"cat (uutils coreutils) 0.7.0\"\n.SH NAME\ncat - concatenate files\n";
let expected = ".TH CAT 1 \"2024-01-01\"\n.SH NAME\ncat - concatenate files\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_removes_trailing_whitespace() {
// Test that trailing whitespace is removed from lines
let input = ".TH TEST 1 \nSome text with trailing spaces \n.SH SECTION \n";
let expected = ".TH TEST 1 \"2024-01-01\"\nSome text with trailing spaces\n.SH SECTION\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_fixes_double_br() {
// Test that redundant .br macros are removed
let input = ".TH TEST 1\n.br\n.br\nSome text\n";
let expected = ".TH TEST 1 \"2024-01-01\"\n.br\nSome text\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_fixes_br_with_empty_line() {
// Test that .br with empty line patterns are fixed
// Both .br macros should be removed (first because followed by empty, second because preceded by empty)
let input = ".TH TEST 1\n.br\n\n.br\nSome text\n";
let expected = ".TH TEST 1 \"2024-01-01\"\n\nSome text\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_preserves_single_br() {
// Test that single .br macros are preserved
let input = ".TH TEST 1\nLine 1\n.br\nLine 2\n";
let expected = ".TH TEST 1 \"2024-01-01\"\nLine 1\n.br\nLine 2\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_handles_mixed_case_command() {
// Test that mixed case command names are uppercased
let input = ".TH CaT 1 \"some version info\"\nContent\n";
let expected = ".TH CAT 1 \"2024-01-01\"\nContent\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_handles_no_th_header() {
// Test that manpages without TH headers are handled gracefully
let input = ".SH NAME\ntest - a test utility\n";
let expected = ".SH NAME\ntest - a test utility\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_complex_br_pattern() {
// Test complex .br patterns with multiple occurrences
let input =
".TH TEST 1\nSection 1\n.br\n\n.br\nMiddle\n.br\n.br\nSection 2\n.br\n\n.br\nEnd\n";
// .br followed/preceded by empty lines should be removed, consecutive .br should have one removed
let expected = ".TH TEST 1 \"2024-01-01\"\nSection 1\n\nMiddle\n.br\nSection 2\n\nEnd\n";
let result = post_process_manpage(input.to_string(), Some("2024-01-01"));
assert_eq!(result, expected);
}
#[test]
fn test_post_process_manpage_malformed_th_header() {
// Test that malformed TH headers don't cause panics and are handled gracefully
let input1 = ".TH\nContent\n"; // Missing command name
let expected1 = ".TH\nContent\n";
let result1 = post_process_manpage(input1.to_string(), Some("2024-01-01"));
assert_eq!(result1, expected1);
// TH header with special characters
let input2 = ".TH test-cmd 1 \"version 1.0\"\nContent\n";
let expected2 = ".TH TEST-CMD 1 \"2024-01-01\"\nContent\n";
let result2 = post_process_manpage(input2.to_string(), Some("2024-01-01"));
assert_eq!(result2, expected2);
// TH header at end of file without newline
let input3 = "Content\n.TH test 1";
let expected3 = "Content\n.TH TEST 1 \"2024-01-01\"\n";
let result3 = post_process_manpage(input3.to_string(), Some("2024-01-01"));
assert_eq!(result3, expected3);
// Multiple TH headers (only first should be processed due to ^anchor)
let input4 = ".TH first 1\nMiddle\n.TH second 1\n";
let expected4 = ".TH FIRST 1 \"2024-01-01\"\nMiddle\n.TH SECOND 1 \"2024-01-01\"\n";
let result4 = post_process_manpage(input4.to_string(), Some("2024-01-01"));
assert_eq!(result4, expected4);
}
}