Tree hierarchy in Bash - bash

I'm trying to implement a function in bash which displays a tree of files/directories for the given depth. It takes 3 arguments.
$1 = *current directory*
$2 = *current depth*
$3 = *lines*
for example, if my current directory is ".../living/", my depth is 2, my function should output:
DIR .../living/
----DIR animals
--------FILE dog
--------FILE cat
----DIR plants
--------FILE flowers
As you can see, the number of lines is increased by 4 for each depth change. The type of file (DIR, FILE) is not the question of this thread.
Here's what I have so far:
function tree {
#some code to get the directory in variable cwd
...
a=$(getType $cwd)
echo "$a $cwd"
depth=3 #the value does not matter, it's just for you guys to see
drawTree $cwd $depth "----"
}
function drawTree {
if [[ $2 == 0 ]]; then
return
fi
dat=$1
list=$(ls $dat)
depth=$2
lines=$3
for d in $list; do
f="$dat/$d"
t=$(getType $f)
echo "$lines$t $d"
if [[ $t == "DIR" ]]; then
g=$(($depth-1))
l="$lines----"
if [[ $g > 00 ]]; then
drawTree $f $g $l
fi
fi
done
The output of this code is sadly false and I have no idea why.

There are quite a few issues with that code.
The most serious is that your variables are not made local (see help local) which can be disastrous in a recursive function. In the loop in drawtree, the second iteration will see unwanted modifications to $depth and $lines, both of which will cause the output to be incorrect in different ways.
Also:
g=$(($depth-1))
l="$lines----"
if [[ $g > 00 ]]; then
drawTree $f $g $l
fi
would much better be written without so many unnecessary variables, and using arithmetic rather than string comparison:
if (( depth > 1 )); then
drawTree $f $((depth - 1)) ${lines}----
fi
Finally:
list=$(ls $dat)
for d in $list; do
will fail disastrously if there is whitespace or a shell metacharacter in a filepath. Much better is the use of a bash array and glob expansion rather than the ls command):
# Create an array from a glob
list=("$dat"/*)
# Use the elements of the array, individually quoted:
for d in "${list[#]}"; do

Related

Bash function not returning the value as expected

This is a small portion of a larger script, but it doesn't show the right result. I want it to check for a folder, if that folder exists then check for number of files in it. If empty then exit (continue with larger script). Or maybe it should be a large function?
The script:
#!/bin/bash
BU_DIR=~/Backups
function Func_CountFiles() {
FILES=$(find ${BU_DIR} -type f | wc -l )
# echo $BU_FILES
}
### Get files from Backupfolder
#
if [ ! -d "${BU_DIR}" ]; then
BU_Status="Directory, ($BU_DIR), not found!"
else
Files=$(Func_CountFiles)
if [ $Files -le "0" ]; then
FU_Status="Current dir, ($BU_DIR), is empty."
else
# Files=$(Func_CountFiles)
BU_Status="Current dir, ($BU_DIR), contains $Files."
fi
fi
exit 0
The idea is right, but you are not returning anything to the calling function. You need the return the value by printing the variable value FILES. Only then the variable Files in the calling function is updated with the count you are determining in the Func_CountFiles(). Do
function Func_CountFiles() {
fileCount=$(find ${BU_DIR} -type f | wc -l )
printf "%d" "$fileCount"
}
and capture the value of called function as
declare -i file_count_from_function
file_count_from_function=$(Func_CountFiles)
printf "%d\n" "$file_count_from_function"
and since you are using bash use its own arithmetic operator ((..)) for numeric evaluations. You can use the variables for arithmetic evaluations just as you do in C programming.
if (( file_count_from_function <= 0 )); then

bash : If branch for two different loop

I am writing a for loop. But the loop is dependent on the content of positional argument.
If the positional arguments are seq 2 1 10, the loop is for i in $(seq 2 1 10)
If the positional arguments are purely numbers such as 1 2 5 7 10, then the loop is for i in 1 2 5 7 10.
I tried this, but it didn't work:
test () {
if [[ $1 == seq ]]
then
for i in $(seq $2 $3 $4)
else
for i in $#
fi
do
echo $i
done
}
I also tried this:
test2 () {
if [[ $1 == seq ]]
then
sss="for i in $(seq $2 $3 $4)"
else
sss="for i in $#"
fi
$sss
do
echo $i
done
}
also doesn't work.
So my questions are:
I know I could write explicit two loop inside if. But if loop content is large, this is a waste of code space. Is there any better way?
In my second attempt, why doesn't $sss expand to a for sentence and get parsed properly by bash?
Save the list of numbers in an array.
test () {
if [[ $1 == seq ]]
then
numbers=($(seq "$2" "$3" "$4"))
else
numbers=("$#")
fi
for i in "${numbers[#]}"
do
echo $i
done
}
In my second attempt, why doesn't $sss expand to a for sentence and get parsed properly by bash?
A variable can be expanded into a command to run, but not into a flow control construct like a for loop or if statement. Those need to be written out directly, they can't be stored in variables. If you try, bash will attempt to run a command named for--that is, it will look in /bin, /usr/bin, etc., for a binary named for.
An alternative to using arrays as in John Kugelman's answer is to use set -- to change the positional parameters:
test ()
{
if [[ $1 == seq ]]; then
set -- $(seq $2 $3 $4)
fi
for i; do
echo $i
done
}
Note that for i is equivalent to for i in "$#".
John already mentioned why it didn't work - variable interpolation and splitting happens after control flow is parsed.

Bash script: internal function alias for longer arguments

Suppose I have defined an array, like this:
DIR=(A B Supercalifragilistic)
and I need to invoke the script as
./script A B Supercalifragilistic
where the arguments are processed by internal functions func1() and func2(). Is there a way to make an alias (or anything, however it's called) S for Supercalifragilistic so that when I invoke:
./script A B S
the internal functions will process/interpret S as Supercalifragilistic?
Thank you in advance.
[edit]
I should add that the script is invoked via terminal, not inside a script, and the arguments A B Supercalifragilistic, or (hopefully) S, are passed on to the script in the terminal. I'm sorry for the confusion.
[edit2]
The script is here: Bash script: if any argument is "N" then function has extra options , in the answer below. What it does is explained in the OP there, below the script. Finally, instead of DIR=(A B C D E F) it's DIR=(A B Clarification D E F) (it's just an example) and the folder Clarification is the only one in a different path than the rest. I hope it's more clear now, if not, please tell me.
[final edit, I hope]
I think I can shout "Evrika!". Your word "hardcoded" made me realize I have to modify the script anytime a new folder gets added/deleted, so I thought of making the array dynamic, as in
./script a b "d e" g results in array=(a b "d e" g)
but also that it should replace the long paths with some short ones (Clarification >> C), so I made this test script based on also the answers here:
#!/bin/bash
array=()
for i in "$#"
do
if [[ "$i" == C ]]
then
array+=("Clarification")
else
array+=("$i")
fi
done
echo ${array[*]}
echo
for i in $(seq 0 $(( $# - 1 )))
do
echo ${array["$i"]}
done
and this is what it shows at command prompt:
$ ./x.sh abc C "d f" e
abc Clarification d f e
abc
Clarification
d f
e
I think now I can finally make the script to do what I want. Thank you, all, for the answers.
I really have no idea what you exactly want to achieve! But I had a look at the script you linked in your last edit. Since you have a hard-coded array you might as well instead use an associative array:
declare -A dir_h
dir_h["A"]=A
dir_h["B"]=B
dir_h["C"]=../path/Clarification
dir_h["D"]=D
dir_h["E"]=E
to loop on the keys of dir_h, i.e., on A B C D E:
for k in "${!dir_h[#]}"; do
echo "$k => ${dir_h[$k]}"
done
Try it, this might help you with your "alias" problem (or not).
Here's your script from your other post, using this technique and in a more consistent and readable form (note: I haven't tried it, there might be some minor typos, let me know if it's the case):
#!/bin/bash
# ./test.sh = 1. searches for existing archives
# 1.a. if they exist, it backups them into BKP/.
# 1.b. if not, displays a message
# 2. archives all the directories in the array list
# ./test.sh N = 1. deletes all the folder's archives existent and
# specified in the array list
# 2. archives all the directories in the array list
# ./test.sh {A..F} = 1. searches for existing archives from arguments
# 1.a. if they exist, it backups them into BKP/.
# 1.b. if not, displays a message
# 2. archives all the directories passed as arguments
# ./test.sh {A..F} N = 1. deletes all the archives matching $argument.zip
# 2. archives all the directories passed as arguments
# The directories to be backed-up/archived, all in the current (script's) path
# except "C", on a different path
declare -A dir_h
dir_h["A"]=A
dir_h["B"]=B
dir_h["C"]=../path/Clarification
dir_h["D"]=D
dir_h["E"]=E
dir_h["F"]=F
declare -A nope_h
nope_h["A"]=bogus
nope_h["B"]=bogus
nope_h["C"]=nope
nope_h["D"]=bogus
nope_h["E"]=bogus
nope_h["F"]=bogus
die() {
(($#)) && printf >&2 "%s\n" "$#"
exit 1
}
bak() {
if [[ "$1" != N ]]; then
# Check that arg is in dir list:
[[ -n ${dir_h["$1"]} ]] || die "Error in bak: argument \`$1' not handled"
if [[ -f $1.zip ]]; then
mv -vi "$1.zip" "BKP/$1.zip_$(date +"%H-%M")" || die
else
echo "$(tput setaf 1) no $1.zip$(tput sgr0)"
fi
fi
}
# The archive function, if any argument is "N", processing it is omitted. Folder
# "C" has special treatment
archive() {
if [[ $1 != N ]]; then
7z a -mx=9 "$1.zip" "${dir_h["$1"]}" -r -x\!"$1/${nope_h["$1"]}" || die
fi
}
# Let's check once for all whether N is in the arg list
foundN=0
for a in "$#"; do [[ $a = N ]] && foundN=1 && break; done
if (($#==0)); then
# case #1: no arguments
for d in "${!dir_h[#]}"; do
echo "$(tput setaf 2) backup$(tput sgr0)"
bak "$d"
archive "$d"
done
elif (($#==1)) && ((foundN)); then
# case #2: one argument, "N"
for d in "${!dir_h[#]}"; do
echo "$(tput setaf 1) no backup needed, removing$(tput sgr0)"
rm -v "$d".zip || die
archive "$d"
done
elif (($#>1)) && ((foundN)); then
# case #3: folders as arguments with "N"
for f in "$#"; do
if [[ $f != N ]]; then
echo "$(tput setaf 1) no backup needed, removing$(tput sgr0)"
rm -v "$f.zip" || die
fi
archive "$f"
done
else
for f in "$#"; do
echo "$(tput setaf 2) backup$(tput sgr0)"
bak "$f"
archive "$f"
done
fi
From this you can do a lot, and have pretty much infinite "alias" handling possibilities.
No need to use an alias. You could try something like :
$ cat test.sh
#!/bin/bash
declare -a args
for arg in "$#"; do
[[ $arg = "S" ]] && arg="Supercalifragilistic"
args+=( "$arg" )
done
for arg in "${args[#]}"; do
echo "$arg"
done
$ ./test.sh a b S e
a
b
Supercalifragilistic
e
You don't need alias here. Just set variable S to your string:
S=Supercalifragilistic
and then use:
./script A B "$S"
OR else call your script directly using array:
./script ${DIR[#]}
PS: It is not a good habit to use all caps variable names in shell and you can accidentally overwrite PATH variable some day.
You can do this:
processed_directories=()
for dir in "${directories[#]}"
do
if [ "$dir" = 'S' ]
then
dir='Supercalifragilistic'
fi
processed_directories+=("$dir")
done
It'll replace the value "S" with "Supercalifragilistic" anywhere in the array.

parse and expand interval

In my script I need to expand an interval, e.g.:
input: 1,5-7
to get something like the following:
output: 1,5,6,7
I've found other solutions here, but they involve python and I can't use it in my script.
Solution with Just Bash 4 Builtins
You can use Bash range expansions. For example, assuming you've already parsed your input you can perform a series of successive operations to transform your range into a comma-separated series. For example:
value1=1
value2='5-7'
value2=${value2/-/..}
value2=`eval echo {$value2}`
echo "input: $value1,${value2// /,}"
All the usual caveats about the dangers of eval apply, and you'd definitely be better off solving this problem in Perl, Ruby, Python, or AWK. If you can't or won't, then you should at least consider including some pipeline tools like tr or sed in your conversions to avoid the need for eval.
Try something like this:
#!/bin/bash
for f in ${1//,/ }; do
if [[ $f =~ - ]]; then
a+=( $(seq ${f%-*} 1 ${f#*-}) )
else
a+=( $f )
fi
done
a=${a[*]}
a=${a// /,}
echo $a
Edit: As #Maxim_united mentioned in the comments, appending might be preferable to re-creating the array over and over again.
This should work with multiple ranges too.
#! /bin/bash
input="1,5-7,13-18,22"
result_str=""
for num in $(tr ',' ' ' <<< "$input"); do
if [[ "$num" == *-* ]]; then
res=$(seq -s ',' $(sed -n 's#\([0-9]\+\)-\([0-9]\+\).*#\1 \2#p' <<< "$num"))
else
res="$num"
fi
result_str="$result_str,$res"
done
echo ${result_str:1}
Will produce the following output:
1,5,6,7,13,14,15,16,17,18,22
expand_commas()
{
local arg
local st en i
set -- ${1//,/ }
for arg
do
case $arg in
[0-9]*-[0-9]*)
st=${arg%-*}
en=${arg#*-}
for ((i = st; i <= en; i++))
do
echo $i
done
;;
*)
echo $arg
;;
esac
done
}
Usage:
result=$(expand_commas arg)
eg:
result=$(expand_commas 1,5-7,9-12,3)
echo $result
You'll have to turn the separated words back into commas, of course.
It's a bit fragile with bad inputs but it's entirely in bash.
Here's my stab at it:
input=1,5-7,10,17-20
IFS=, read -a chunks <<< "$input"
output=()
for chunk in "${chunks[#]}"
do
IFS=- read -a args <<< "$chunk"
if (( ${#args[#]} == 1 )) # single number
then
output+=(${args[*]})
else # range
output+=($(seq "${args[#]}"))
fi
done
joined=$(sed -e 's/ /,/g' <<< "${output[*]}")
echo $joined
Basically split on commas, then interpret each piece. Then join back together with commas at the end.
A generic bash solution using the sequence expression `{x..y}'
#!/bin/bash
function doIt() {
local inp="${#/,/ }"
declare -a args=( $(echo ${inp/-/..}) )
local item
local sep
for item in "${args[#]}"
do
case ${item} in
*..*) eval "for i in {${item}} ; do echo -n \${sep}\${i}; sep=, ; done";;
*) echo -n ${sep}${item};;
esac
sep=,
done
}
doIt "1,5-7"
Should work with any input following the sample in the question. Also with multiple occurrences of x-y
Use only bash builtins
Using ideas from both #Ansgar Wiechers and #CodeGnome:
input="1,5-7,13-18,22"
for s in ${input//,/ }
do
if [[ $f =~ - ]]
then
a+=( $(eval echo {${s//-/..}}) )
else
a+=( $s )
fi
done
oldIFS=$IFS; IFS=$','; echo "${a[*]}"; IFS=$oldIFS
Works in Bash 3
Considering all the other answers, I came up with this solution, which does not use any sub-shells (but one call to eval for brace expansion) or separate processes:
# range list is assumed to be in $1 (e.g. 1-3,5,9-13)
# convert $1 to an array of ranges ("1-3" "5" "9-13")
IFS=,
local range=($1)
unset IFS
list=() # initialize result list
local r
for r in "${range[#]}"; do
if [[ $r == *-* ]]; then
# if the range is of the form "x-y",
# * convert to a brace expression "{x..y}",
# * using eval, this gets expanded to "x" "x+1" … "y" and
# * append this to the list array
eval list+=( {${r/-/..}} )
else
# otherwise, it is a simple number and can be appended to the array
list+=($r)
fi
done
# test output
echo ${list[#]}

How to know if file in a loop is the last one?

Example
for FILE in $DIR/*
do
if(<is last File>)
doSomethingSpecial($FILE)
else
doSomethingRegular($FILE)
fi
done
What to call for <is last file> to check if the current file is the last one in the array ?
Is there an easy built-in check without checking the array's length by myself ?
What to call for to check if the current file is the last one in the array ?
For a start, you are not using an array. If you were then it would be easy:
declare -a files
files=($DIR/*)
pos=$(( ${#files[*]} - 1 ))
last=${files[$pos]}
for FILE in "${files[#]}"
do
if [[ $FILE == $last ]]
then
echo "$FILE is the last"
break
else
echo "$FILE"
fi
done
I know of no way to tell that you are processing the last element of a list in a for loop. However you could use an array, iterate over all but the last element, and then process the last element outside the loop:
files=($DIR/*)
for file in "${files[#]::${#files[#]}-1}" ; do
doSomethingRegular "$file"
done
doSomethingSpecial "${files[#]: -1:1}"
The expansion ${files[#]:offset:length} evaluates to all the elements starting at offset (or the beginning if empty) for length elements. ${#files[#]}-1 is the number of elements in the array minus 1.
${files[#]: -1:1} evaluates to the last element - -1 from the end, length 1. The space is necessary as :- is treated differently to : -.
Try this
LAST_FILE=""
for f in *
do
if [ ! -z $LAST_FILE ]
then
echo "Process file normally $LAST_FILE"
fi
LAST_FILE=$f
done
if [ ! -z $LAST_FILE ]
then
echo "Process file as last file $LAST_FILE"
fi
Produces
bash[1051]: ls
1 2 3 4
bash[1052]: sh ../last_file.sh
Process file normally 1
Process file normally 2
Process file normally 3
Process file as last file 4
You can use find to find the total number of files.
Then when you are in the loop count to the total number and carry out your task when the total equals the count i.e, the last file.
f=0
tot_files=`find . -iname '*.txt' | wc -l`
for FILE in $DIR/*
do
f=($f+1)
if [[ $f == $tot_files ]];then
carryout your task
fi
done
Building on the current highest-voted answer from #cdarke (https://stackoverflow.com/a/12298757/415523), if looking at a general array of values (rather than specifically files on disk), the loop code would be as follows:
declare -a array
declare -i length current
array=( a b c d e c )
length=${#array[#]}
current=0
for VALUE in "${array[#]}"; do
current=$((current + 1))
if [[ "$current" -eq "$length" ]]; then
echo "$VALUE is the last"
else
echo "$VALUE"
fi
done
This yields the output:
a
b
c
d
e
c is the last
This ensures that only the last item in the array triggers the alternative action and that, if any other item in the array duplicates the last value, the alternative action is not called for the earlier duplicates.
In the case of an array of paths to files in a specific directory, e.g.
array=( $DIR/* )
...it is probably less of a concern, since individual filenames within the same directory are almost-certainly unique (unless you have a really odd filesystem!)
You can abuse the positional parameters, since they act similarly to an array,
but are a little easier to manipulate. You should either save the old positional
parameters, or execute in a subshell.
# Method 1: use a subshell. Slightly cleaner, but you can't always
# do this (for example, you may need to affect variables in the current
# shell
files=( $DIR/* )
(
set -- "${files[#]}"
until (( $# == 1 )); do
doSomethingRegular "$1"
shift
done
doSomethingSpecial "$1"
)
# Method 2: save the positional parameters. A bit uglier, but
# executes everything in the same shell.
files=( $DIR/* )
oldPP=( "$#" )
set -- "${files[#]}"
until (( $# == 1 )); do
doSomethingRegular "$1"
shift
done
doSomethingSpecial "$1"
set -- "${oldPP[#]}"
What makes a file the last one? Is there something special about it? Is it the file with the greatest name when sorted by name?
Maybe you can take the file names backwards. Then, it's the first file you want to treat special and not the last. figuring out the first is a much easier task than doing the last:
for file in $(ls -r1 $dir)
do
if [ ! $processedLast ]
then
doSomethingSpecial($file)
processedLast=1
else
doSomethingRegular($file)
fi
done
No arrays needed. Actually, I like chepner's answer about using positional parameters.
It's old question - but building on answer from #GregReynolds please use this one-liner if commands differ only by parameters on last pass. Ugly, ugly code for one-liner lovers
( ff="" ; for f in * "" ; do [ -n "$ff" ] && echo $(${f:+false} && echo $ff alternate params here || echo normal params $ff ) ; ff=$f ; done )
normal params 1
normal params 2
normal params 3
4 alternate params here

Resources