I'm writing a simple recursive ls program in bash (which I'm very not experienced at, so feel free to be brutal).
The program is supposed to print out each file (possibly directory) on a separate line, and each time a new directory is entered, the output is shifted over by 4 spaces, to give it a tree-like output.
Currently, it doesn't print out files with spaces correctly, and it doesn't put a forward slash after directories. (More details below.)
Code
recls () {
# store current working directory
# issues: seems bad to have cwd defined up here and used down below in getAbsolutePath -- too much coupling
cwd=$PWD
# get absolute path of arg
argdir=`getAbsolutePath "$#"`
# check if it exists
if [ ! -e $argdir ]; then
echo "$argdir does not exist"
return 1
fi
echo "$argdir exists"
# check if it's a directory
if [ ! -d $argdir ]; then
echo "$argdir is not a directory"
return 2
fi
echo "$argdir is a directory"
tab=""
recls_internal $argdir
return 0
}
recls_internal () {
for file in $#; do
echo -n "$tab${file##/*/}"
if [ -d $file ]; then
# print forward slash to show it's a directory
echo "/"
savedtab=$tab
tab="$tab "
myls_internal $file/*
tab=$savedtab
else
# if not a directory, print a new line
echo ""
fi
done
}
getAbsolutePath () {
if [ -z ${1##/*} ]; then
echo "$1"
else
echo "$cwd/$1"
fi
}
Output
The script is contained in a folder called bash-practice. When I do recls ., I get the following output:
./
myls.sh
myls.sh~
recdir.sh
recls.sh
recls.sh~
sample
document.txt
sample-folder
sample-stuff
test-12.txt
test-1.txt
test-2.txt
sort-test.txt
sort-text-copy.txt
test-5-19-14-1
The Problem
As you can see, the indentation is working properly but there are two problems:
1) The file sample document.txt is spread across two lines, because it has a space in it.
2) Every directory should have a forward slash in front of it, but for some reason that only works on the very first one.
Attempted Solution
In order to fix (1), I tried saving the internal file separator and replacing it with a newline character like so:
...
tab=""
savedIFS=$IFS
IFS="\n"
recls_internal $argdir
IFS=$savedIFS
return 0
But this did not work at all. It didn't even display more than the first folder. Clearly my understanding of things is not correct.
As for (2), I don't see any reason why it shouldn't be working as intended.
Conclusion
bash is difficult for me as it seems to have more unusual syntax than most other programming languages (being a shell scripting language), so I would appreciate any insights into my mistakes, as well as a solution.
Update #1
I went to the site http://www.shellcheck.com that mklement0 suggested, and its hints were basically all to double quote things more. When I double quoted "$#", the program correctly printed the file sample document.txt, but then directly after that, it gave me a "binary operator expected" error. Here is a print out of what it looks like now:
Update #2 [problem solved?]
OK, it turns out that I had a typo which was causing it to default to an earlier version of my function called myls_internal when it recursed. This earlier version didn't mark directories with a forward slash. The error message in the "Update" section was also fixed. I changed the line
myls_internal "$file/*"
to
recls_internal $file/*
and now it seems to work properly. If anyone is in the middle of writing an answer, I still appreciate your insights as I don't really understand the mechanics of how quoting "$#" fixed the spacing issue.
Fixed code:
recls () {
# store current working directory
# issues: seems bad to have cwd defined up here and used down below in getAbsolutePath -- too much coupling
cwd=$PWD
# get absolute path of arg
argdir=$(getAbsolutePath "$#")
# check if it exists
if [ ! -e $argdir ]; then
echo "$argdir does not exist"
return 1
fi
echo "$argdir exists"
# check if it's a directory
if [ ! -d $argdir ]; then
echo "$argdir is not a directory"
return 2
fi
echo "$argdir is a directory"
tab=""
recls_internal $argdir
return 0
}
recls_internal () {
for file in "$#"; do
echo -n "$tab${file##/*/}"
if [ -d "$file" ]; then
# print forward slash to show it's a directory
echo "/"
savedtab=$tab
tab="$tab "
recls_internal $file/*
tab=$savedtab
else
# if not a directory, print a new line
echo ""
fi
done
}
getAbsolutePath () {
if [ -z ${1##/*} ]; then
echo "$1"
else
echo "$cwd/$1"
fi
}
Fixed output:
Update #3
The line
recls_internal $file/*
should instead be
recls_internal "$file"/*
which handles directories with spaces in them correctly. Otherwise, a folder such as cs 350 containing Homework1.pdf and Homework2.pdf will expand to
cs 350/Homework1.pdf 350/Homework2.pdf
when it should be
cs 350/Homework1.pdf cs 350/Homework2.pdf
I think? I don't really get the finer details of what's going on, but that seemed to fix it.
To illustrate the difference between "$#" and $#, let us consider the two following functions:
f() { for i in $#; do echo $i; done; }
g() { for i in "$#"; do echo $i; done; }
When calling these function with the parameters a "b c" "d e" the result will be
function f
f a "b c" "d e"
a
b
c
d
e
function g
g a "b c" "d e"
a
b c
d e
So when "$#" is within double quotes, the expansion keeps each parameter in a separate word (even if the parameter contains one or more space).
When $# (without double quotes) is expanded, a parameter with a space will be considered as two words.
In your script, you need also to surround argdir and file with double quotes. It is useful when the name of a directory or a file contains space so the name will be considered as a single value. Below your script modified.
#! /bin/bash -u
recls () {
# store current working directory
# issues: seems bad to have cwd defined up here and used down below in getAbsolutePath -- too much coupling
cwd=$PWD
# get absolute path of arg
argdir=`getAbsolutePath "$#"`
# check if it exists
if [ ! -e "$argdir" ]; then
echo "$argdir does not exist"
return 1
fi
echo "$argdir exists"
# check if it's a directory
if [ ! -d "$argdir" ]; then
echo "$argdir is not a directory"
return 2
fi
echo "$argdir is a directory"
tab=""
recls_internal "$argdir"
return 0
}
recls_internal () {
for file in "$#"; do
echo -n "$tab${file##/*/}"
if [ -d "$file" ]; then
# print forward slash to show it's a directory
echo "/"
savedtab=$tab
tab="$tab "
recls_internal "$file"/*
tab=$savedtab
else
# if not a directory, print a new line
echo ""
fi
done
}
getAbsolutePath () {
if [ -z ${1##/*} ]; then
echo "$1"
else
echo "$cwd/$1"
fi
}
Related
I was doing this little script in which the first argument must be a path to an existing directory and the second any other thing.
Each object in the path indicated in the first argument must be renamed so that the new
name is the original that was added as a prefix to the character string passed as the second argument. Example, for the string "hello", the object OBJECT1 is renamed hello.OBJECT1 and so on
Additionally, if an object with the new name is already present, a message is shown by a standard error output and the operation is not carried out continuing with the next object.
I have the following done:
#! /bin/bash
if [ "$#" != 2 ]; then
exit 1
else
echo "$2"
if [ -d "$1" ]; then
echo "directory"
for i in $(ls "$1")
do
for j in $(ls "$1")
do
echo "$i"
if [ "$j" = "$2"."$i" ]; then
exit 1
else
mv -v "$i" "$2"."$i"
echo "$2"."$i"
fi
done
done
else
echo "no"
fi
fi
I am having problems if I run the script from another file other than the one I want to do it, for example if I am in /home/pp and I want the changes to be made in /home/pp/rr, since that is the only way It does in the current.
I tried to change the ls to catch the whole route with
ls -R | sed "s;^;pwd;" but the route catches me badly.
Using find you can't because it puts me in front of the path and doesn't leave the file
Then another question, to verify that that object that is going to create new is not inside, when doing it with two for I get bash errors for all files and not just for coincidences
I'm starting with this scripting, so it has to be a very simple solution thing
An obvious answer to your question would be to put a cd "$2 in the script to make it work. However, there are some opportunities in this script for improvement.
#! /bin/bash
if [ "$#" != 2 ]; then
You might put an error message here, for example, echo "Usage: $0 dir prefix" or even a more elaborate help text.
exit 1
else
echo $2
Please quote, as in echo "$2".
if [ -d $1 ]; then
Here, the quotes are important. Suppose that your directory name has a space in it; then this if would fail with bash: [: a: binary operator expected. So, put quotes around the $1: if [ -d "$1" ]; then
echo "directory"
This is where you could insert the cd "$1".
for i in $(ls $1)
do
It is almost always a bad idea to parse the output of ls. Once again, this for-loop will fail if a file name has a space in it. A possible improvement would be for i in "$1"/* ; do.
for j in $(ls $1)
do
echo $i
if [ $j = $2.$i ]; then
exit 1
else
The logic of this section seems to be: if a file with the prefix exists, then exit instead of overwriting. It is always a good idea to tell why the script fails; an echo before the exit 1 will be helpful.
The question is why you use the second loop? a simple if [ -f "$2.$i ] ; then would do the same, but without the second loop. And it will therefore be faster.
mv -v $i $2.$i
echo $2.$i
Once again: use quotes!
fi
done
done
else
echo "no"
fi
fi
So, with all the remarks, you should be able to improve your script. As tripleee said in his comment, running shellcheck would have provided you with most of the comment above. But he also mentioned basename, which would be useful here.
With all that, this is how I would do it. Some changes you will probably only appreciate in a few months time when you need some changes to the script and try to remember what the logic was that you had in the past.
#!/bin/bash
if [ "$#" != 2 ]; then
echo "Usage: $0 directory prefix" >&2
echo "Put a prefix to all the files in a directory." >&2
exit 1
else
directory="$1"
prefix="$2"
if [ -d "$directory" ]; then
for f in "$directory"/* ; do
base=$(basename "$f")
if [ -f "Sdirectory/$prefix.$base" ] ; then
echo "This would overwrite $prefix.$base; exiting" >&2
exit 1
else
mv -v "$directory/$base" "$directory/$prefix.$base"
fi
done
else
echo "$directory is not a directory" >&2
fi
fi
I am trying to get the filename in a folder with only one file in it.
FYI: The $FOLDER_TMP contains a space in it, that is why I use printf
function nameofkeyfile(){
FOLDER_TMP="${PWD%/*/*}/folder/"
FOLDER=$(printf %q "${FOLDER_TMP}")
FILENAME=ls "$FOLDER" # Error: No such file or directory
# or this: FILENAME=$(ls "$FOLDER") # Error: No such file or directory
FNAME=`basename $FILENAME`
}
The problem is the line:
FILENAME=ls "$FOLDER" # Error: No such file or directory
Do you know why - and yes the folder is there?
And if I echo the $FOLDER it gives me the right folder.
I am trying to get the filename in a folder with only one file in it.
You definitely have the wrong approach.
Instead, consider using globbing like so:
The assignment
fname=( "${PWD%/*/*}"/folder/* )
will populate the array fname will the expansion of the given glob: that is, all files in the directory "${PWD%/*/*}"/folder/, if any. If there are no files at all, your array will contain the glob, verbatim.
Hence, a more robust approach is the following:
nameofkeyfile() {
fname=( "${PWD%/*/*}"/folder/* )
# Now check that there's at most one element in the array
if (( ${#fname[#]} > 1 )); then
echo "Oh no, there are too many files in your folder"
return 1
fi
# Now check that there is a file
if [[ ! -f ${fname[0]} ]]; then
echo "Oh no, there are no files in your folder"
return 1
fi
# Here, all is good!
echo "Your file is: $fname"
}
This uses Bash (named) arrays. If you want the function to be POSIX-compliant, it's rather straightforward since POSIX shells have an unnamed array (the positional parameters):
# POSIX-compliant version
nameofkeyfile() {
set -- "${PWD%/*/*}"/folder/*
# Now check that there's at most one element in the array
if [ "$#" -gt 1 ]; then
echo "Oh no, there are too many files in your folder"
return 1
fi
# Now check that there is a file
if [ ! -f "$1" ]; then
echo "Oh no, there are no files in your folder"
return 1
fi
# Here, all is good!
echo "Your file is: $1, I'll store it in variable fname for you"
fname=$1
}
I didn't strip the full path from the filename, but that's really easy (don't use basename for that!):1
fname=${fname##*/}
More precisely: in the Bash version, you'd use:
fname=${fname[0]##*/}
and in the POSIX version you'd use:
fname=${1##*/}
1there's a catch when using parameter expansions to get the basename, it's the case of /. But it seems you won't be in this case, so it's all safe!
To store the output ls "$FOLDER" in a variable, put it in a sub-shell:
FILENAME=$(ls "$FOLDER")
Another problem is the printf.
It adds escaping backslashes in the string,
and when you try to list the directory in the next step,
those backslashes are used literally by the shell.
So drop the printf:
function nameofkeyfile() {
FOLDER="${PWD%/*/*}/folder/"
FILENAME=$(ls "$FOLDER")
FNAME=$(basename $FILENAME)
}
Lastly, it's better to use $(...) than `...`:
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.
#!/bin/bash
traverse() {
local x=$1
if [ -d $x ]
then
lst=(`ls $x`)
for((i=${#lst[#]}; --i;)); do
echo "${lst[i]}"
done
else echo "not a directory"
fi
}
traverse
I want to pass a parameter such as "/path/to/this/directory/" when executing program but only works if I'm running the program in the same directory as my bash script file and any other parameter I pass is completely ignored.
the script is supposed to take a parameter and check if it's a directory and if it's a directory then list all the files/folders in descending order. If not display error message.
What is wrong with code, thanks!
This happens because $1 in the function refers to traverse's parameters, not your script's parameters.
To run your function once with each argument, use
for arg in "$#" # "$#" is also the default, so you can drop the 'in ..'
do
traverse "$arg"
done
If you in the future want to pass all the script's parameters to a function, use
myfunc "$#"
This is just the problem at hand, though. Other problems include not quoting your variables and using command expansion of ls, lst=(`ls $x`), instead of globs, lst=( "$x"/* )
You don't need to call ls for that. You can use this code:
traverse() {
local x="$1"
if [ -d "$x" ]; then
arr=( "$x/"* )
for ((i=${#arr[#]}; i>0; i--)); do
echo "${arr[$i]}"
done
else
echo "not a directory"
fi
}
"That other guy" has the right answer. The reason it always looks at the current directory:
you invoke traverse with no arguments
the $1 in the traverse function is empty, therefore $x is empty
the test is therefore [ -d ], and when [ is given 1 argument, it returns success if the argument is not empty. Your if command always executes the "true" block and ls $x is just ls when x is empty
Use [[ ... ]] with bash: it is smarter about empty arguments. Otherwise, quote your variables:
$ x=; [ -d $x ] && echo always true || echo not a directory
always true
$ x=; [[ -d $x ]] && echo always true || echo not a directory
not a directory
$ x=; [ -d "$x" ] && echo always true || echo not a directory
not a directory
I have to write a script that accepts 1+ source files and destination directory as arguments. I've attempted to write an error message for filenames that contain spaces, but its getting an error when I enter more than 1 argument with a space in between as well. Any help would be appreciated.. This is what I wrote so far:
if [ "$#" -eq "$(echo "$#" | wc -w)" ]
then
echo "Invalid arguments. Filenames may not contain spaces."
echo "usage: bkup file1 [file2...] bkup_directory"
exit 13
fi
You MAY want to try:
if [ $filename == "*" "*" ]; then
echo your error message here
fi
for the testing of spaces in filenames.
As for actually getting the args
$1 is the variable for the first arg and $2 for the second and so on so something like this might work:
if [ $2 == "*" ]; then
echo NO NO NO NOT TODAY!!!
fi
this would check to see if the second argument says anything at all and so if it did we can assume that it is part of the file name (THIS WOULD ONLY WORK IF THE FILENAME IS THE LAST ARGUMENT) OR:
if [ $2 == "*" ]; then
fil=$1 + \
filename=&fil + $2
fi
this would automatically change their filename into an acceptable format for the system an you would not need an error message.
but i am also new to bash so this could not be what you are looking for or I could have the right idea and all this could be complete whooey..... But if I helped then I'm glad I could.