79

I'm trying to create a Bash script that will extract the last parameter given from the command line into a variable to be used elsewhere. Here's the script I'm working on:

#!/bin/bash
# compact - archive and compact file/folder(s)

eval LAST=\$$#

FILES="$@"
NAME=$LAST

# Usage - display usage if no parameters are given
if [[ -z $NAME ]]; then
  echo "compact <file> <folder>... <compressed-name>.tar.gz"
  exit
fi

# Check if an archive name has been given
if [[ -f $NAME ]]; then
  echo "File exists or you forgot to enter a filename.  Exiting."
  exit
fi

tar -czvpf "$NAME".tar.gz $FILES

Since the first parameters could be of any number, I have to find a way to extract the last parameter, (e.g. compact file.a file.b file.d files-a-b-d.tar.gz). As it is now the archive name will be included in the files to compact. Is there a way to do this?

tshepang
  • 12,111
  • 21
  • 91
  • 136
user148813
  • 885
  • 1
  • 7
  • 11

16 Answers16

117

To remove the last item from the array you could use something like this:

#!/bin/bash

length=$(($#-1))
array=${@:1:$length}
echo $array

Even shorter way:

array=${@:1:$#-1}

But arays are a Bashism, try avoid using them :(.

pevik
  • 4,523
  • 3
  • 33
  • 44
Krzysztof Klimonda
  • 1,517
  • 1
  • 11
  • 8
32

Portable and compact solutions

This is how I do in my scripts

last=${@:$#} # last parameter 
other=${*%${!#}} # all parameters except the last

EDIT
According to some comments (see below), this solution is more portable than others.
Please read Michael Dimmitt's commentary for an explanation of how it works.

ePi272314
  • 12,557
  • 5
  • 50
  • 36
  • 4
    "other" has an unintended side effect of removing all parameters that match the last, not just the last element. The method of using matching via (%) isn't strictly correct. `Example: ( item1 item2 item2 ) => ( item1 )` – Matt Kneiser Jan 28 '17 at 01:50
  • 3
    TLDR, https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html . variable expansion for the win! The last command is using $@ which is an array of all arguments. $# which is the number of arguments. And then the colon by itself in variable expansion means offset. Overall the command means. offset the array of args $@, by the number of args $# and convert the array to a string because of the variable expansion. It is the most portable because Environment variable expansion is specified in GNU Coreutils. – Michael Dimmitt Oct 26 '18 at 16:29
  • the interior expansion: ${!#} is missing quotes. So it works only when passing characters safe for bash – marinara Jun 19 '19 at 01:44
  • If you have parameters like "p1 p2 '\.p3'", other=${*%${!#}} will be "p1 p2 \" . Is this a quoting issue and can it be fixed? Somehow I was winding my head around this but could not get it right. I stick with the ${@:1:$#-1} for now. – Micha May 16 '21 at 15:06
22
last_arg="${!#}" 
  • 2
    @LukeDavis I think basically expands `!#` to the equivalent of `$#` due to: "If the first character of parameter is an exclamation point (!), and parameter is not a nameref, it introduces a level of variable indirection." https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html – n8henrie Jun 23 '18 at 15:54
  • @n8henrie, no, because $# gives the number of argument's position, and ${!#} gives the last argument's value – Gavriel Apr 30 '20 at 23:23
14

Several solutions have already been posted; however I would advise restructuring your script so that the archive name is the first parameter rather than the last. Then it's really simple, since you can use the shift builtin to remove the first parameter:

ARCHIVENAME="$1"
shift
# Now "$@" contains all of the arguments except for the first
Adam Rosenfield
  • 390,455
  • 97
  • 512
  • 589
  • 2
    I thought of this but basically I didn't want to do this because the typing syntax of 'compact file1 file4... archivename' just made better sense tome. Thanks though. – user148813 Aug 02 '09 at 02:24
6

Thanks guys, got it done, heres the final bash script:

#!/bin/bash
# compact - archive and compress file/folder(s)

# Extract archive filename for variable
ARCHIVENAME="${!#}"

# Remove archive filename for file/folder list to backup
length=$(($#-1))
FILES=${@:1:$length} 

# Usage - display usage if no parameters are given
if [[ -z $@ ]]; then
  echo "compact <file> <folder>... <compressed-name>.tar.gz"
  exit
fi

# Tar the files, name archive after last file/folder if no name given
if [[ ! -f $ARCHIVENAME ]]; then
  tar -czvpf "$ARCHIVENAME".tar.gz $FILES; else
  tar -czvpf "$ARCHIVENAME".tar.gz "$@"
fi
user148813
  • 885
  • 1
  • 7
  • 11
  • 4
    This has the usual problem of confusing spaces with separators between filenames; try it on files with spaces in the name, and it'll fail miserably. Use arrays: `FILES=("${@:1:$length}")`, and then `tar ... "${FILES[@]}"; else` should work much more reliably – Gordon Davisson Aug 03 '09 at 02:30
  • Hmm, not sure how this will recogznize a "\ ". I tried it, yeah, and am getting: line 9: {@:1:2}: command not found – user148813 Aug 10 '09 at 00:23
  • You use [bashism](http://mywiki.wooledge.org/Bashism#Arrays), so this could not be used in distro system script. Sometimes it's better to use common approaches (some presented here), when possible to apply (alias, set name as first parameter, ...). – pevik Apr 24 '18 at 05:08
3

I would add this as a comment, but don't have enough reputation and the answer got a bit longer anyway. Hope it doesn't mind.

As @func stated:

last_arg="${!#}"

How it works:

${!PARAM} indicates level of indirection. You are not referencing PARAM itself, but the value stored in PARAM ( think of PARAM as pointer to value ).
${#} expands to the number of parameters (Note: $0 - the script name - is not counted here).

Consider following execution:

$./myscript.sh p1 p2 p3

And in the myscript.sh

#!/bin/bash

echo "Number of params: ${#}"  # 3
echo "Last parameter using '\${!#}': ${!#}"  # p3
echo "Last parameter by evaluating positional value: $(eval LASTP='$'${#} ; echo $LASTP)"  # p3

Hence you can think of ${!#} as a shortcut for the above eval usage, which does exactly the approach described above - evaluates the value stored in the given parameter, here the parameter is 3 and holds the positional argument $3

Now if you want all the params except the last one, you can use substring removal ${PARAM%PATTERN} where % sign means 'remove the shortest matching pattern from the end of the string'.

Hence in our script:

echo "Every parameter except the last one: ${*%${!#}}"


You can read something in here: Parameter expansion

CermakM
  • 1,642
  • 1
  • 16
  • 15
  • There is a shortcoming though. ./myscript.sh p1 p2 '\.p3' will return "Every parameter except the last one: p1 p2 \" – Micha May 16 '21 at 15:01
3

Just dropping the length variable used in Krzysztof Klimonda's solution:

(
set -- 1 2 3 4 5
echo "${@:1:($#-1)}"       # 1 2 3 4
echo "${@:(-$#):($#-1)}"   # 1 2 3 4
)
bashist
  • 31
  • 1
2
#!/bin/bash

lastidx=$#
lastidx=`expr $lastidx - 1`

eval last='$'{$lastidx}
echo $last
jsight
  • 27,819
  • 25
  • 107
  • 140
2

Try:

if [ "$#" -gt '0' ]; then
    /bin/echo "${!#}" "${@:1:$(($# - 1))}
fi
Rostyslav Dzinko
  • 39,424
  • 5
  • 49
  • 62
zorro
  • 21
  • 1
2

Array without last parameter:

array=${@:1:$#-1}

But it's a bashism :(. Proper solutions would involve shift and adding into variable as others use.

pevik
  • 4,523
  • 3
  • 33
  • 44
2

Are you sure this fancy script is any better than a simple alias to tar?

alias compact="tar -czvpf"

Usage is:

compact ARCHIVENAME FILES...

Where FILES can be file1 file2 or globs like *.html

MestreLion
  • 12,698
  • 8
  • 66
  • 57
1
#!/bin/sh

eval last='$'$#
while test $# -gt 1; do
    list="$list $1"
    shift
done

echo $list $last

William Pursell
  • 204,365
  • 48
  • 270
  • 300
1

Alternative way to pull the last parameter out of the argument list:

eval last="\$$#"
eval set -- `awk 'BEGIN{for(i=1;i<'$#';i++) printf " \"$%d\"",i;}'`
Anders
  • 11
  • 2
0

I can't find a way to use array-subscript notation on $@, so this is the best I can do:

#!/bin/bash

args=("$@")
echo "${args[$(($#-1))]}"
Norman Ramsey
  • 198,648
  • 61
  • 360
  • 533
0

This script may work for you - it returns a subrange of the arguments, and can be called from another script.

Examples of it running:

$ args_get_range 2 -2 y a b "c 1" d e f g                          
'b' 'c 1' 'd' 'e'

$ args_get_range 1 2 n arg1 arg2                                   
arg1 arg2

$ args_get_range 2 -2 y arg1 arg2 arg3 "arg 4" arg5                
'arg2' 'arg3'

$ args_get_range 2 -1 y arg1 arg2 arg3 "arg 4" arg5                
'arg2' 'arg3' 'arg 4'

# You could use this in another script of course 
# by calling it like so, which puts all
# args except the last one into a new variable
# called NEW_ARGS

NEW_ARGS=$(args_get_range 1 -1 y "$@")

args_get_range.sh

#!/usr/bin/env bash

function show_help()
{
  IT="
  Extracts a range of arguments from passed in args
  and returns them quoted or not quoted.

  usage: START END QUOTED ARG1 {ARG2} ...

  e.g. 

  # extract args 2-3 
  $ args_get_range.sh 2 3 n arg1 arg2 arg3
  arg2 arg3

  # extract all args from 2 to one before the last argument 
  $ args_get_range.sh 2 -1 n arg1 arg2 arg3 arg4 arg5
  arg2 arg3 arg4

  # extract all args from 2 to 3, quoting them in the response
  $ args_get_range.sh 2 3 y arg1 arg2 arg3 arg4 arg5
  'arg2' 'arg3'

  # You could use this in another script of course 
  # by calling it like so, which puts all
  # args except the last one into a new variable
  # called NEW_ARGS

  NEW_ARGS=\$(args_get_range.sh 1 -1 \"\$@\")

  "
  echo "$IT"
  exit
}

if [ "$1" == "help" ]
then
  show_help
fi
if [ $# -lt 3 ]
then
  show_help
fi

START=$1
END=$2
QUOTED=$3
shift;
shift;
shift;

if [ $# -eq 0 ]
then
  echo "Please supply a folder name"
  exit;
fi

# If end is a negative, it means relative
# to the last argument.
if [ $END -lt 0 ]
then
  END=$(($#+$END))
fi

ARGS=""

COUNT=$(($START-1))
for i in "${@:$START}"
do
  COUNT=$((COUNT+1))

  if [ "$QUOTED" == "y" ]
  then
    ARGS="$ARGS '$i'"
  else
    ARGS="$ARGS $i"
  fi

  if [ $COUNT -eq $END ]
  then
    echo $ARGS
    exit;
  fi
done
echo $ARGS
Community
  • 1
  • 1
Brad Parks
  • 66,836
  • 64
  • 257
  • 336
0

This works for me, with sh and bash:

last=${*##* }
others=${*%${*##* }}