bash: whitespace-safe procedural use of find into select
If you only need to handle spaces and tabs (not embedded newlines) then you can use mapfile
(or its synonym, readarray
) to read into an array e.g. given
$ ls -1
file
other file
somefile
then
$ IFS= mapfile -t files < <(find . -type f)
$ select f in "${files[@]}"; do ls "$f"; break; done
1) ./file
2) ./somefile
3) ./other file
#? 3
./other file
If you do need to handle newlines, and your bash
version provides a null-delimited mapfile
1, then you can modify that to IFS= mapfile -t -d '' files < <(find . -type f -print0)
. Otherwise, assemble an equivalent array from null-delimited find
output using a read
loop:
$ touch $'filename\nwith\nnewlines'
$
$ files=()
$ while IFS= read -r -d '' f; do files+=("$f"); done < <(find . -type f -print0)
$
$ select f in "${files[@]}"; do ls "$f"; break; done
1) ./file
2) ./somefile
3) ./other file
4) ./filename
with
newlines
#? 4
./filename?with?newlines
1 the -d
option was added to mapfile
in bash
version 4.4 iirc
This answer has solutions for any type of files. With newlines or spaces.
There are solutions for recent bash as well as ancient bash and even old posix shells.
The tree listed down below in this answer[1] is used for the tests.
select
It is easy to get select
to work either with an array:
$ dir='deep/inside/a/dir'
$ arr=( "$dir"/* )
$ select var in "${arr[@]}"; do echo "$var"; break; done
Or with the positional parameters:
$ set -- "$dir"/*
$ select var; do echo "$var"; break; done
So, the only real problem is to get the "list of files" (correctly delimited) inside an array or inside the Positional Parameters. Keep reading.
bash
I don't see the problem you report with bash. Bash is able to search inside a given directory:
$ dir='deep/inside/a/dir'
$ printf '<%s>\n' "$dir"/*
<deep/inside/a/dir/directory>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>
Or, if you like a loop:
$ set -- "$dir"/*
$ for f; do printf '<%s>\n' "$f"; done
<deep/inside/a/dir/directory>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>
Note that the syntax above will work correctly with any (reasonable) shell ( not csh at least).
The only limit that the syntax above has is to descend into other directories.
But bash could do that:
$ shopt -s globstar
$ set -- "$dir"/**/*
$ for f; do printf '<%s>\n' "$f"; done
<deep/inside/a/dir/directory>
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/directory/file name>
<deep/inside/a/dir/directory/file with a
newline>
<deep/inside/a/dir/directory/zz last file>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>
To select only some files (like the ones that end in file) just replace the *:
$ set -- "$dir"/**/*file
$ printf '<%s>\n' "$@"
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/directory/zz last file>
<deep/inside/a/dir/file>
<deep/inside/a/dir/zz last file>
robust
When you place a "space-safe" in the title, I am going to assume that what you meant was "robust".
The simplest way to be robust about spaces (or newlines) is to reject the processing of input that has spaces (or newlines). A very simple way to do this in the shell is to exit with an error if any file name expands with an space. There are several ways to do this, but the most compact (and posix) (but limited to one directory contents, including suddirectories names and avoiding dot-files) is:
$ set -- "$dir"/file* # read the directory
$ a="$(printf '%s' "$@" x)" # make it a long string
$ [ "$a" = "${a%% *}" ] || echo "exit on space" # if $a has an space.
$ nl='
' # define a new line in the usual posix way.
$ [ "$a" = "${a%%"$nl"*}" ] || echo "exit on newline" # if $a has a newline.
If the solution used is robust in any of those items, remove the test.
In bash, sub- directories could be tested at once with the ** explained above.
There are a couple of ways to include dot files, the Posix solution is:
set -- "$dir"/* "$dir"/.[!.]* "$dir"/..?*
find
If find must be used for some reason, replace the delimiter with a NUL (0x00).
bash 4.4+
$ readarray -t -d '' arr < <(find "$dir" -type f -name file\* -print0)
$ printf '<%s>\n' "${arr[@]}"
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/directory/file name>
<deep/inside/a/dir/directory/file with a
newline>
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/file>
bash 2.05+
i=1 # lets start on 1 so it works also in zsh.
while IFS='' read -d '' val; do
arr[i++]="$val";
done < <(find "$dir" -type f -name \*file -print0)
printf '<%s>\n' "${arr[@]}"
POSIXLY
To make a valid POSIX solution where find does not have a NUL delimiter and there is no -d
(nor -a
) for read we need an entirelly diferent aproach.
We need to use a complex -exec
from find with a call to a shell:
find "$dir" -type f -exec sh -c '
for f do
echo "<$f>"
done
' sh {} +
Or, if what is needed is a select (select is part of bash, not sh):
$ find "$dir" -type f -exec bash -c '
select f; do echo "<$f>"; break; done ' bash {} +
1) deep/inside/a/dir/file name
2) deep/inside/a/dir/zz last file
3) deep/inside/a/dir/file with a
newline
4) deep/inside/a/dir/directory/file name
5) deep/inside/a/dir/directory/zz last file
6) deep/inside/a/dir/directory/file with a
newline
7) deep/inside/a/dir/directory/file
8) deep/inside/a/dir/file
#? 3
<deep/inside/a/dir/file with a
newline>
[1] This tree (the \012 are newlines):
$ tree
.
└── deep
└── inside
└── a
└── dir
├── directory
│ ├── file
│ ├── file name
│ └── file with a \012newline
├── file
├── file name
├── otherfile
├── with a\012newline
└── zz last file
Could be built with this two commands:
$ mkdir -p deep/inside/a/dir/directory/
$ touch deep/inside/a/dir/{,directory/}{file{,\ {name,with\ a$'\n'newline}},zz\ last\ file}
You can't set a variable in front of a looping construct, but you can set it in front of the condition. Here's the segment from the man page:
The environment for any simple command or function may be augmented temporarily by prefixing it with parameter assignments, as described above in PARAMETERS.
(A loop isn't a simple command.)
Here's a commonly used construct demonstrating the failure and success scenarios:
IFS=$'\n' while read -r x; do ...; done </tmp/file # Failure
while IFS=$'\n' read -r x; do ...; done </tmp/file # Success
Unfortunately I cannot see a way to embed a changed IFS
into the select
construct while having it affect the processing of an associated $(...)
. However, there's nothing to prevent IFS
being set outside the loop:
IFS=$'\n'; while read -r x; do ...; done </tmp/file # Also success
and it's this construct that I can see works with select
:
IFS=$'\n'; select file in $(find -type f -name 'file*'); do echo "$file"; break; done
When writing defensive code I'd recommend that the clause either be run in a subshell, or IFS
and SHELLOPTS
saved and restored around the block:
OIFS="$IFS" IFS=$'\n' # Split on newline only
OSHELLOPTS="$SHELLOPTS"; set -o noglob # Wildcards must not expand twice
select file in $(find -type f -name 'file*'); do echo $file; break; done
IFS="$OIFS"
[[ "$OSHELLOPTS" !~ noglob ]] && set +o noglob