diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63bff7f09..14ec62343 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,35 +33,25 @@ There are several ways to contribute to posixutils-rs: Small, lightweight utility with command line processing, core algorithm, and zero external crate dependencies. 3. "only std" When an external crate is required, avoid mega-crates. Prefer - std-only, or, tiny crates such as `atty` that perform a single, + std-only, or, tiny crates such as `tempfile` that perform a single, lightweight function. 4. Correctness, readability, performance, in that order. Code should be readable by unfamiliar developers. Avoid dense, uncommented code. +5. Write small functions. Break up large functions. + The compiler can inline as needed. ### Testing, POSIX compliance and programmatic goals -* All utilities should have tests. -* Use plib's TestPlan framework for integration tests. -* Test pattern: - 1. Pre-generate input data files. - 2. Run system OS utility on input data files, - generating known-good output. - 3. Store input and output in-tree, as known-good data. - 4. Write a TestPlan that executes our utility, using - static input data, and compares output with - static output data. -* Only "quick" tests should be run automatically in `cargo test` -* Longer tests, or tests requiring root access, should be triggered - via special environment variables. * POSIX compliance -* Support the most widely used GNU/BSD extensions -* If a system has an OS-specific feature that _must_ be - exposed through a given utility, do so. -* Race-free userland. See `ftw` internal crate. +* Full integration test coverage +* Support widely used GNU/BSD extensions +* Race-free userland. (See `ftw` internal crate.) * Push small crates out: Create tiny, light-dep crates from common functionality (such as Lex or Yacc file parsing), and publish via cargo. Remove from main posixutils tree and set of crates. +* If a system has an OS-specific feature that _must_ be + exposed through a given utility, do so. ### Testing and Bug Reporting: Info to provide in GH issue @@ -70,3 +60,17 @@ There are several ways to contribute to posixutils-rs: * Provide any error output from the utility. * Describe expected results: What did you expect to happen, and did not? +### Writing tests + +* Test pattern: + 1. Pre-generate input data files. + 2. Run system OS utility on input data files, + generating known-good output. + 3. Store input and output in-tree, as known-good data. + 4. Write a TestPlan that executes our utility, using + static input data, and compares output with + static output data (OS reference data). +* Use plib's TestPlan framework for integration tests. +* Only "quick" tests should be run automatically in `cargo test` +* Longer tests, or tests requiring root access, should be triggered + via special environment variables. diff --git a/README.md b/README.md index bad7d99c9..da6753fe2 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Because it is a FAQ, the major differences between this project and uutils are: ## Stage 3 - Test coverage - [x] ar (Development) + - [x] asa - [x] awk - [x] basename - [x] bc @@ -70,6 +71,7 @@ Because it is a FAQ, the major differences between this project and uutils are: - [x] cut - [x] diff - [x] dirname + - [x] echo - [x] ex (Editors) - [x] expand - [x] expr @@ -97,6 +99,7 @@ Because it is a FAQ, the major differences between this project and uutils are: - [x] paste - [x] pax - [x] pr + - [x] printf - [x] readlink - [x] realpath - [x] rm @@ -108,6 +111,7 @@ Because it is a FAQ, the major differences between this project and uutils are: - [x] strings - [x] strip (Development) - [x] tail + - [x] test - [x] time - [x] timeout - [x] tr @@ -158,22 +162,18 @@ Because it is a FAQ, the major differences between this project and uutils are: ## Stage 1 - Rough draft - - [x] asa - [x] cal - [x] df - [x] du - - [x] echo - [x] dd - [x] getconf - [x] id - [x] ipcs (IPC) - [x] kill - [x] logger - - [x] printf - [x] ps - [x] stty - [x] tabs - - [x] test - [x] tput - [x] tsort - [x] who diff --git a/clippy.toml b/clippy.toml index 5729b3f05..a65bcc438 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1 @@ -msrv = "1.80.0" +msrv = "1.84.0" diff --git a/display/echo.rs b/display/echo.rs index 2b04e9874..61c9d8ea7 100644 --- a/display/echo.rs +++ b/display/echo.rs @@ -6,65 +6,104 @@ // file in the root directory of this project. // SPDX-License-Identifier: MIT // -// TODO: -// - echo needs to translate backslash-escaped octal numbers: -// ``` -// \0num -// Write an 8-bit value that is the 0, 1, 2 or 3-digit octal number _num_. -// use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; use std::io::{self, Write}; -fn translate_str(skip_nl: bool, s: &str) -> String { - let mut output = String::with_capacity(s.len()); - - let mut in_bs = false; +fn translate_str(skip_nl: bool, s: &str) -> Vec { + let mut output = Vec::with_capacity(s.len()); let mut nl = true; - for ch in s.chars() { + let chars: Vec = s.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; if ch == '\\' { - in_bs = true; - } else if in_bs { - in_bs = false; - match ch { + if i + 1 >= chars.len() { + // Trailing backslash - preserve it + output.push(b'\\'); + i += 1; + continue; + } + + let next = chars[i + 1]; + match next { 'a' => { - output.push('\x07'); + output.push(0x07); + i += 2; } 'b' => { - output.push('\x08'); + output.push(0x08); + i += 2; } 'c' => { nl = false; break; } 'f' => { - output.push('\x12'); + output.push(0x0c); + i += 2; } 'n' => { - output.push('\n'); + output.push(b'\n'); + i += 2; } 'r' => { - output.push('\r'); + output.push(b'\r'); + i += 2; } 't' => { - output.push('\t'); + output.push(b'\t'); + i += 2; } 'v' => { - output.push('\x11'); + output.push(0x0b); + i += 2; } '\\' => { - output.push('\\'); + output.push(b'\\'); + i += 2; + } + '0' => { + // Octal escape: \0num where num is 0-3 octal digits + i += 2; // Skip \0 + let mut octal_value: u8 = 0; + let mut digits = 0; + + while digits < 3 && i < chars.len() { + let digit = chars[i]; + if ('0'..='7').contains(&digit) { + octal_value = octal_value * 8 + (digit as u8 - b'0'); + i += 1; + digits += 1; + } else { + break; + } + } + // Push raw byte value directly - octal escapes produce single bytes + output.push(octal_value); + } + _ => { + // Unknown escape - preserve the character after backslash + // Encode the char as UTF-8 bytes + let mut buf = [0u8; 4]; + let encoded = next.encode_utf8(&mut buf); + output.extend_from_slice(encoded.as_bytes()); + i += 2; } - _ => {} } } else { - output.push(ch); + // Encode the char as UTF-8 bytes + let mut buf = [0u8; 4]; + let encoded = ch.encode_utf8(&mut buf); + output.extend_from_slice(encoded.as_bytes()); + i += 1; } } if nl && !skip_nl { - output.push('\n'); + output.push(b'\n'); } output @@ -87,9 +126,9 @@ fn main() -> Result<(), Box> { } }; - let echo_str = translate_str(skip_nl, &args.join(" ")); + let echo_bytes = translate_str(skip_nl, &args.join(" ")); - io::stdout().write_all(echo_str.as_bytes())?; + io::stdout().write_all(&echo_bytes)?; Ok(()) } diff --git a/display/more.rs b/display/more.rs index f144e69b1..aee9a0a18 100644 --- a/display/more.rs +++ b/display/more.rs @@ -1652,7 +1652,7 @@ fn compile_regex(pattern: String, ignore_case: bool) -> Result struct MoreControl { /// Program arguments args: Args, - /// Terminal for displaying content in interactive session + /// Terminal for displaying content in interactive session terminal: Option, /// Context of reading current [`Source`] context: SourceContext, @@ -1674,6 +1674,8 @@ struct MoreControl { is_new_file: bool, /// Last search has succeess match is_matched: bool, + /// Buffered stdin content for '-' operand (stdin can only be read once) + stdin_buffer: Option, } impl MoreControl { @@ -1682,20 +1684,46 @@ impl MoreControl { let terminal = Terminal::new(args.test, args.lines, args.plain).ok(); let mut current_position = None; let mut file_pathes = vec![]; + // Buffer stdin if '-' appears in file list (stdin can only be read once) + let stdin_buffer: Option = if args.input_files.iter().any(|f| f == "-") { + let mut buf = String::new(); + use std::io::Read; + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|_| MoreError::InputRead)?; + Some(buf) + } else { + None + }; + let source = if args.input_files.is_empty() || (args.input_files.len() == 1 && args.input_files[0] == *"-") { - let Some(Ok(buf)) = BufReader::new(std::io::stdin().lock()).lines().next() else { - return Err(MoreError::InputRead); - }; - Source::Buffer(Cursor::new(buf)) + if let Some(buf) = stdin_buffer.clone() { + Source::Buffer(Cursor::new(buf)) + } else { + let Some(Ok(buf)) = BufReader::new(std::io::stdin().lock()).lines().next() else { + return Err(MoreError::InputRead); + }; + Source::Buffer(Cursor::new(buf)) + } } else { for file_string in &args.input_files { - let path = to_path(file_string.clone())?; - file_pathes.push(path); + if file_string == "-" { + // Use a special path marker for stdin entries + file_pathes.push(PathBuf::from("-")); + } else { + let path = to_path(file_string.clone())?; + file_pathes.push(path); + } } current_position = Some(0); - Source::File(file_pathes[0].clone()) + let first_file = &file_pathes[0]; + if first_file == &PathBuf::from("-") { + Source::Buffer(Cursor::new(stdin_buffer.clone().unwrap_or_default())) + } else { + Source::File(first_file.clone()) + } }; let size = terminal .as_ref() @@ -1720,6 +1748,7 @@ impl MoreControl { file_pathes, is_new_file: false, is_matched: false, + stdin_buffer, }) } @@ -1740,23 +1769,41 @@ impl MoreControl { } } else { for file_path in &input_files { + // Handle '-' as stdin + let source = if *file_path == PathBuf::from("-") { + let buf = self.stdin_buffer.clone().unwrap_or_default(); + Source::Buffer(Cursor::new(buf)) + } else { + Source::File(file_path.clone()) + }; + let Ok(_) = self .context - .set_source(Source::File(file_path.clone())) + .set_source(source) .inspect_err(|e| self.handle_error(e.clone())) else { return; }; if input_files.len() > 1 { - let Ok(header) = format_file_header( - file_path.clone(), - self.context.terminal_size.map(|ts| ts.1), - ) - .inspect_err(|e| self.handle_error(e.clone())) else { - return; - }; - for line in header { - println!("{line}"); + // Format header differently for stdin vs file + if *file_path == PathBuf::from("-") { + const STDIN_LABEL: &str = "(standard input)"; + let header_width = STDIN_LABEL.len() + 2; // 2 for border padding + let border = ":".repeat(header_width); + println!("{border}"); + println!("{STDIN_LABEL}"); + println!("{border}"); + } else { + let Ok(header) = format_file_header( + file_path.clone(), + self.context.terminal_size.map(|ts| ts.1), + ) + .inspect_err(|e| self.handle_error(e.clone())) else { + return; + }; + for line in header { + println!("{line}"); + } } } @@ -2182,6 +2229,13 @@ impl MoreControl { let _ = self.context.set_source(self.context.last_source.clone()); self.last_position = None; } + } else if file_string == "-" { + // Handle stdin as a source using the buffered content + self.context.goto_eof(None); + let _ = self.context.update_screen(); + let buf = self.stdin_buffer.clone().unwrap_or_default(); + self.context.set_source(Source::Buffer(Cursor::new(buf)))?; + self.last_position = self.current_position; } else { self.context.goto_eof(None); let _ = self.context.update_screen(); @@ -2922,6 +2976,31 @@ pub fn commands_usage() -> String { buf } +/// Parse arguments with MORE environment variable support. +/// POSIX specifies: "more $MORE options operands" +/// Command line options override those from MORE. +fn parse_args_with_more_env() -> Args { + // Get the MORE environment variable + if let Ok(more_env) = std::env::var("MORE") { + // Parse MORE variable into args + let more_args: Vec = more_env.split_whitespace().map(String::from).collect(); + + if !more_args.is_empty() { + // Get actual command line args (skip program name) + let cmd_args: Vec = std::env::args().collect(); + + // Build combined args: program name, MORE args, then command line args + let mut combined_args = vec![cmd_args[0].clone()]; + combined_args.extend(more_args); + combined_args.extend(cmd_args.into_iter().skip(1)); + + return Args::parse_from(combined_args); + } + } + + Args::parse() +} + fn main() { let _ = setlocale( LocaleCategory::LcAll, @@ -2930,7 +3009,7 @@ fn main() { let _ = textdomain(PROJECT_NAME); let _ = bind_textdomain_codeset(PROJECT_NAME, "UTF-8"); - let args = Args::parse(); + let args = parse_args_with_more_env(); match MoreControl::new(args) { Ok(mut ctl) => { if ctl.terminal.is_none() { diff --git a/display/printf.rs b/display/printf.rs index bb9112952..c93585120 100644 --- a/display/printf.rs +++ b/display/printf.rs @@ -6,10 +6,6 @@ // file in the root directory of this project. // SPDX-License-Identifier: MIT // -// TODO: -// - floating point support (a, A, e, E, f, F, g, and G conversion specifiers) -// - fix bug: zero padding does not work for negative numbers -// use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; use std::{ @@ -328,74 +324,621 @@ fn tokenize_format_str(format: &[u8]) -> Result, Box> { Ok(tokens) } -fn format_arg_uint(conv: &ConvSpec, arg: usize) -> Vec { - format_arg_string(conv, arg.to_string().as_bytes()) +/// Format an integer with all POSIX flags applied +fn format_integer(conv: &ConvSpec, value: i64, is_signed: bool, base: u32, upper: bool) -> Vec { + let is_negative = value < 0; + let abs_value = value.unsigned_abs(); + + // Convert to string in the appropriate base + let digits = match base { + 8 => format!("{abs_value:o}"), + 16 if upper => format!("{abs_value:X}"), + 16 => format!("{abs_value:x}"), + _ => abs_value.to_string(), + }; + + // Determine sign/prefix + let sign_char = if is_negative { + Some('-') + } else if is_signed && conv.sign { + Some('+') + } else if is_signed && conv.space { + Some(' ') + } else { + None + }; + + // Alternate form prefix (#) + let prefix = if conv.alt_form && abs_value != 0 { + match base { + 8 => "0", + 16 if upper => "0X", + 16 => "0x", + _ => "", + } + } else { + "" + }; + + // Apply precision (minimum digits, zero-padded) + let digits_with_precision = if let Some(prec) = conv.precision { + if digits.len() < prec { + format!("{:0>width$}", digits, width = prec) + } else { + digits + } + } else { + digits + }; + + // Calculate total content length + let sign_len = if sign_char.is_some() { 1 } else { 0 }; + let content_len = sign_len + prefix.len() + digits_with_precision.len(); + + // Apply width + let width = conv.width.unwrap_or(0); + + let mut output = Vec::with_capacity(width.max(content_len)); + + if width > content_len { + let padding = width - content_len; + + if conv.left_justify { + // Left justify: content then spaces + if let Some(s) = sign_char { + output.push(s as u8); + } + output.extend_from_slice(prefix.as_bytes()); + output.extend_from_slice(digits_with_precision.as_bytes()); + output.resize(output.len() + padding, b' '); + } else if conv.zero_pad && conv.precision.is_none() { + // Zero pad: sign/prefix, zeros, digits + if let Some(s) = sign_char { + output.push(s as u8); + } + output.extend_from_slice(prefix.as_bytes()); + output.resize(output.len() + padding, b'0'); + output.extend_from_slice(digits_with_precision.as_bytes()); + } else { + // Right justify with spaces + output.resize(output.len() + padding, b' '); + if let Some(s) = sign_char { + output.push(s as u8); + } + output.extend_from_slice(prefix.as_bytes()); + output.extend_from_slice(digits_with_precision.as_bytes()); + } + } else { + // No padding needed + if let Some(s) = sign_char { + output.push(s as u8); + } + output.extend_from_slice(prefix.as_bytes()); + output.extend_from_slice(digits_with_precision.as_bytes()); + } + + output +} + +/// Parse a floating point argument according to POSIX rules +fn parse_float_arg(arg: &[u8]) -> Result<(f64, bool), String> { + if arg.is_empty() { + return Ok((0.0, true)); + } + + let arg_str = match std::str::from_utf8(arg) { + Ok(s) => s, + Err(_) => return Err("invalid UTF-8".to_string()), + }; + + let arg_str = arg_str.trim(); + + if arg_str.is_empty() { + return Ok((0.0, true)); + } + + // Check for character constant: 'c or "c + if (arg_str.starts_with('\'') || arg_str.starts_with('"')) && arg_str.len() >= 2 { + let ch = arg_str.chars().nth(1).unwrap(); + return Ok((ch as u32 as f64, true)); + } + + // Parse as float + match arg_str.parse::() { + Ok(n) => Ok((n, true)), + Err(_) => { + // Try to parse as much as we can + let mut end = 0; + let chars: Vec = arg_str.chars().collect(); + + // Optional sign + if !chars.is_empty() && (chars[0] == '+' || chars[0] == '-') { + end = 1; + } + + // Digits before decimal + while end < chars.len() && chars[end].is_ascii_digit() { + end += 1; + } + + // Optional decimal point and digits after + if end < chars.len() && chars[end] == '.' { + end += 1; + while end < chars.len() && chars[end].is_ascii_digit() { + end += 1; + } + } + + // Optional exponent + if end < chars.len() && (chars[end] == 'e' || chars[end] == 'E') { + let exp_start = end; + end += 1; + if end < chars.len() && (chars[end] == '+' || chars[end] == '-') { + end += 1; + } + let had_exp_digits = end < chars.len() && chars[end].is_ascii_digit(); + while end < chars.len() && chars[end].is_ascii_digit() { + end += 1; + } + if !had_exp_digits { + end = exp_start; // Backtrack if no exponent digits + } + } + + if end == 0 { + return Err(format!("invalid floating point number: {arg_str}")); + } + + let parsed: &str = &arg_str[..end]; + match parsed.parse::() { + Ok(n) => Ok((n, end == arg_str.len())), + Err(_) => Err(format!("invalid floating point number: {arg_str}")), + } + } + } +} + +/// Format a floating point number in scientific notation with proper exponent formatting +fn format_scientific(value: f64, precision: usize, upper: bool) -> String { + if value.is_nan() { + return if upper { + "NAN".to_string() + } else { + "nan".to_string() + }; + } + if value.is_infinite() { + return if upper { + "INF".to_string() + } else { + "inf".to_string() + }; + } + + // Use Rust's formatting then fix the exponent + let formatted = if upper { + format!("{:.prec$E}", value, prec = precision) + } else { + format!("{:.prec$e}", value, prec = precision) + }; + + // Fix exponent to have at least 2 digits with sign + fix_exponent(&formatted, upper) } -fn format_arg_octal(conv: &ConvSpec, arg: usize) -> Vec { - format_arg_string(conv, format!("{arg:o}").as_bytes()) +/// Fix exponent format to have sign and at least 2 digits +fn fix_exponent(s: &str, upper: bool) -> String { + let exp_char = if upper { 'E' } else { 'e' }; + if let Some(pos) = s.find(exp_char) { + let (mantissa, exp_part) = s.split_at(pos); + let exp_str = &exp_part[1..]; // Skip 'e' or 'E' + + let (sign, digits) = if let Some(stripped) = exp_str.strip_prefix('-') { + ('-', stripped) + } else if let Some(stripped) = exp_str.strip_prefix('+') { + ('+', stripped) + } else { + ('+', exp_str) + }; + + // Pad to at least 2 digits + let padded = if digits.len() < 2 { + format!("{:0>2}", digits) + } else { + digits.to_string() + }; + + format!("{}{}{}{}", mantissa, exp_char, sign, padded) + } else { + s.to_string() + } } -fn format_arg_hex(conv: &ConvSpec, arg: usize, upper: bool) -> Vec { - let st = if upper { - format!("{arg:X}") +/// Format a floating point number in general format (%g/%G) +fn format_general(value: f64, precision: usize, upper: bool, alt_form: bool) -> String { + if value.is_nan() { + return if upper { + "NAN".to_string() + } else { + "nan".to_string() + }; + } + if value.is_infinite() { + return if upper { + "INF".to_string() + } else { + "inf".to_string() + }; + } + + // %g uses precision as "significant figures", minimum 1 + let sig_figs = precision.max(1); + + // Get the exponent + let exp = if value == 0.0 { + 0 + } else { + value.abs().log10().floor() as i32 + }; + + // Use %e if exponent < -4 or >= precision, else %f style + let result = if exp < -4 || exp >= sig_figs as i32 { + // Use scientific notation with sig_figs - 1 decimal places + let e_prec = sig_figs.saturating_sub(1); + format_scientific(value, e_prec, upper) } else { - format!("{arg:x}") + // Use fixed notation + // Number of decimal places = sig_figs - 1 - exp (for positive exp) + // or sig_figs - 1 + |exp| (for negative exp) + let decimal_places = if exp >= 0 { + sig_figs.saturating_sub(1).saturating_sub(exp as usize) + } else { + sig_figs.saturating_sub(1) + (-exp) as usize + }; + format!("{:.prec$}", value, prec = decimal_places) }; - format_arg_string(conv, st.as_bytes()) + // Remove trailing zeros (unless # flag) + if !alt_form { + remove_trailing_zeros(&result) + } else { + result + } } -fn format_arg_uint_base(conv: &ConvSpec, arg: &[u8]) -> Result, Box> { - let arg = { - if arg.is_empty() { - 0_usize +/// Format a floating point number in hexadecimal format (%a/%A) +fn format_hex_float(value: f64, precision: usize, upper: bool) -> String { + if value.is_nan() { + return if upper { + "NAN".to_string() + } else { + "nan".to_string() + }; + } + if value.is_infinite() { + return if upper { + "INF".to_string() + } else { + "inf".to_string() + }; + } + if value == 0.0 { + let zeros = if precision > 0 { + format!(".{:0> 52) & 0x7FF) as i32; + let mantissa = bits & 0xFFFFFFFFFFFFF; + + let (exponent, leading_digit) = if exp_bits == 0 { + // Denormalized + (-1022, 0) + } else { + (exp_bits - 1023, 1) + }; + + // Format mantissa as hex + let mantissa_hex = format!("{:013x}", mantissa); + let trimmed = mantissa_hex.trim_end_matches('0'); + let frac_part = if trimmed.is_empty() { + if precision > 0 { + format!(".{:0 0 { + format!( + ".{:0() { - Ok(n) => n, - Err(_) => { - eprintln!("printf: invalid unsigned integer: {arg_str}"); + let exp_sign = if exponent >= 0 { '+' } else { '-' }; + let exp_abs = exponent.abs(); - 0_usize + if upper { + format!("0X{}{frac_part}P{exp_sign}{exp_abs}", leading_digit) + } else { + format!("0x{}{frac_part}p{exp_sign}{exp_abs}", leading_digit) + } +} + +/// Format a floating point number with all POSIX flags applied +fn format_float(conv: &ConvSpec, value: f64) -> Vec { + let is_negative = value.is_sign_negative() && !value.is_nan(); + let abs_value = value.abs(); + + // Default precision is 6 + let precision = conv.precision.unwrap_or(6); + + // Format the number according to specifier + let formatted = match conv.spec { + 'f' | 'F' => { + if conv.spec == 'F' && (abs_value.is_infinite() || abs_value.is_nan()) { + if abs_value.is_infinite() { + "INF".to_string() + } else { + "NAN".to_string() } + } else { + format!("{:.prec$}", abs_value, prec = precision) } } + 'e' => format_scientific(abs_value, precision, false), + 'E' => format_scientific(abs_value, precision, true), + 'g' => format_general(abs_value, precision, false, conv.alt_form), + 'G' => format_general(abs_value, precision, true, conv.alt_form), + 'a' => format_hex_float(abs_value, precision, false), + 'A' => format_hex_float(abs_value, precision, true), + _ => format!("{:.prec$}", abs_value, prec = precision), }; - let formatted = match conv.spec { - 'u' => format_arg_uint(conv, arg), - 'o' => format_arg_octal(conv, arg), - 'x' => format_arg_hex(conv, arg, false), - 'X' => format_arg_hex(conv, arg, true), - ch => { - panic!("printf: BUG: invalid conversion specifier: {ch}"); + // Determine sign + let sign_char = if is_negative { + Some('-') + } else if conv.sign { + Some('+') + } else if conv.space { + Some(' ') + } else { + None + }; + + // Calculate total content length + let sign_len = if sign_char.is_some() { 1 } else { 0 }; + let content_len = sign_len + formatted.len(); + + // Apply width + let width = conv.width.unwrap_or(0); + + let mut output = Vec::with_capacity(width.max(content_len)); + + if width > content_len { + let padding = width - content_len; + + if conv.left_justify { + if let Some(s) = sign_char { + output.push(s as u8); + } + output.extend_from_slice(formatted.as_bytes()); + output.resize(output.len() + padding, b' '); + } else if conv.zero_pad { + if let Some(s) = sign_char { + output.push(s as u8); + } + output.resize(output.len() + padding, b'0'); + output.extend_from_slice(formatted.as_bytes()); + } else { + output.resize(output.len() + padding, b' '); + if let Some(s) = sign_char { + output.push(s as u8); + } + output.extend_from_slice(formatted.as_bytes()); + } + } else { + if let Some(s) = sign_char { + output.push(s as u8); + } + output.extend_from_slice(formatted.as_bytes()); + } + + output +} + +/// Remove trailing zeros after decimal point +fn remove_trailing_zeros(s: &str) -> String { + if s.contains('.') { + // Find 'e' or 'E' for exponent + let exp_pos = s.find(['e', 'E']); + let (num_part, exp_part) = match exp_pos { + Some(pos) => (&s[..pos], &s[pos..]), + None => (s, ""), + }; + + let trimmed = num_part.trim_end_matches('0'); + let trimmed = trimmed.strip_suffix('.').unwrap_or(trimmed); + + format!("{}{}", trimmed, exp_part) + } else { + s.to_string() + } +} + +fn format_arg_float(conv: &ConvSpec, arg: &[u8]) -> Result<(Vec, bool), Box> { + let mut had_error = false; + let value = match parse_float_arg(arg) { + Ok((n, fully_consumed)) => { + if !fully_consumed { + let arg_str = std::str::from_utf8(arg).unwrap_or(""); + eprintln!("printf: {arg_str}: not completely converted"); + had_error = true; + } + n + } + Err(e) => { + let arg_str = std::str::from_utf8(arg).unwrap_or(""); + eprintln!("printf: {arg_str}: {e}"); + had_error = true; + 0.0 } }; - Ok(formatted) + Ok((format_float(conv, value), had_error)) } -fn format_arg_int(conv: &ConvSpec, arg: &[u8]) -> Result, Box> { - let arg = { - if arg.is_empty() { - 0_isize - } else { - let arg_str = std::str::from_utf8(arg)?; +/// Parse an integer argument according to POSIX rules: +/// - Leading + or - allowed +/// - 0x or 0X prefix for hexadecimal +/// - 0 prefix for octal +/// - 'c or "c for character constant (value of character c) +fn parse_integer_arg(arg: &[u8]) -> Result<(i64, bool), String> { + if arg.is_empty() { + return Ok((0, true)); + } + + let arg_str = match std::str::from_utf8(arg) { + Ok(s) => s, + Err(_) => return Err("invalid UTF-8".to_string()), + }; + + let arg_str = arg_str.trim(); + + if arg_str.is_empty() { + return Ok((0, true)); + } + + // Check for character constant: 'c or "c + if (arg_str.starts_with('\'') || arg_str.starts_with('"')) && arg_str.len() >= 2 { + let ch = arg_str.chars().nth(1).unwrap(); + return Ok((ch as i64, true)); + } - match arg_str.parse::() { - Ok(n) => n, - Err(_) => { - eprintln!("printf: invalid integer: {arg_str}"); + // Handle sign + let (is_negative, num_str) = if let Some(rest) = arg_str.strip_prefix('-') { + (true, rest) + } else if let Some(rest) = arg_str.strip_prefix('+') { + (false, rest) + } else { + (false, arg_str) + }; - 0_isize + // Parse the number + let (value, fully_consumed) = if let Some(hex) = num_str + .strip_prefix("0x") + .or_else(|| num_str.strip_prefix("0X")) + { + // Hexadecimal + match i64::from_str_radix(hex, 16) { + Ok(n) => (n, true), + Err(_) => { + // Try to parse as much as we can + let valid_len = hex.chars().take_while(|c| c.is_ascii_hexdigit()).count(); + if valid_len == 0 { + return Err(format!("invalid number: {arg_str}")); + } + match i64::from_str_radix(&hex[..valid_len], 16) { + Ok(n) => (n, valid_len == hex.len()), + Err(_) => return Err(format!("invalid number: {arg_str}")), } } } + } else if num_str.starts_with('0') + && num_str.len() > 1 + && num_str + .chars() + .nth(1) + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { + // Octal + let valid_len = num_str + .chars() + .take_while(|c| ('0'..='7').contains(c)) + .count(); + match i64::from_str_radix(&num_str[..valid_len], 8) { + Ok(n) => (n, valid_len == num_str.len()), + Err(_) => return Err(format!("invalid octal number: {arg_str}")), + } + } else { + // Decimal + let valid_len = num_str.chars().take_while(|c| c.is_ascii_digit()).count(); + if valid_len == 0 { + return Err(format!("invalid number: {arg_str}")); + } + match num_str[..valid_len].parse::() { + Ok(n) => (n, valid_len == num_str.len()), + Err(_) => return Err(format!("invalid number: {arg_str}")), + } }; - Ok(format_arg_string(conv, arg.to_string().as_bytes())) + let result = if is_negative { -value } else { value }; + Ok((result, fully_consumed)) +} + +fn format_arg_uint_base(conv: &ConvSpec, arg: &[u8]) -> Result<(Vec, bool), Box> { + let mut had_error = false; + let value = match parse_integer_arg(arg) { + Ok((n, fully_consumed)) => { + if !fully_consumed { + let arg_str = std::str::from_utf8(arg).unwrap_or(""); + eprintln!("printf: {arg_str}: not completely converted"); + had_error = true; + } + n + } + Err(e) => { + let arg_str = std::str::from_utf8(arg).unwrap_or(""); + eprintln!("printf: {arg_str}: {e}"); + had_error = true; + 0 + } + }; + + let formatted = match conv.spec { + 'u' => format_integer(conv, value.unsigned_abs() as i64, false, 10, false), + 'o' => format_integer(conv, value.unsigned_abs() as i64, false, 8, false), + 'x' => format_integer(conv, value.unsigned_abs() as i64, false, 16, false), + 'X' => format_integer(conv, value.unsigned_abs() as i64, false, 16, true), + ch => { + panic!("printf: BUG: invalid conversion specifier: {ch}"); + } + }; + + Ok((formatted, had_error)) +} + +fn format_arg_int(conv: &ConvSpec, arg: &[u8]) -> Result<(Vec, bool), Box> { + let mut had_error = false; + let value = match parse_integer_arg(arg) { + Ok((n, fully_consumed)) => { + if !fully_consumed { + let arg_str = std::str::from_utf8(arg).unwrap_or(""); + eprintln!("printf: {arg_str}: not completely converted"); + had_error = true; + } + n + } + Err(e) => { + let arg_str = std::str::from_utf8(arg).unwrap_or(""); + eprintln!("printf: {arg_str}: {e}"); + had_error = true; + 0 + } + }; + + Ok((format_integer(conv, value, true, 10, false), had_error)) } fn format_arg_char(conv: &ConvSpec, arg: &[u8]) -> Vec { @@ -404,88 +947,228 @@ fn format_arg_char(conv: &ConvSpec, arg: &[u8]) -> Vec { format_arg_string(conv, arg_to_use) } +/// Result of processing %b argument +enum ProcessBResult { + /// Normal bytes to output + Bytes(Vec), + /// \c was encountered - stop all output + StopOutput(Vec), +} + +/// Process backslash escapes in a %b argument +/// Returns the processed bytes and whether \c was encountered +fn process_b_escapes(arg: &[u8]) -> ProcessBResult { + let mut output = Vec::with_capacity(arg.len()); + let mut peekable = arg.iter().peekable(); + + while let Some(&byte) = peekable.next() { + if byte == b'\\' { + let Some(&next_byte) = peekable.next() else { + // Trailing backslash - preserve it + output.push(b'\\'); + continue; + }; + + match next_byte { + b'\\' => output.push(b'\\'), + b'a' => output.push(b'\x07'), + b'b' => output.push(b'\x08'), + b'c' => return ProcessBResult::StopOutput(output), + b'f' => output.push(b'\x0c'), + b'n' => output.push(b'\n'), + b'r' => output.push(b'\r'), + b't' => output.push(b'\t'), + b'v' => output.push(b'\x0b'), + b'0' => { + // Octal escape: \0ddd where ddd is 0-3 octal digits + let mut octal_value: u8 = 0; + let mut digits = 0; + + while digits < 3 { + if let Some(&&digit) = peekable.peek() { + if (b'0'..=b'7').contains(&digit) { + octal_value = + octal_value.wrapping_mul(8).wrapping_add(digit - b'0'); + peekable.next(); + digits += 1; + } else { + break; + } + } else { + break; + } + } + output.push(octal_value); + } + _ => { + // Unknown escape - output the character after backslash + output.push(next_byte); + } + } + } else { + output.push(byte); + } + } + + ProcessBResult::Bytes(output) +} + +fn format_arg_b(conv: &ConvSpec, arg: &[u8]) -> (Vec, bool) { + match process_b_escapes(arg) { + ProcessBResult::Bytes(bytes) => (format_arg_string(conv, &bytes), false), + ProcessBResult::StopOutput(bytes) => (format_arg_string(conv, &bytes), true), + } +} + fn format_arg_string(conv: &ConvSpec, arg: &[u8]) -> Vec { - let Some(width) = conv.width else { - return arg.to_vec(); + // Apply precision to limit string length + let arg = if let Some(prec) = conv.precision { + if prec < arg.len() { + &arg[..prec] + } else { + arg + } + } else { + arg }; let arg_len = arg.len(); - let padding_char = match conv.zero_pad { - true => '0', - false => ' ', + let Some(width) = conv.width else { + return arg.to_vec(); }; - let mut encoding_buffer = [0_u8; 4_usize]; + if width <= arg_len { + return arg.to_vec(); + } - let padding_char_encoded_slice = padding_char.encode_utf8(&mut encoding_buffer).as_bytes(); + // For strings, zero padding is treated as space padding + let padding_char = if conv.zero_pad && conv.spec != 's' && conv.spec != 'b' { + b'0' + } else { + b' ' + }; let mut output = Vec::::with_capacity(width); - if width > arg_len { - let conv_left_justify = conv.left_justify; - - if conv_left_justify { - output.extend_from_slice(arg); - } - - for _ in 0_usize..(width - arg_len) { - output.extend_from_slice(padding_char_encoded_slice); + if conv.left_justify { + output.extend_from_slice(arg); + for _ in 0..(width - arg_len) { + output.push(padding_char); } - - if !conv_left_justify { - output.extend_from_slice(arg); + } else { + for _ in 0..(width - arg_len) { + output.push(padding_char); } + output.extend_from_slice(arg); } output } -fn format_arg(conv: &ConvSpec, arg: &[u8]) -> Result, Box> { - let vec = match conv.spec { - 'd' | 'i' => format_arg_int(conv, arg)?, - 'u' | 'o' | 'x' | 'X' => format_arg_uint_base(conv, arg)?, - 'c' => format_arg_char(conv, arg), - 's' => format_arg_string(conv, arg), +/// Result of formatting an argument +struct FormatResult { + bytes: Vec, + stop_output: bool, + had_error: bool, +} + +fn format_arg(conv: &ConvSpec, arg: &[u8]) -> Result> { + let (bytes, stop_output, had_error) = match conv.spec { + 'd' | 'i' => { + let (bytes, had_error) = format_arg_int(conv, arg)?; + (bytes, false, had_error) + } + 'u' | 'o' | 'x' | 'X' => { + let (bytes, had_error) = format_arg_uint_base(conv, arg)?; + (bytes, false, had_error) + } + 'f' | 'F' | 'e' | 'E' | 'g' | 'G' | 'a' | 'A' => { + let (bytes, had_error) = format_arg_float(conv, arg)?; + (bytes, false, had_error) + } + 'c' => (format_arg_char(conv, arg), false, false), + 's' => (format_arg_string(conv, arg), false, false), + 'b' => { + let (bytes, stop) = format_arg_b(conv, arg); + (bytes, stop, false) + } + '%' => (vec![b'%'], false, false), ch => { eprintln!("printf: unknown conversion specifier: {ch}"); - - format_arg_string(conv, arg) + (format_arg_string(conv, arg), false, true) } }; - Ok(vec) + Ok(FormatResult { + bytes, + stop_output, + had_error, + }) } -fn do_printf<'a>( - format: &[u8], - mut arguments: impl Iterator, -) -> Result<(), Box> { +/// Returns Ok(had_error) where had_error indicates if any conversion errors occurred +fn do_printf(format: &[u8], arguments: &[&[u8]]) -> Result> { let token_vec = tokenize_format_str(format)?; let mut output = Vec::::with_capacity(format.len() * 2_usize); + let mut arg_index = 0_usize; + let mut stop_output = false; + let mut had_error = false; + + // Process format, reusing it if there are remaining arguments + loop { + let had_conversion = token_vec + .iter() + .any(|t| matches!(t, Token::Conversion(c) if c.spec != '%')); + + for token in &token_vec { + if stop_output { + break; + } - for token in token_vec { - match token { - Token::Conversion(co) => { - let arg_str = arguments.next().unwrap_or(&[]); + match token { + Token::Conversion(co) => { + // %% doesn't consume an argument + let arg_str = if co.spec == '%' { + &[][..] + } else { + let arg = arguments.get(arg_index).copied().unwrap_or(&[]); + arg_index += 1; + arg + }; - let format_arg_result = format_arg(&co, arg_str)?; + let result = format_arg(co, arg_str)?; + output.extend_from_slice(&result.bytes); - output.extend_from_slice(format_arg_result.as_slice()); - } - Token::IgnoreRestOfFormat => { - break; - } - Token::Literal(vec) => { - output.extend_from_slice(vec.as_slice()); + if result.had_error { + had_error = true; + } + + if result.stop_output { + stop_output = true; + break; + } + } + Token::IgnoreRestOfFormat => { + stop_output = true; + break; + } + Token::Literal(vec) => { + output.extend_from_slice(vec.as_slice()); + } } } + + // If we've consumed all arguments or there are no conversion specs, stop + if stop_output || arg_index >= arguments.len() || !had_conversion { + break; + } } io::stdout().write_all(output.as_slice())?; - Ok(()) + Ok(had_error) } fn main() -> ExitCode { @@ -501,20 +1184,24 @@ fn main() -> ExitCode { match args.as_slice() { &[ref _program_file_name, ref format, ref arguments @ ..] => { - let arguments_iterator = arguments.iter().map(|os| os.as_bytes()); + let arguments_vec: Vec<&[u8]> = arguments.iter().map(|os| os.as_bytes()).collect(); - match do_printf(format.as_bytes(), arguments_iterator) { - Ok(()) => ExitCode::SUCCESS, + match do_printf(format.as_bytes(), &arguments_vec) { + Ok(had_error) => { + if had_error { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } + } Err(er) => { eprint!("printf: {er}"); - ExitCode::FAILURE } } } _ => { eprintln!("printf: {}", gettext("not enough arguments")); - ExitCode::FAILURE } } diff --git a/display/tests/echo/mod.rs b/display/tests/echo/mod.rs index 2a75891fc..a1c09275e 100644 --- a/display/tests/echo/mod.rs +++ b/display/tests/echo/mod.rs @@ -7,7 +7,7 @@ // SPDX-License-Identifier: MIT // -use plib::testing::{run_test, TestPlan}; +use plib::testing::{run_test, run_test_u8, TestPlan, TestPlanU8}; fn echo_test(args: &[&str], expected_output: &str) { let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); @@ -22,10 +22,170 @@ fn echo_test(args: &[&str], expected_output: &str) { }); } +fn echo_test_bytes(args: &[&str], expected_output: &[u8]) { + let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); + + run_test_u8(TestPlanU8 { + cmd: String::from("echo"), + args: str_args, + stdin_data: Vec::new(), + expected_out: expected_output.to_vec(), + expected_err: Vec::new(), + expected_exit_code: 0, + }); +} + +#[test] +fn test_echo_no_args() { + // No arguments - only newline is written + echo_test(&[], "\n"); +} + #[test] fn test_echo_basic() { + echo_test(&["hello"], "hello\n"); echo_test(&["big", "brown", "bear"], "big brown bear\n"); + echo_test(&["hello", "world"], "hello world\n"); +} +#[test] +fn test_echo_suppress_newline_n() { + // -n as first argument suppresses newline (BSD behavior, implementation-defined) echo_test(&["-n", "foo", "bar"], "foo bar"); + echo_test(&["-n", "hello"], "hello"); + echo_test(&["-n"], ""); +} + +#[test] +fn test_echo_suppress_newline_c() { + // \c suppresses newline and ignores following characters echo_test(&["foo", "bar\\c"], "foo bar"); + echo_test(&["hello\\c", "world"], "hello"); + echo_test(&["abc\\cdef"], "abc"); +} + +#[test] +fn test_echo_escape_alert() { + // \a - alert (bell) + echo_test(&["hello\\aworld"], "hello\x07world\n"); +} + +#[test] +fn test_echo_escape_backspace() { + // \b - backspace + echo_test(&["hello\\bworld"], "hello\x08world\n"); +} + +#[test] +fn test_echo_escape_formfeed() { + // \f - form-feed + echo_test(&["hello\\fworld"], "hello\x0cworld\n"); +} + +#[test] +fn test_echo_escape_newline() { + // \n - newline + echo_test(&["hello\\nworld"], "hello\nworld\n"); +} + +#[test] +fn test_echo_escape_carriage_return() { + // \r - carriage-return + echo_test(&["hello\\rworld"], "hello\rworld\n"); +} + +#[test] +fn test_echo_escape_tab() { + // \t - tab + echo_test(&["hello\\tworld"], "hello\tworld\n"); +} + +#[test] +fn test_echo_escape_vertical_tab() { + // \v - vertical-tab + echo_test(&["hello\\vworld"], "hello\x0bworld\n"); +} + +#[test] +fn test_echo_escape_backslash() { + // \\ - literal backslash + echo_test(&["hello\\\\world"], "hello\\world\n"); +} + +#[test] +fn test_echo_octal_escape_zero() { + // \0 with no digits - NUL character + echo_test(&["hello\\0world"], "hello\x00world\n"); +} + +#[test] +fn test_echo_octal_escape_one_digit() { + // \0 followed by 1 octal digit + echo_test(&["\\07"], "\x07\n"); // bell + echo_test(&["\\01"], "\x01\n"); +} + +#[test] +fn test_echo_octal_escape_two_digits() { + // \0 followed by 2 octal digits + echo_test(&["\\012"], "\n\n"); // \012 = newline + echo_test(&["\\041"], "!\n"); // \041 = '!' +} + +#[test] +fn test_echo_octal_escape_three_digits() { + // \0 followed by 3 octal digits + echo_test(&["\\0101"], "A\n"); // \0101 = 'A' (65 decimal) + echo_test(&["\\0141"], "a\n"); // \0141 = 'a' (97 decimal) + // \0377 = 255 (max byte) - use byte comparison since 0xFF is not valid UTF-8 + echo_test_bytes(&["\\0377"], &[0xff, b'\n']); +} + +#[test] +fn test_echo_octal_non_octal_terminates() { + // Non-octal digit terminates octal sequence + echo_test(&["\\018"], "\x018\n"); // '8' is not octal, so \01 = 1, then '8' + echo_test(&["\\0789"], "\x0789\n"); // \07 = 7, '8' and '9' are literal +} + +#[test] +fn test_echo_unknown_escape() { + // Unknown escape sequences - the character after backslash is preserved + echo_test(&["hello\\xworld"], "helloxworld\n"); + echo_test(&["\\q"], "q\n"); + echo_test(&["\\1"], "1\n"); // \1 without leading 0 is unknown +} + +#[test] +fn test_echo_trailing_backslash() { + // Trailing backslash is preserved + echo_test(&["hello\\"], "hello\\\n"); +} + +#[test] +fn test_echo_double_dash() { + // POSIX: "--" shall be recognized as a string operand, not option terminator + echo_test(&["--"], "--\n"); + echo_test(&["--", "hello"], "-- hello\n"); + echo_test(&["hello", "--", "world"], "hello -- world\n"); +} + +#[test] +fn test_echo_multiple_escapes() { + // Multiple escape sequences in one argument + echo_test(&["\\t\\t\\t"], "\t\t\t\n"); + echo_test(&["a\\nb\\nc"], "a\nb\nc\n"); +} + +#[test] +fn test_echo_mixed_content() { + // Mix of regular text and escapes + echo_test(&["Name:\\tJohn\\nAge:\\t30"], "Name:\tJohn\nAge:\t30\n"); +} + +#[test] +fn test_echo_empty_string() { + // Empty string argument + echo_test(&[""], "\n"); + echo_test(&["", ""], " \n"); } diff --git a/display/tests/more/mod.rs b/display/tests/more/mod.rs index a1b3cc149..6d04aa081 100644 --- a/display/tests/more/mod.rs +++ b/display/tests/more/mod.rs @@ -9,7 +9,7 @@ use std::process::Output; -use plib::testing::{run_test_with_checker, TestPlan}; +use plib::testing::{run_test_with_checker, run_test_with_checker_and_env, TestPlan}; fn test_checker_more(plan: &TestPlan, output: &Output) { let stdout = String::from_utf8_lossy(&output.stdout); @@ -46,6 +46,30 @@ fn run_test_more( ); } +fn run_test_more_with_env( + args: &[&str], + stdin_data: &str, + expected_out: &str, + expected_err: &str, + expected_exit_code: i32, + env_vars: &[(&str, &str)], +) { + let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); + + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("more"), + args: str_args, + stdin_data: String::from(stdin_data), + expected_out: String::from(expected_out), + expected_err: String::from(expected_err), + expected_exit_code, + }, + env_vars, + test_checker_more, + ); +} + // base tests #[test] fn test_minus_files() { @@ -1177,3 +1201,65 @@ fn test_flags_n_tag() { 0, ); } + +// POSIX compliance tests: MORE environment variable +#[test] +fn test_more_env_variable() { + // Test that MORE environment variable is parsed and applied + // Using -s option from MORE env var + run_test_more_with_env( + &[ + "--test", + "-p", + "\":n:n:n:n:n\"", + "test_files/README.md", + "test_files/TODO.md", + "test_files/styled.txt", + ], + ":n ", + "", + "", + 0, + &[("MORE", "-s")], + ); +} + +#[test] +fn test_more_env_variable_with_n() { + // Test that MORE environment variable with -n option is parsed + run_test_more_with_env( + &[ + "--test", + "-p", + "\":n:n:n:n:n\"", + "test_files/README.md", + "test_files/TODO.md", + "test_files/styled.txt", + ], + ":n ", + "", + "", + 0, + &[("MORE", "-n 15")], + ); +} + +#[test] +fn test_more_env_variable_combined() { + // Test that MORE environment variable can combine multiple options + run_test_more_with_env( + &[ + "--test", + "-p", + "\":n:n:n:n:n\"", + "test_files/README.md", + "test_files/TODO.md", + "test_files/styled.txt", + ], + ":n ", + "", + "", + 0, + &[("MORE", "-s -c")], + ); +} diff --git a/display/tests/printf/mod.rs b/display/tests/printf/mod.rs index f04cc9602..8ef680553 100644 --- a/display/tests/printf/mod.rs +++ b/display/tests/printf/mod.rs @@ -9,50 +9,526 @@ use plib::testing::{run_test, TestPlan}; -#[test] -fn test_basic_string_output() { +fn printf_test(args: &[&str], expected_out: &str) { + let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); + run_test(TestPlan { cmd: String::from("printf"), - args: vec![String::from("Hello, %s!"), String::from("World")], - expected_out: String::from("Hello, World!"), + args: str_args, + expected_out: String::from(expected_out), expected_err: String::new(), stdin_data: String::new(), expected_exit_code: 0, }); } +// Basic string output tests +#[test] +fn test_basic_string_output() { + printf_test(&["Hello, %s!", "World"], "Hello, World!"); +} + +#[test] +fn test_string_no_args() { + printf_test(&["Hello World"], "Hello World"); +} + +#[test] +fn test_string_multiple_args() { + printf_test(&["%s %s %s", "one", "two", "three"], "one two three"); +} + +// Integer output tests #[test] fn test_integer_output() { - run_test(TestPlan { - cmd: String::from("printf"), - args: vec![String::from("The answer is %d."), String::from("42")], - expected_out: String::from("The answer is 42."), - expected_err: String::new(), - stdin_data: String::new(), - expected_exit_code: 0, - }); + printf_test(&["The answer is %d.", "42"], "The answer is 42."); } +#[test] +fn test_integer_negative() { + printf_test(&["%d", "-42"], "-42"); +} + +#[test] +fn test_integer_positive_sign() { + printf_test(&["%d", "+42"], "42"); +} + +#[test] +fn test_integer_i_specifier() { + printf_test(&["%i", "123"], "123"); +} + +// Octal output tests #[test] fn test_octal_output() { - run_test(TestPlan { - cmd: String::from("printf"), - args: vec![String::from("%o"), String::from("8")], - expected_out: String::from("10"), - expected_err: String::new(), - stdin_data: String::new(), - expected_exit_code: 0, - }); + printf_test(&["%o", "8"], "10"); } +#[test] +fn test_octal_output_64() { + printf_test(&["%o", "64"], "100"); +} + +// Hex output tests #[test] fn test_hex_output() { - run_test(TestPlan { - cmd: String::from("printf"), - args: vec![String::from("%x"), String::from("255")], - expected_out: String::from("ff"), - expected_err: String::new(), - stdin_data: String::new(), - expected_exit_code: 0, - }); + printf_test(&["%x", "255"], "ff"); +} + +#[test] +fn test_hex_uppercase() { + printf_test(&["%X", "255"], "FF"); +} + +// Unsigned integer tests +#[test] +fn test_unsigned_output() { + printf_test(&["%u", "42"], "42"); +} + +// Character output tests +#[test] +fn test_char_output() { + printf_test(&["%c", "A"], "A"); +} + +#[test] +fn test_char_from_string() { + printf_test(&["%c", "Hello"], "H"); +} + +// %b specifier tests (backslash escapes in argument) +#[test] +fn test_b_specifier_basic() { + printf_test(&["%b", "hello"], "hello"); +} + +#[test] +fn test_b_specifier_newline() { + printf_test(&["%b", "hello\\nworld"], "hello\nworld"); +} + +#[test] +fn test_b_specifier_tab() { + printf_test(&["%b", "hello\\tworld"], "hello\tworld"); +} + +#[test] +fn test_b_specifier_backslash() { + printf_test(&["%b", "hello\\\\world"], "hello\\world"); +} + +#[test] +fn test_b_specifier_octal() { + printf_test(&["%b", "\\0101"], "A"); // \0101 = 65 = 'A' +} + +#[test] +fn test_b_specifier_alert() { + printf_test(&["%b", "\\a"], "\x07"); +} + +#[test] +fn test_b_specifier_backspace() { + printf_test(&["%b", "\\b"], "\x08"); +} + +#[test] +fn test_b_specifier_formfeed() { + printf_test(&["%b", "\\f"], "\x0c"); +} + +#[test] +fn test_b_specifier_carriage_return() { + printf_test(&["%b", "\\r"], "\r"); +} + +#[test] +fn test_b_specifier_vertical_tab() { + printf_test(&["%b", "\\v"], "\x0b"); +} + +// %% specifier test +#[test] +fn test_percent_literal() { + printf_test(&["100%%"], "100%"); +} + +#[test] +fn test_percent_literal_with_args() { + printf_test(&["%d%% complete", "50"], "50% complete"); +} + +// Character constant tests ('A -> 65) +#[test] +fn test_char_constant_single_quote() { + printf_test(&["%d", "'A"], "65"); +} + +#[test] +fn test_char_constant_double_quote() { + printf_test(&["%d", "\"A"], "65"); +} + +#[test] +fn test_char_constant_plus() { + printf_test(&["%d", "'+"], "43"); +} + +#[test] +fn test_char_constant_minus() { + printf_test(&["%d", "'-"], "45"); +} + +// Hex/Octal integer argument parsing +#[test] +fn test_hex_arg_parsing() { + printf_test(&["%d", "0x10"], "16"); +} + +#[test] +fn test_hex_arg_parsing_uppercase() { + printf_test(&["%d", "0X1F"], "31"); +} + +#[test] +fn test_octal_arg_parsing() { + printf_test(&["%d", "010"], "8"); +} + +#[test] +fn test_octal_arg_parsing_larger() { + printf_test(&["%d", "0100"], "64"); +} + +// Format reuse tests +#[test] +fn test_format_reuse() { + printf_test(&["%d\n", "1", "2", "3"], "1\n2\n3\n"); +} + +#[test] +fn test_format_reuse_string() { + printf_test(&["%s ", "a", "b", "c"], "a b c "); +} + +// Width and precision tests +#[test] +fn test_width_right_justify() { + printf_test(&["%5s", "abc"], " abc"); +} + +#[test] +fn test_width_left_justify() { + printf_test(&["%-5s", "abc"], "abc "); +} + +#[test] +fn test_width_integer() { + printf_test(&["%5d", "42"], " 42"); +} + +#[test] +fn test_precision_string() { + printf_test(&["%.3s", "hello"], "hel"); +} + +#[test] +fn test_precision_string_longer() { + printf_test(&["%.10s", "hello"], "hello"); +} + +#[test] +fn test_width_and_precision() { + printf_test(&["%10.3s", "hello"], " hel"); +} + +// Escape sequences in format string +#[test] +fn test_format_escape_newline() { + printf_test(&["hello\\nworld"], "hello\nworld"); +} + +#[test] +fn test_format_escape_tab() { + printf_test(&["hello\\tworld"], "hello\tworld"); +} + +#[test] +fn test_format_escape_backslash() { + printf_test(&["hello\\\\world"], "hello\\world"); +} + +#[test] +fn test_format_escape_octal() { + printf_test(&["\\101"], "A"); // \101 = 65 = 'A' +} + +#[test] +fn test_format_escape_alert() { + printf_test(&["\\a"], "\x07"); +} + +#[test] +fn test_format_escape_backspace() { + printf_test(&["\\b"], "\x08"); +} + +#[test] +fn test_format_escape_formfeed() { + printf_test(&["\\f"], "\x0c"); +} + +#[test] +fn test_format_escape_carriage_return() { + printf_test(&["\\r"], "\r"); +} + +#[test] +fn test_format_escape_vertical_tab() { + printf_test(&["\\v"], "\x0b"); +} + +// Zero padding tests +#[test] +fn test_zero_padding_integer() { + printf_test(&["%05d", "42"], "00042"); +} + +#[test] +fn test_zero_padding_hex() { + printf_test(&["%04x", "15"], "000f"); +} + +// Missing argument defaults +#[test] +fn test_missing_string_arg() { + printf_test(&["%s%s", "hello"], "hello"); +} + +#[test] +fn test_missing_int_arg() { + printf_test(&["%d%d", "42"], "420"); +} + +// POSIX example from spec +#[test] +fn test_posix_example() { + printf_test( + &["%5d%4d\n", "1", "21", "321", "4321", "54321"], + " 1 21\n 3214321\n54321 0\n", + ); +} + +// Empty format +#[test] +fn test_empty_format() { + printf_test(&[""], ""); +} + +// \c in format stops output +#[test] +fn test_format_c_escape() { + printf_test(&["hello\\cworld"], "hello"); +} + +// \c in %b stops all output +#[test] +fn test_b_specifier_c_escape() { + printf_test(&["%b%b", "hello\\c", "world"], "hello"); +} + +// + flag tests (always show sign) +#[test] +fn test_plus_flag_positive() { + printf_test(&["%+d", "42"], "+42"); +} + +#[test] +fn test_plus_flag_negative() { + printf_test(&["%+d", "-42"], "-42"); +} + +#[test] +fn test_plus_flag_zero() { + printf_test(&["%+d", "0"], "+0"); +} + +// space flag tests (space if no sign) +#[test] +fn test_space_flag_positive() { + printf_test(&["% d", "42"], " 42"); +} + +#[test] +fn test_space_flag_negative() { + printf_test(&["% d", "-42"], "-42"); +} + +#[test] +fn test_plus_overrides_space() { + printf_test(&["%+ d", "42"], "+42"); +} + +// # flag tests (alternate form) +#[test] +fn test_alt_form_hex() { + printf_test(&["%#x", "255"], "0xff"); +} + +#[test] +fn test_alt_form_hex_upper() { + printf_test(&["%#X", "255"], "0XFF"); +} + +#[test] +fn test_alt_form_hex_zero() { + // # flag doesn't add prefix for zero + printf_test(&["%#x", "0"], "0"); +} + +#[test] +fn test_alt_form_octal() { + printf_test(&["%#o", "8"], "010"); +} + +#[test] +fn test_alt_form_octal_zero() { + // # flag doesn't add prefix for zero + printf_test(&["%#o", "0"], "0"); +} + +// Integer precision tests +#[test] +fn test_int_precision() { + printf_test(&["%.5d", "42"], "00042"); +} + +#[test] +fn test_int_precision_larger() { + printf_test(&["%.3d", "12345"], "12345"); +} + +#[test] +fn test_int_precision_negative() { + printf_test(&["%.5d", "-42"], "-00042"); +} + +#[test] +fn test_int_width_and_precision() { + printf_test(&["%8.5d", "42"], " 00042"); +} + +// Zero padding with sign +#[test] +fn test_zero_pad_negative() { + printf_test(&["%05d", "-42"], "-0042"); +} + +#[test] +fn test_zero_pad_with_plus() { + printf_test(&["%+05d", "42"], "+0042"); +} + +// Combined flags +#[test] +fn test_left_justify_with_plus() { + printf_test(&["%-+5d", "42"], "+42 "); +} + +#[test] +fn test_alt_form_with_width() { + printf_test(&["%#8x", "255"], " 0xff"); +} + +#[test] +fn test_alt_form_with_zero_pad() { + printf_test(&["%#08x", "255"], "0x0000ff"); +} + +// Floating point tests - %f +#[test] +fn test_float_f_basic() { + printf_test(&["%f", "3.14159"], "3.141590"); +} + +#[test] +fn test_float_f_precision() { + printf_test(&["%.2f", "3.14159"], "3.14"); +} + +#[test] +fn test_float_f_precision_zero() { + printf_test(&["%.0f", "3.7"], "4"); +} + +#[test] +fn test_float_f_negative() { + printf_test(&["%f", "-3.14"], "-3.140000"); +} + +#[test] +fn test_float_f_width() { + printf_test(&["%10.2f", "3.14"], " 3.14"); +} + +#[test] +fn test_float_f_zero_pad() { + printf_test(&["%010.2f", "3.14"], "0000003.14"); +} + +#[test] +fn test_float_f_plus_flag() { + printf_test(&["%+f", "3.14"], "+3.140000"); +} + +#[test] +fn test_float_f_space_flag() { + printf_test(&["% f", "3.14"], " 3.140000"); +} + +// Floating point tests - %e (scientific notation) +#[test] +fn test_float_e_basic() { + printf_test(&["%e", "12345.6789"], "1.234568e+04"); +} + +#[test] +fn test_float_e_precision() { + printf_test(&["%.2e", "12345.6789"], "1.23e+04"); +} + +#[test] +fn test_float_e_negative() { + printf_test(&["%e", "-0.00123"], "-1.230000e-03"); +} + +// Floating point tests - %E (uppercase scientific) +#[test] +fn test_float_big_e_basic() { + printf_test(&["%E", "12345.6789"], "1.234568E+04"); +} + +// Floating point tests - %g (general format) +#[test] +fn test_float_g_small() { + printf_test(&["%g", "0.0001234"], "0.0001234"); +} + +#[test] +fn test_float_g_large() { + printf_test(&["%g", "1234567"], "1.23457e+06"); +} + +// Integer and float argument with missing arg defaults +#[test] +fn test_float_missing_arg() { + printf_test(&["%f", ""], "0.000000"); +} + +// Character constant for float +#[test] +fn test_float_char_constant() { + printf_test(&["%f", "'A"], "65.000000"); } diff --git a/make/src/parser/parse.rs b/make/src/parser/parse.rs index 7c69db1e8..f7ea8f308 100644 --- a/make/src/parser/parse.rs +++ b/make/src/parser/parse.rs @@ -433,7 +433,7 @@ impl Rule { pub fn targets(&self) -> impl Iterator { self.syntax() .children_with_tokens() - .take_while(|it| it.as_token().map_or(true, |t| t.kind() != COLON)) + .take_while(|it| it.as_token().is_none_or(|t| t.kind() != COLON)) .filter_map(|it| it.as_token().map(|t| t.text().to_string())) } diff --git a/sh/builtin/cd.rs b/sh/builtin/cd.rs index 32378399d..7e103967a 100644 --- a/sh/builtin/cd.rs +++ b/sh/builtin/cd.rs @@ -121,7 +121,7 @@ impl BuiltinUtility for Cd { } if !handle_dot_dot_physically { - if !curr_path.as_bytes().first().is_some_and(|c| *c == b'/') { + if curr_path.as_bytes().first().is_none_or(|c| *c != b'/') { let mut new_curr_path = shell .environment .get_str_value("PWD")