diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index 8b8da5135a2..696340ac7c1 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -539,8 +539,71 @@ fn print_special_setting(setting: &PrintSetting, fd: i32) -> nix::Result<()> { Ok(()) } -fn print_terminal_size(termios: &Termios, opts: &Options) -> nix::Result<()> { +/// Handles line wrapping for stty output to fit within terminal width +struct WrappedPrinter { + width: usize, + current: usize, + first_in_line: bool, +} + +impl WrappedPrinter { + /// Creates a new printer with the specified terminal width. + /// If term_size is None (typically when output is piped), falls back to + /// the COLUMNS environment variable or a default width of 80 columns. + fn new(term_size: Option<&TermSize>) -> Self { + let columns = match term_size { + Some(term_size) => term_size.columns, + None => { + const DEFAULT_TERM_WIDTH: u16 = 80; + + std::env::var_os("COLUMNS") + .and_then(|s| s.to_str()?.parse().ok()) + .filter(|&c| c > 0) + .unwrap_or(DEFAULT_TERM_WIDTH) + } + }; + + Self { + width: columns.max(1) as usize, + current: 0, + first_in_line: true, + } + } + + fn print(&mut self, token: &str) { + let token_len = self.prefix().chars().count() + token.chars().count(); + if self.current > 0 && self.current + token_len > self.width { + println!(); + self.current = 0; + self.first_in_line = true; + } + + print!("{}{}", self.prefix(), token); + self.current += token_len; + self.first_in_line = false; + } + + fn prefix(&self) -> &str { + if self.first_in_line { "" } else { " " } + } + + fn flush(&mut self) { + if self.current > 0 { + println!(); + self.current = 0; + self.first_in_line = false; + } + } +} + +fn print_terminal_size( + termios: &Termios, + opts: &Options, + window_size: Option<&TermSize>, + term_size: Option<&TermSize>, +) -> nix::Result<()> { let speed = cfgetospeed(termios); + let mut printer = WrappedPrinter::new(window_size); // BSDs use a u32 for the baud rate, so we can simply print it. #[cfg(any( @@ -551,7 +614,7 @@ fn print_terminal_size(termios: &Termios, opts: &Options) -> nix::Result<()> { target_os = "netbsd", target_os = "openbsd" ))] - print!("{} ", translate!("stty-output-speed", "speed" => speed)); + printer.print(&translate!("stty-output-speed", "speed" => speed)); // Other platforms need to use the baud rate enum, so printing the right value // becomes slightly more complicated. @@ -565,17 +628,15 @@ fn print_terminal_size(termios: &Termios, opts: &Options) -> nix::Result<()> { )))] for (text, baud_rate) in BAUD_RATES { if *baud_rate == speed { - print!("{} ", translate!("stty-output-speed", "speed" => (*text))); + printer.print(&translate!("stty-output-speed", "speed" => (*text))); break; } } if opts.all { - let mut size = TermSize::default(); - unsafe { tiocgwinsz(opts.file.as_raw_fd(), &raw mut size)? }; - print!( - "{} ", - translate!("stty-output-rows-columns", "rows" => size.rows, "columns" => size.columns) + let term_size = term_size.as_ref().expect("terminal size should be set"); + printer.print( + &translate!("stty-output-rows-columns", "rows" => term_size.rows, "columns" => term_size.columns), ); } @@ -585,10 +646,9 @@ fn print_terminal_size(termios: &Termios, opts: &Options) -> nix::Result<()> { // so we get the underlying libc::termios struct to get that information. let libc_termios: nix::libc::termios = termios.clone().into(); let line = libc_termios.c_line; - print!("{}", translate!("stty-output-line", "line" => line)); + printer.print(&translate!("stty-output-line", "line" => line)); } - - println!(); + printer.flush(); Ok(()) } @@ -756,39 +816,41 @@ fn control_char_to_string(cc: nix::libc::cc_t) -> nix::Result { Ok(format!("{meta_prefix}{ctrl_prefix}{character}")) } -fn print_control_chars(termios: &Termios, opts: &Options) -> nix::Result<()> { +fn print_control_chars( + termios: &Termios, + opts: &Options, + term_size: Option<&TermSize>, +) -> nix::Result<()> { if !opts.all { // Print only control chars that differ from sane defaults - let mut printed = false; + let mut printer = WrappedPrinter::new(term_size); for (text, cc_index) in CONTROL_CHARS { let current_val = termios.control_chars[*cc_index as usize]; let sane_val = get_sane_control_char(*cc_index); if current_val != sane_val { - print!("{text} = {}; ", control_char_to_string(current_val)?); - printed = true; + printer.print(&format!( + "{text} = {};", + control_char_to_string(current_val)? + )); } } - - if printed { - println!(); - } + printer.flush(); return Ok(()); } + let mut printer = WrappedPrinter::new(term_size); for (text, cc_index) in CONTROL_CHARS { - print!( - "{text} = {}; ", + printer.print(&format!( + "{text} = {};", control_char_to_string(termios.control_chars[*cc_index as usize])? - ); + )); } - println!( - "{}", - translate!("stty-output-min-time", + printer.print(&translate!("stty-output-min-time", "min" => termios.control_chars[S::VMIN as usize], "time" => termios.control_chars[S::VTIME as usize] - ) - ); + )); + printer.flush(); Ok(()) } @@ -806,22 +868,48 @@ fn print_in_save_format(termios: &Termios) { println!(); } +/// Gets terminal size using the tiocgwinsz ioctl system call. +/// This queries the kernel for the current terminal window dimensions. +fn get_terminal_size(fd: RawFd) -> nix::Result { + let mut term_size = TermSize::default(); + unsafe { tiocgwinsz(fd, &raw mut term_size) }.map(|_| term_size) +} + fn print_settings(termios: &Termios, opts: &Options) -> nix::Result<()> { if opts.save { print_in_save_format(termios); } else { - print_terminal_size(termios, opts)?; - print_control_chars(termios, opts)?; - print_flags(termios, opts, CONTROL_FLAGS); - print_flags(termios, opts, INPUT_FLAGS); - print_flags(termios, opts, OUTPUT_FLAGS); - print_flags(termios, opts, LOCAL_FLAGS); + let device_fd = opts.file.as_raw_fd(); + let term_size = if opts.all { + Some(get_terminal_size(device_fd)?) + } else { + get_terminal_size(device_fd).ok() + }; + + let stdout_fd = stdout().as_raw_fd(); + let window_size = if device_fd == stdout_fd { + &term_size + } else { + &get_terminal_size(stdout_fd).ok() + }; + + print_terminal_size(termios, opts, window_size.as_ref(), term_size.as_ref())?; + print_control_chars(termios, opts, window_size.as_ref())?; + print_flags(termios, opts, CONTROL_FLAGS, window_size.as_ref()); + print_flags(termios, opts, INPUT_FLAGS, window_size.as_ref()); + print_flags(termios, opts, OUTPUT_FLAGS, window_size.as_ref()); + print_flags(termios, opts, LOCAL_FLAGS, window_size.as_ref()); } Ok(()) } -fn print_flags(termios: &Termios, opts: &Options, flags: &[Flag]) { - let mut printed = false; +fn print_flags( + termios: &Termios, + opts: &Options, + flags: &[Flag], + term_size: Option<&TermSize>, +) { + let mut printer = WrappedPrinter::new(term_size); for &Flag { name, flag, @@ -836,20 +924,17 @@ fn print_flags(termios: &Termios, opts: &Options, flags: &[Flag< let val = flag.is_in(termios, group); if group.is_some() { if val && (!sane || opts.all) { - print!("{name} "); - printed = true; + printer.print(name); } } else if opts.all || val != sane { if !val { - print!("-"); + printer.print(&format!("-{name}")); + continue; } - print!("{name} "); - printed = true; + printer.print(name); } } - if printed { - println!(); - } + printer.flush(); } /// Apply a single setting diff --git a/tests/by-util/test_stty.rs b/tests/by-util/test_stty.rs index f68de5daf5b..10799178f43 100644 --- a/tests/by-util/test_stty.rs +++ b/tests/by-util/test_stty.rs @@ -526,3 +526,60 @@ fn test_saved_state_with_control_chars() { .stderr_is(exp_result.stderr_str()) .code_is(exp_result.code()); } + +#[test] +#[cfg(unix)] +fn test_columns_env_wrapping() { + use std::process::Stdio; + let (path, _controller, _replica) = pty_path(); + + // Must pipe output so stty uses COLUMNS env instead of actual terminal size + for (columns, max_len) in [(20, 20), (40, 40), (50, 50)] { + let result = new_ucmd!() + .args(&["--all", "--file", &path]) + .env("COLUMNS", columns.to_string()) + .set_stdout(Stdio::piped()) + .succeeds(); + + for line in result.stdout_str().lines() { + assert!( + line.len() <= max_len, + "Line exceeds COLUMNS={columns}: '{line}'" + ); + } + } + + // Wide columns should allow longer lines + let result = new_ucmd!() + .args(&["--all", "--file", &path]) + .env("COLUMNS", "200") + .set_stdout(Stdio::piped()) + .succeeds(); + let has_long_line = result.stdout_str().lines().any(|line| line.len() > 80); + assert!( + has_long_line, + "Expected at least one line longer than 80 chars with COLUMNS=200" + ); + + // Invalid values should fall back to default + for invalid in ["invalid", "0", "-10"] { + new_ucmd!() + .args(&["--all", "--file", &path]) + .env("COLUMNS", invalid) + .set_stdout(Stdio::piped()) + .succeeds(); + } + + // Without --all flag + let result = new_ucmd!() + .args(&["--file", &path]) + .env("COLUMNS", "30") + .set_stdout(Stdio::piped()) + .succeeds(); + for line in result.stdout_str().lines() { + assert!( + line.len() <= 30, + "Line exceeds COLUMNS=30 without --all: '{line}'" + ); + } +}