use std::{ffi::OsString, io::Write, ops::ControlFlow}; use uucore::format; use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion}; /// Format a string. #[derive(winnow_args::Args)] #[arg(disable_help_flag, disable_version_flag, disable_help_subcommand)] pub(crate) struct PrintfCommand { /// If specified, the output of the command is assigned to this variable. #[arg(short = 'v', allow_hyphen_values)] output_variables: Vec, /// Format string + arguments to the format string. #[arg(positional, stop_flags, required)] format_and_args: Vec, } brush_winnow_args_adapter::impl_parse_args!(PrintfCommand); impl builtins::Builtin for PrintfCommand { type Error = brush_core::Error; async fn execute( self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { // Every -v name must be valid, not just the last one, which is used. let parser_options = context.shell.parser_options(); if let Some(invalid) = self .output_variables .iter() .find(|name| !valid_output_variable(name, &parser_options)) { writeln!( context.stderr(), "{}: `{invalid}': not a valid identifier", context.command_name )?; return Ok(ExecutionResult::new(2)); } if let Some(variable_name) = self.output_variables.last() { // Format to a u8 vector. let mut result: Vec = vec![]; format(self.format_and_args.as_slice(), &mut result)?; // Convert to a string. let result_str = String::from_utf8(result).map_err(|_| { brush_core::ErrorKind::PrintfInvalidUsage("invalid UTF-8 output".into()) })?; // Assign to the selected variable. expansion::assign_to_named_parameter( context.shell, &context.params, variable_name, result_str, ) .await?; } else { format(self.format_and_args.as_slice(), context.stdout())?; context.stdout().flush()?; } Ok(ExecutionResult::success()) } } /// Whether `name` is a variable name, or one followed by a non-empty `[subscript]`, /// by the grammar of an assignment's name. The subscript isn't evaluated here. fn valid_output_variable(name: &str, options: &brush_parser::ParserOptions) -> bool { use brush_parser::ast::{AssignmentName, AssignmentValue}; match brush_parser::word::parse_scalar_assignment(&format!("{name}="), options) { Ok(assignment) if !assignment.append => { matches!(&assignment.value, AssignmentValue::Scalar(word) if word.value.is_empty()) && match assignment.name { AssignmentName::VariableName(_) => true, AssignmentName::ArrayElementName(_, index) => !index.is_empty(), } } _ => false, } } fn format(format_and_args: &[String], writer: impl Write) -> Result<(), brush_core::Error> { match format_and_args { // Handle format string with arguments using uucore [fmt, args @ ..] => format_via_uucore(fmt, args.iter(), writer), // Handle case with no format string (we shouldn't be able to get here since parsing // fails when the format string is missing) [] => Err(ErrorKind::PrintfInvalidUsage("missing operand".into()).into()), } } fn format_via_uucore( format_string: &str, args: impl Iterator>, mut writer: impl Write, ) -> Result<(), brush_core::Error> { // Convert string arguments to FormatArgument::Unparsed let format_args: Vec<_> = args .map(|s| format::FormatArgument::Unparsed(s.into())) .collect(); // Parse format string once. let format_items = parse_format_string(format_string)?; // Wrap the format arguments. let mut format_args_wrapper = format::FormatArguments::new(&format_args); // Determine whether the format string contains any specifiers that consume arguments. If it // doesn't, then we must only run through it once -- even when extra arguments are provided -- // since otherwise we'd loop forever waiting for arguments that will never be consumed. This // matches the behavior of other shells, which print such a format string exactly once. let format_consumes_args = format_items .iter() .any(|(item, _)| matches!(item, format::FormatItem::Spec(_))); // Keep going until we've exhausted all format arguments. Also make sure to run at least once // even if there's no format arguments. while format_args.is_empty() || !format_args_wrapper.is_exhausted() { // Process all format items, in order. We'll bail when we're told to stop. for (item, backslash_quote) in &format_items { if let (format::FormatItem::Spec(format::Spec::QuotedString { position }), true) = (item, *backslash_quote) { let arg = format_args_wrapper.next_string(*position).to_string_lossy(); let quoted = quote_printf_q(&arg); write!(writer, "{quoted}")?; continue; } let control_flow = item .write(&mut writer, &mut format_args_wrapper) .map_err(|e| match e { // Propagate I/O errors directly so they can be handled appropriately format::FormatError::IoError(io_err) => Error::from(io_err), // Wrap other format errors other => Error::from(ErrorKind::PrintfInvalidUsage(std::format!( "printf formatting error: {other}" ))), })?; if control_flow == ControlFlow::Break(()) { break; } } // If the format string doesn't consume any arguments, stop now; otherwise we'd reprocess // it forever since no arguments will ever be consumed. if !format_consumes_args { break; } // Start next batch if not exhausted if !format_args_wrapper.is_exhausted() { format_args_wrapper.start_next_batch(); } if format_args.is_empty() { break; } } Ok(()) } fn quote_printf_q(s: &str) -> String { // Like bash's, this escapes commas too, so the result is safe even inside a brace // expansion. let options = escape::QuoteOptions::builder() .preferred_mode(escape::QuoteMode::BackslashEscape) .quote_commas(true) .build(); escape::quote(s, &options).into_owned() } type ParsedFormatItem = (format::FormatItem, bool); fn parse_format_string(format_string: &str) -> Result, brush_core::Error> { let format_items: Result, _> = format::parse_spec_and_escape(format_string.as_bytes()) .map(|result| match result { Ok(item @ format::FormatItem::Spec(format::Spec::QuotedString { .. })) => { Ok((item, true)) } Ok(item) => Ok((item, false)), // Fixed q/Q modifiers are deliberately ignored; dynamic modifiers remain unsupported // until uucore exposes quoted-string metadata. Err(format::FormatError::SpecError(spec, span)) if matches!(spec.last(), Some(b'q' | b'Q')) && !spec.contains(&b'*') && !spec.contains(&b'$') => { let mut bare_q: &[u8] = b"q"; let item = format::Spec::parse(&mut bare_q) .map(format::FormatItem::Spec) .map_err(|spec| format::FormatError::SpecError(spec.to_vec(), span))?; Ok((item, !spec.contains(&b'#'))) } Err(error) => Err(error), }) .collect(); // Observe any errors we encountered along the way. let format_items = format_items .map_err(|e| ErrorKind::PrintfInvalidUsage(format!("printf parsing error: {e}")))?; Ok(format_items) } #[cfg(test)] #[expect(clippy::panic_in_result_fn)] mod tests { use super::*; use anyhow::Result; fn sprintf_via_uucore( format_string: &str, args: impl Iterator>, ) -> Result { let mut result = vec![]; format_via_uucore(format_string, args, &mut result)?; Ok(String::from_utf8(result)?) } #[test] fn test_basic_sprintf() -> Result<()> { assert_eq!(sprintf_via_uucore("%s", std::iter::once(&"xyz"))?, "xyz"); assert_eq!(sprintf_via_uucore(r"%d\n", std::iter::once(&"1"))?, "1\n"); Ok(()) } #[test] fn test_sprintf_without_args() -> Result<()> { let empty: [&str; 0] = []; assert_eq!(sprintf_via_uucore("xyz", empty.iter())?, "xyz"); assert_eq!(sprintf_via_uucore("%s|", empty.iter())?, "|"); Ok(()) } #[test] fn test_sprintf_with_cycles() -> Result<()> { assert_eq!(sprintf_via_uucore("%s|", ["x", "y"].iter())?, "x|y|"); Ok(()) } }