diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index cd40045..432c806 100644 --- a/crates/deno_task_shell/src/grammar.pest +++ b/crates/deno_task_shell/src/grammar.pest @@ -90,6 +90,8 @@ VARIABLE_EXPANSION = ${ "$" ~ ( "{" ~ INDIRECT_VARIABLE ~ VARIABLE_MODIFIER? ~ "}" | "{" ~ VARIABLE ~ VARIABLE_MODIFIER? ~ "}" | + "{" ~ ARGNUM ~ VARIABLE_MODIFIER? ~ "}" | + "{" ~ BRACED_ALL_PARAMS ~ "}" | SPECIAL_PARAM | VARIABLE ) @@ -117,6 +119,8 @@ BRACE_STEP = ${ SPECIAL_PARAM = ${ ARGNUM | "@" | "#" | "?" | "$" | "*" } ARGNUM = ${ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* | "0" } +// ${@} and ${*}; modifiers such as ${@:2} (slicing) are not supported +BRACED_ALL_PARAMS = ${ "@" | "*" } VARIABLE = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } VARIABLE_MODIFIER = _{ diff --git a/crates/deno_task_shell/src/shell/command.rs b/crates/deno_task_shell/src/shell/command.rs index 82083db..29c9303 100644 --- a/crates/deno_task_shell/src/shell/command.rs +++ b/crates/deno_task_shell/src/shell/command.rs @@ -170,7 +170,7 @@ async fn parse_shebang_args( context: &mut ShellCommandContext, ) -> Result> { fn err_unsupported(text: &str) -> Result> { - miette::bail!("unsupported shebang. Please report this as a bug (https://github.com/prefix.dev/shell).\n\nShebang: {}", text) + miette::bail!("unsupported shebang. Please report this as a bug (https://github.com/prefix.dev/shell).\n\nShebang: {}", text); } let mut args = crate::parser::parse(text)?; diff --git a/crates/deno_task_shell/src/shell/commands/args.rs b/crates/deno_task_shell/src/shell/commands/args.rs index 3a40445..80c6021 100644 --- a/crates/deno_task_shell/src/shell/commands/args.rs +++ b/crates/deno_task_shell/src/shell/commands/args.rs @@ -15,16 +15,16 @@ impl ArgKind<'_> { pub fn bail_unsupported(&self) -> Result<()> { match self { ArgKind::Arg(arg) => { - bail!("unsupported argument: {}", arg) + bail!("unsupported argument: {}", arg); } ArgKind::LongFlag(name) => { - bail!("unsupported flag: --{}", name) + bail!("unsupported flag: --{}", name); } ArgKind::ShortFlag(name) => { - bail!("unsupported flag: -{}", name) + bail!("unsupported flag: -{}", name); } ArgKind::PlusFlag(name) => { - bail!("unsupported flag: +{}", name) + bail!("unsupported flag: +{}", name); } } } diff --git a/crates/deno_task_shell/src/shell/commands/cd.rs b/crates/deno_task_shell/src/shell/commands/cd.rs index 7184a41..caff1b5 100644 --- a/crates/deno_task_shell/src/shell/commands/cd.rs +++ b/crates/deno_task_shell/src/shell/commands/cd.rs @@ -45,7 +45,7 @@ fn execute_cd( let path = if args.is_empty() { "~".to_string() } else if args.len() > 1 { - bail!("too many arguments") + bail!("too many arguments"); } else { args[0].clone() }; @@ -57,7 +57,7 @@ fn execute_cd( .unwrap_or_else(|_| fs_util::canonicalize_path(&new_dir).unwrap()); if !new_dir.is_dir() { - bail!("{}: Not a directory", path) + bail!("{}: Not a directory", path); } Ok(new_dir) } diff --git a/crates/deno_task_shell/src/shell/commands/cp_mv.rs b/crates/deno_task_shell/src/shell/commands/cp_mv.rs index 3ef932e..a07fdfd 100644 --- a/crates/deno_task_shell/src/shell/commands/cp_mv.rs +++ b/crates/deno_task_shell/src/shell/commands/cp_mv.rs @@ -78,15 +78,15 @@ async fn do_copy_operation( if to.path.exists() && to.path.is_file() { bail!("destination was a file"); } else if to.path.is_symlink() { - bail!("no support for copying to symlinks") + bail!("no support for copying to symlinks"); } else if from.path.is_symlink() { - bail!("no support for copying from symlinks") + bail!("no support for copying from symlinks"); } else { copy_dir_recursively(from.path.clone(), to.path.clone()) .await?; } } else { - bail!("source was a directory; maybe specify -r") + bail!("source was a directory; maybe specify -r"); } } else { tokio::fs::copy(&from.path, &to.path) diff --git a/crates/deno_task_shell/src/shell/commands/exit.rs b/crates/deno_task_shell/src/shell/commands/exit.rs index f8f0ee1..e244e3c 100644 --- a/crates/deno_task_shell/src/shell/commands/exit.rs +++ b/crates/deno_task_shell/src/shell/commands/exit.rs @@ -58,11 +58,13 @@ fn parse_args(args: Vec) -> Result { let arg = paths.remove(0).to_string(); match arg.parse::() { Ok(value) => Ok(value), - Err(_) => bail!("numeric argument required."), + Err(_) => { + bail!("numeric argument required."); + } } } _ => { - bail!("too many arguments") + bail!("too many arguments"); } } } diff --git a/crates/deno_task_shell/src/shell/commands/head.rs b/crates/deno_task_shell/src/shell/commands/head.rs index 9810387..b4938e6 100644 --- a/crates/deno_task_shell/src/shell/commands/head.rs +++ b/crates/deno_task_shell/src/shell/commands/head.rs @@ -134,7 +134,9 @@ fn parse_args(args: Vec) -> Result { Some(ArgKind::Arg(arg)) => { lines = Some(arg.parse::().into_diagnostic()?); } - _ => bail!("expected a value following -n"), + _ => { + bail!("expected a value following -n"); + } }, ArgKind::LongFlag(flag) => { if flag == "lines" || flag == "lines=" { diff --git a/crates/deno_task_shell/src/shell/commands/shift.rs b/crates/deno_task_shell/src/shell/commands/shift.rs index 2ee5ea7..d2d6d5a 100644 --- a/crates/deno_task_shell/src/shell/commands/shift.rs +++ b/crates/deno_task_shell/src/shell/commands/shift.rs @@ -70,11 +70,14 @@ impl ShellCommand for ShiftCommand { changes.push(EnvChange::UnsetVar(idx.to_string())); } - // Update $# (parameter count) + // Update $# (parameter count), $@ and $* changes.push(EnvChange::SetShellVar( "#".to_string(), remaining.len().to_string(), )); + let joined = remaining.join(" "); + changes.push(EnvChange::SetShellVar("@".to_string(), joined.clone())); + changes.push(EnvChange::SetShellVar("*".to_string(), joined)); Box::pin(futures::future::ready(ExecuteResult::Continue( 0, diff --git a/crates/deno_task_shell/src/shell/commands/unset.rs b/crates/deno_task_shell/src/shell/commands/unset.rs index ed971f6..abb0bdf 100644 --- a/crates/deno_task_shell/src/shell/commands/unset.rs +++ b/crates/deno_task_shell/src/shell/commands/unset.rs @@ -38,7 +38,9 @@ fn parse_names(mut args: Vec) -> Result> { // Running the actual `unset` with no argument completes with success. Ok(args) } - Some(flag) if flag == "-f" => bail!("unsupported flag: -f"), + Some(flag) if flag == "-f" => { + bail!("unsupported flag: -f"); + } Some(flag) if flag == "-v" => { // It's fine to use `swap_remove` (instead of `remove`) because the order // of args doesn't matter for `unset` command. diff --git a/crates/deno_task_shell/src/shell/commands/xargs.rs b/crates/deno_task_shell/src/shell/commands/xargs.rs index 0791b36..57e006c 100644 --- a/crates/deno_task_shell/src/shell/commands/xargs.rs +++ b/crates/deno_task_shell/src/shell/commands/xargs.rs @@ -98,11 +98,13 @@ fn delimit_blanks(text: &str) -> Result> { break; } match c { - '\n' => bail!("{}", UNMATCHED_MESSAGE), + '\n' => { + bail!("{}", UNMATCHED_MESSAGE); + } _ => current.push(c), } if chars.peek().is_none() { - bail!("{}", UNMATCHED_MESSAGE) + bail!("{}", UNMATCHED_MESSAGE); } } } @@ -147,8 +149,12 @@ fn parse_args(args: Vec) -> Result { Some('t') => '\t', Some('\\') => '\\', Some('0') => '\0', - None => bail!("expected character following escape"), - _ => bail!("unsupported/not implemented escape character"), + None => { + bail!("expected character following escape"); + } + _ => { + bail!("unsupported/not implemented escape character"); + } }; } @@ -197,7 +203,9 @@ fn parse_args(args: Vec) -> Result { Some(ArgKind::Arg(arg)) => { delimiter = Some(parse_delimiter(arg)?); } - _ => bail!("expected delimiter argument following -d"), + _ => { + bail!("expected delimiter argument following -d"); + } }, ArgKind::LongFlag(flag) => { if let Some(arg) = flag.strip_prefix("delimiter=") { @@ -211,7 +219,7 @@ fn parse_args(args: Vec) -> Result { } if is_null_delimited && delimiter.is_some() { - bail!("cannot specify both null and delimiter flag") + bail!("cannot specify both null and delimiter flag"); } Ok(XargsFlags { diff --git a/crates/deno_task_shell/src/shell/execute.rs b/crates/deno_task_shell/src/shell/execute.rs index 85a42f9..dc94c5a 100644 --- a/crates/deno_task_shell/src/shell/execute.rs +++ b/crates/deno_task_shell/src/shell/execute.rs @@ -251,7 +251,7 @@ fn execute_sequence( sequence: Sequence, mut state: ShellState, stdin: ShellPipeReader, - mut stdout: ShellPipeWriter, + stdout: ShellPipeWriter, mut stderr: ShellPipeWriter, ) -> FutureExecuteResult { // requires boxed async because of recursive async @@ -274,7 +274,7 @@ fn execute_sequence( if state.print_trace() { let _ = - stdout.write_line(&format!("+ {}={}", var.name, value)); + stderr.write_line(&format!("+ {}={}", var.name, value)); } ExecuteResult::Continue( @@ -1814,7 +1814,7 @@ async fn execute_simple_command( command: SimpleCommand, state: &mut ShellState, stdin: ShellPipeReader, - mut stdout: ShellPipeWriter, + stdout: ShellPipeWriter, mut stderr: ShellPipeWriter, ) -> ExecuteResult { let args = @@ -1846,7 +1846,7 @@ async fn execute_simple_command( changes.extend(word_result.changes); if state.print_trace() { - let _ = stdout.write_line(&format!( + let _ = stderr.write_line(&format!( "+ {:}={:}", env_var.name, word_result.value )); @@ -1854,7 +1854,7 @@ async fn execute_simple_command( } if state.print_trace() { - let _ = stdout.write_line(&format!("+ {:}", args.join(" "))); + let _ = stderr.write_line(&format!("+ {:}", args.join(" "))); } let result = execute_command_args(args, state, stdin, stdout, stderr).await; @@ -2553,6 +2553,26 @@ fn expand_brace_word_parts( Ok(result) } +/// Whether a variable expansion is `$@` or `${@}` without a modifier. +fn is_all_params( + name: &str, + modifier: &Option>, + indirect: bool, +) -> bool { + name == "@" && modifier.is_none() && !indirect +} + +/// The positional parameters `$1` to `${N}`, where N is `$#`. +fn positional_params(state: &ShellState) -> Vec { + let count = state + .get_var("#") + .and_then(|n| n.parse::().ok()) + .unwrap_or(0); + (1..=count) + .map(|i| state.get_var(&i.to_string()).cloned().unwrap_or_default()) + .collect() +} + fn evaluate_word_parts( parts: Vec, state: &mut ShellState, @@ -2676,7 +2696,8 @@ fn evaluate_word_parts( async move { let mut result = WordPartsResult::new(Vec::new(), Vec::new()); let mut current_text = Vec::new(); - for part in parts { + let mut parts = std::collections::VecDeque::from(parts); + while let Some(part) = parts.pop_front() { let evaluation_result_text: Result, Error> = match part { WordPart::Text(text) => { @@ -2694,7 +2715,18 @@ fn evaluate_word_parts( } else { name.clone() }; - if let Some(modifier) = modifier { + if is_all_params(&resolved_name, &modifier, indirect) + { + // Unquoted $@ expands to one word per + // non-empty positional parameter + Ok(Some(Text::new( + positional_params(state) + .into_iter() + .filter(|p| !p.is_empty()) + .map(OtherText) + .collect(), + ))) + } else if let Some(modifier) = modifier { let (text, env_changes) = modifier .apply( &resolved_name, @@ -2734,9 +2766,51 @@ fn evaluate_word_parts( .await; Ok(Some(cmd.into())) } - WordPart::Quoted(parts) => { + WordPart::Quoted(mut quoted_parts) => { + // "$@" expands to one word per positional + // parameter: "pre$@post" with parameters a and b + // gives "prea" and "bpost", and with no + // parameters "$@" gives no word at all. + if let Some(idx) = quoted_parts.iter().position(|p| { + matches!(p, WordPart::Variable(name, modifier, indirect) + if is_all_params(name, modifier, *indirect)) + }) { + let after = quoted_parts.split_off(idx + 1); + quoted_parts.pop(); + if !quoted_parts.is_empty() { + let res = evaluate_word_parts_inner( + quoted_parts, + IsQuoted::Yes, + eval_glob, + state, + stdin.clone(), + stderr.clone(), + ) + .await?; + result.with_changes(res.changes); + current_text + .push(TextPart::Quoted(res.value.join(" "))); + } + let params = positional_params(state); + let count = params.len(); + for (i, param) in params.into_iter().enumerate() { + current_text.push(TextPart::Quoted(param)); + if i + 1 < count { + result.extend(evaluate_word_text( + state, + std::mem::take(&mut current_text), + is_quoted, + eval_glob, + )?); + } + } + if !after.is_empty() { + parts.push_front(WordPart::Quoted(after)); + } + continue; + } let res = evaluate_word_parts_inner( - parts, + quoted_parts, IsQuoted::Yes, eval_glob, state, diff --git a/crates/shell/src/commands/set.rs b/crates/shell/src/commands/set.rs index dd88cdd..9be4ddf 100644 --- a/crates/shell/src/commands/set.rs +++ b/crates/shell/src/commands/set.rs @@ -47,7 +47,9 @@ fn execute_set(args: Vec) -> Result<(i32, Vec)> { ArgKind::PlusFlag('u') => { env_changes.push(EnvChange::SetShellOptions(ShellOptions::NoUnset, false)); } - _ => bail!(format!("Unsupported argument: {:?}", arg)), + _ => { + bail!(format!("Unsupported argument: {:?}", arg)); + } } } Ok((0, env_changes)) diff --git a/crates/shell/src/main.rs b/crates/shell/src/main.rs index 6b2c8d5..dbc8c69 100644 --- a/crates/shell/src/main.rs +++ b/crates/shell/src/main.rs @@ -42,21 +42,23 @@ struct Options { args: Vec, } -async fn init_state(norc: bool, var_args: &[String]) -> miette::Result { +async fn init_state(norc: bool, arg0: &str, var_args: &[String]) -> miette::Result { let mut env_vars: HashMap = std::env::vars().collect(); let default_ps1 = "{display_cwd}{git_branch}$ "; env_vars.insert("PS1".to_string(), default_ps1.to_string()); let mut shell_vars = HashMap::new(); - // Set all arguments such as $0, $1, $2, etc. + // Set the positional parameters $0, $1, $2, etc. + shell_vars.insert("0".to_string(), arg0.to_string()); for (idx, arg) in var_args.iter().enumerate() { shell_vars.insert(format!("{}", idx + 1), arg.clone()); } - // Set the $@ variable - let args: Vec = std::env::args().collect(); - shell_vars.insert("@".to_string(), args.join(" ")); - shell_vars.insert("#".to_string(), args.len().to_string()); + // Set $@, $* and $# from the arguments only, not from the shell's own + // command line + shell_vars.insert("@".to_string(), var_args.join(" ")); + shell_vars.insert("*".to_string(), var_args.join(" ")); + shell_vars.insert("#".to_string(), var_args.len().to_string()); // Set the SHELL variable let current_exe = std::env::current_exe().into_diagnostic()?; @@ -90,7 +92,12 @@ async fn init_state(norc: bool, var_args: &[String]) -> miette::Result, norc: bool, args: &[String]) -> miette::Result<()> { +async fn interactive( + state: Option, + norc: bool, + arg0: &str, + args: &[String], +) -> miette::Result<()> { let config = Config::builder() .history_ignore_space(true) .completion_type(CompletionType::List) @@ -113,7 +120,7 @@ async fn interactive(state: Option, norc: bool, args: &[String]) -> let mut state = match state { Some(state) => state, - None => init_state(norc, args).await?, + None => init_state(norc, arg0, args).await?, }; let home = dirs::home_dir().ok_or(miette::miette!("Couldn't get home directory"))?; @@ -222,12 +229,19 @@ async fn interactive(state: Option, norc: bool, args: &[String]) -> #[tokio::main] async fn main() -> miette::Result<()> { let options = Options::parse(); - let mut state = init_state(options.norc, &options.args).await?; + // $0 is the script path, or the name the shell was invoked as + let arg0 = match &options.file { + Some(path) => path.display().to_string(), + None => std::env::args() + .next() + .unwrap_or_else(|| "shell".to_string()), + }; + let mut state = init_state(options.norc, &arg0, &options.args).await?; match (options.file, options.command) { (None, None) => { // Interactive mode only - interactive(None, options.norc, &options.args).await + interactive(None, options.norc, &arg0, &options.args).await } (file, command) => { // Handle script file or command @@ -241,7 +255,7 @@ async fn main() -> miette::Result<()> { let result = execute(&script_text, filename, &mut state).await?; if options.interact { - interactive(Some(state), options.norc, &options.args).await?; + interactive(Some(state), options.norc, &arg0, &options.args).await?; } std::process::exit(result.exit_code()); diff --git a/crates/shell/tests/script_args.rs b/crates/shell/tests/script_args.rs new file mode 100644 index 0000000..31b1d9e --- /dev/null +++ b/crates/shell/tests/script_args.rs @@ -0,0 +1,76 @@ +// Runs the `shell` binary on a script with arguments, to test how the +// command line becomes the script's positional parameters. + +use std::path::PathBuf; +use std::process::Command; + +fn run_script(name: &str, script: &str, args: &[&str]) -> String { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")); + let path = dir.join(name); + std::fs::write(&path, script).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_shell")) + .arg("--norc") + .arg(&path) + .args(args) + .current_dir(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn script_positional_parameters() { + let script = r#"echo "count=$#" +echo "0=$0" +echo "1=$1" +echo "2=$2" +echo "all=$@" +echo "star=$*" +"#; + let expected_0 = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join("positional.sh") + .display() + .to_string(); + assert_eq!( + run_script("positional.sh", script, &["foo", "b c"]), + format!("count=2\n0={expected_0}\n1=foo\n2=b c\nall=foo b c\nstar=foo b c\n") + ); +} + +#[test] +fn script_without_arguments() { + let script = r#"echo "count=$#" +if [ "$#" -gt 0 ]; then + shift +fi +echo "all=[$@]" +"#; + assert_eq!(run_script("no_args.sh", script, &[]), "count=0\nall=[]\n"); +} + +#[test] +fn script_forwards_arguments() { + // "$@" keeps each script argument as a separate word, also after shift + let script = r#"build_dir="${1:-.}" +if [ "$#" -gt 0 ]; then + shift +fi +echo "build_dir=$build_dir" +for a in -S . "$@"; do + echo "[$a]" +done +"#; + assert_eq!( + run_script("forward.sh", script, &["build", "-DA=1", "-DB=x y"]), + "build_dir=build\n[-S]\n[.]\n[-DA=1]\n[-DB=x y]\n" + ); + assert_eq!( + run_script("forward_none.sh", script, &[]), + "build_dir=.\n[-S]\n[.]\n" + ); +} diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 22e42b1..afe8f88 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -1460,7 +1460,8 @@ async fn test_set() { echo "hi" "#, ) - .assert_stdout("+ echo hi\nhi\n") + .assert_stdout("hi\n") + .assert_stderr("+ echo hi\n") .run() .await; @@ -1471,7 +1472,8 @@ async fn test_set() { echo "hi" && echo "This should be printed" || echo "This should not be printed" "#, ) - .assert_stdout("+ echo hi\nhi\n+ echo This should be printed\nThis should be printed\n") + .assert_stdout("hi\nThis should be printed\n") + .assert_stderr("+ echo hi\n+ echo This should be printed\n") .run() .await; @@ -1483,7 +1485,8 @@ async fn test_set() { echo $FOO "#, ) - .assert_stdout("+ FOO=1\n+ echo 1\n1\n") + .assert_stdout("1\n") + .assert_stderr("+ FOO=1\n+ echo 1\n") .run() .await; @@ -1497,7 +1500,8 @@ async fn test_set() { echo "This should be printed" "#, ) - .assert_stdout("+ FOO=1\n+ echo 1\n1\n+ set +x\nThis should be printed\n") + .assert_stdout("1\nThis should be printed\n") + .assert_stderr("+ FOO=1\n+ echo 1\n+ set +x\n") .run() .await; @@ -1507,7 +1511,22 @@ async fn test_set() { echo $((10 + 20)) "#, ) - .assert_stdout("+ echo 30\n30\n") + .assert_stdout("30\n") + .assert_stderr("+ echo 30\n") + .run() + .await; + + // The trace goes to stderr, so it is not captured by command + // substitution (prefix-dev/shell#293) + TestBuilder::new() + .command( + r#"set -x + v=$(echo hello) + echo "v=[$v]" + "#, + ) + .assert_stdout("v=[hello]\n") + .assert_stderr("+ echo hello\n+ v=hello\n+ echo v=[hello]\n") .run() .await; } diff --git a/crates/tests/src/test_builder.rs b/crates/tests/src/test_builder.rs index e45720f..d91a0f9 100644 --- a/crates/tests/src/test_builder.rs +++ b/crates/tests/src/test_builder.rs @@ -241,7 +241,7 @@ impl TestBuilder { let state = ShellState::new( self.env_vars.clone(), &cwd, - self.custom_commands.drain().collect(), + std::mem::take(&mut self.custom_commands), ); let exit_code = local_set .run_until(execute_with_pipes(list, state, stdin, stdout, stderr)) diff --git a/crates/tests/test-data/functions.sh b/crates/tests/test-data/functions.sh index 986a5da..d349855 100644 --- a/crates/tests/test-data/functions.sh +++ b/crates/tests/test-data/functions.sh @@ -61,3 +61,56 @@ first > f() { echo second; } > f second + +# Braced positional parameters (prefix-dev/shell#292) +> braced() { echo "[${1}x] [${2:-none}] [${3-unset}] [${@}] [${*}]"; } +> braced a b +[ax] [b] [unset] [a b] [a b] + +# Default value for a missing positional parameter +> dir() { d="${1:-.}"; echo "$d"; } +> dir +> dir build +. +build + +# Multi-digit braced positional parameter +> tenth() { echo "${10:-none}"; } +> tenth 1 2 3 4 5 6 7 8 9 ten +ten + +# "$@" expands to one word per positional parameter (prefix-dev/shell#291) +> count() { echo $#; } +> fwd() { count "$@"; } +> fwd a 'b c' d +> fwd +> fwd "" +3 +0 +1 + +> each() { for a in "$@"; do echo "[$a]"; done } +> each a 'b c' +[a] +[b c] + +# Text around "$@" joins the first and last parameters +> words() { for a in "pre$@post"; do echo "[$a]"; done } +> words a b +> words +[prea] +[bpost] +[prepost] + +# Unquoted $@ also gives one word per parameter +> count() { echo $#; } +> fwd() { count $@; } +> fwd a b c +3 + +# shift updates "$@" and "$*" +> rest() { shift; echo "$*"; for a in "$@"; do echo "[$a]"; done } +> rest a b 'c d' +b c d +[b] +[c d]