stty: columns env support and integration testing (#9490)

* stty: Wrap parameters when using --all

Do the same as GNU stty when it has to prints the parameter, doing
proper text wrapping

* Adding integration tests for the COLUMNS env variable support in stty

* Adding integration tests for the COLUMNS env variable support in stty

---------

Co-authored-by: Marco Trevisan (Treviño) <mail@3v1n0.net>
This commit is contained in:
Chris Dryden
2025-12-27 00:13:33 +01:00
committed by GitHub
co-authored by Marco Trevisan
parent 7cce907c0e
commit dbfe7c56ab
2 changed files with 185 additions and 43 deletions
+128 -43
View File
@@ -542,8 +542,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(
@@ -554,7 +617,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.
@@ -568,17 +631,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),
);
}
@@ -588,10 +649,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(())
}
@@ -759,39 +819,41 @@ fn control_char_to_string(cc: nix::libc::cc_t) -> nix::Result<String> {
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(())
}
@@ -809,22 +871,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<TermSize> {
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<T: TermiosFlag>(termios: &Termios, opts: &Options, flags: &[Flag<T>]) {
let mut printed = false;
fn print_flags<T: TermiosFlag>(
termios: &Termios,
opts: &Options,
flags: &[Flag<T>],
term_size: Option<&TermSize>,
) {
let mut printer = WrappedPrinter::new(term_size);
for &Flag {
name,
flag,
@@ -839,20 +927,17 @@ fn print_flags<T: TermiosFlag>(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
+57
View File
@@ -556,3 +556,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}'"
);
}
}