bash: penggunaan prosedural whitespace-safe dari find menjadi select

12

Diberikan nama file ini:

$ ls -1
file
file name
otherfile

bash itu sendiri tidak masalah dengan embedded whitespace:

$ for file in *; do echo "$file"; done
file
file name
otherfile
$ select file in *; do echo "$file"; done
1) file
2) file name
3) otherfile
#?

Namun, kadang-kadang saya mungkin tidak ingin bekerja dengan setiap file, atau bahkan secara ketat $PWD, yang mana findmasuk. Yang juga menangani spasi putih secara nominal:

$ find -type f -name file\*
./file
./file name
./directory/file
./directory/file name

Saya mencoba untuk menyusun versi whispace-aman ini scriptlet yang akan mengambil output dari finddan menyampaikannya ke select:

$ select file in $(find -type f -name file); do echo $file; break; done
1) ./file
2) ./directory/file

Namun, ini meledak dengan spasi putih di nama file:

$ select file in $(find -type f -name file\*); do echo $file; break; done
1) ./file        3) name          5) ./directory/file
2) ./file        4) ./directory/file  6) name

Biasanya, aku akan menyelesaikan masalah ini dengan bermain-main IFS. Namun:

$ IFS=$'\n' select file in $(find -type f -name file\*); do echo $file; break; done
-bash: syntax error near unexpected token `do'
$ IFS='\n' select file in $(find -type f -name file\*); do echo $file; break; done
-bash: syntax error near unexpected token `do'

Apa solusinya?

DopeGhoti
sumber
1
Jika Anda hanya menggunakan findkarena kemampuannya untuk mencocokkan nama file tertentu, Anda dapat menggunakan select file in **/file*(setelah pengaturan shopt -s globstar) dalam bash4 atau lebih baru.
chepner

Jawaban:

14

Jika Anda hanya perlu menangani spasi dan tab (bukan baris baru yang disematkan) maka Anda dapat menggunakan mapfile(atau sinonimnya readarray) untuk membaca ke dalam array misalnya diberikan

$ ls -1
file
other file
somefile

kemudian

$ 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

Jika Anda lakukan perlu menangani baris baru, dan Anda bashversi menyediakan null-delimited mapfile1 , maka Anda dapat memodifikasi bahwa untuk IFS= mapfile -t -d '' files < <(find . -type f -print0). Jika tidak, kumpulkan array yang setara dari findoutput yang dibatasi-nol menggunakan readloop:

$ 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 yang -dpilihan ditambahkan ke mapfiledalam bashversi 4.4 iirc

Steeldriver
sumber
2
+1 untuk kata kerja lain yang belum pernah saya gunakan
roaima
Memang, mapfileini adalah hal baru bagi saya juga. Pujian.
DopeGhoti
The while IFS= readVersi bekerja kembali di bash v3 (yang penting bagi kita menggunakan MacOS).
Gordon Davisson
3
+1 untuk find -print0varian; menggerutu karena meletakkannya setelah versi yang diketahui salah, dan menggambarkannya hanya untuk digunakan jika ada yang tahu bahwa mereka perlu menangani baris baru. Jika seseorang hanya menangani hal-hal yang tidak terduga di tempat-tempat yang diharapkan, ia tidak akan pernah menangani hal yang tidak terduga sama sekali.
Charles Duffy
8

Jawaban ini memiliki solusi untuk semua jenis file. Dengan baris atau spasi baru.
Ada solusi untuk bash baru-baru ini serta bash kuno dan bahkan kerang posix lama.

Pohon yang tercantum di bawah dalam jawaban ini [1] digunakan untuk pengujian.

Pilih

Mudah selectuntuk bekerja dengan array:

$ dir='deep/inside/a/dir'
$ arr=( "$dir"/* )
$ select var in "${arr[@]}"; do echo "$var"; break; done

Atau dengan parameter posisi:

$ set -- "$dir"/*
$ select var; do echo "$var"; break; done

Jadi, satu-satunya masalah sebenarnya adalah mendapatkan "daftar file" (dibatasi dengan benar) di dalam array atau di dalam Parameter Posisi. Teruslah membaca.

pesta

Saya tidak melihat masalah yang Anda laporkan dengan bash. Bash dapat mencari di dalam direktori yang diberikan:

$ 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>

Atau, jika Anda menyukai 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>

Perhatikan bahwa sintaks di atas akan bekerja dengan benar dengan shell (wajar) (setidaknya csh).

Satu-satunya batasan yang dimiliki sintaks di atas adalah turun ke direktori lain.
Tapi bash bisa melakukan itu:

$ 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>

Untuk memilih hanya beberapa file (seperti yang berakhir pada file) cukup ganti *:

$ 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>

kuat

Ketika Anda menempatkan "ruang- aman " di judul, saya akan menganggap bahwa apa yang Anda maksudkan adalah " kuat ".

Cara paling sederhana untuk menjadi kuat tentang spasi (atau baris baru) adalah menolak pemrosesan input yang memiliki spasi (atau baris baru). Cara yang sangat sederhana untuk melakukan ini di shell adalah keluar dengan kesalahan jika ada nama file yang mengembang dengan spasi. Ada beberapa cara untuk melakukan ini, tetapi yang paling ringkas (dan posix) (tetapi terbatas pada satu isi direktori, termasuk nama direktori dan menghindari dot-file) adalah:

$ 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.

Jika solusi yang digunakan kuat di salah satu item tersebut, hapus tes.

Dalam bash, sub-direktori dapat diuji sekaligus dengan ** yang dijelaskan di atas.

Ada beberapa cara untuk memasukkan file dot, solusi Posix adalah:

set -- "$dir"/* "$dir"/.[!.]* "$dir"/..?*

Temukan

Jika find harus digunakan karena alasan tertentu, ganti pembatas dengan 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

Untuk membuat solusi POSIX yang valid di mana find tidak memiliki pembatas NUL dan tidak ada -d(atau -a) untuk membaca kita perlu pendekatan yang berbeda secara keseluruhan.

Kita perlu menggunakan kompleks -execdari find dengan panggilan ke shell:

find "$dir" -type f -exec sh -c '
    for f do
        echo "<$f>"
    done
    ' sh {} +

Atau, jika yang diperlukan adalah pilih (pilih adalah bagian dari bash, bukan 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] Pohon ini (\ 012 adalah baris baru):

$ tree
.
└── deep
    └── inside
        └── a
            └── dir
                ├── directory
                   ├── file
                   ├── file name
                   └── file with a \012newline
                ├── file
                ├── file name
                ├── otherfile
                ├── with a\012newline
                └── zz last file

Dapat dibangun dengan dua perintah ini:

$ mkdir -p deep/inside/a/dir/directory/
$ touch deep/inside/a/dir/{,directory/}{file{,\ {name,with\ a$'\n'newline}},zz\ last\ file}
Panah
sumber
6

Anda tidak bisa mengatur variabel di depan konstruksi perulangan, tetapi Anda bisa mengaturnya di depan kondisi. Inilah segmen dari halaman manual:

Lingkungan untuk perintah atau fungsi sederhana dapat ditambah sementara dengan mengawali dengan penugasan parameter, seperti yang dijelaskan di PARAMETER.

(Pengulangan bukan perintah sederhana .)

Berikut ini adalah konstruk yang umum digunakan yang menunjukkan skenario kegagalan dan kesuksesan:

IFS=$'\n' while read -r x; do ...; done </tmp/file     # Failure
while IFS=$'\n' read -r x; do ...; done </tmp/file     # Success

Sayangnya saya tidak bisa melihat cara untuk menanamkan perubahan IFSke dalam selectkonstruk sementara itu mempengaruhi proses yang terkait $(...). Namun, tidak ada yang mencegah IFSdiatur di luar loop:

IFS=$'\n'; while read -r x; do ...; done </tmp/file    # Also success

dan konstruk inilah yang bisa saya lihat berfungsi dengan select:

IFS=$'\n'; select file in $(find -type f -name 'file*'); do echo "$file"; break; done

Saat menulis kode defensif, saya akan merekomendasikan agar klausa dijalankan dalam subkulit, atau IFSdan SHELLOPTSdisimpan dan dipulihkan di sekitar blok:

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
roaima
sumber
5
Anggap itu IFS=$'\n'aman tidak berdasar. Nama file sangat bisa mengandung literal baris baru.
Charles Duffy
4
Saya terus terang ragu untuk menerima pernyataan tentang kemungkinan dataset seseorang pada nilai nominal, bahkan ketika ada. Kejadian kehilangan data terburuk yang pernah saya hadapi adalah kasus di mana skrip pemeliharaan yang bertanggung jawab untuk membersihkan cadangan lama mencoba menghapus file yang telah dibuat oleh skrip Python menggunakan modul C dengan dereferensi pointer buruk yang membuang sampah acak - termasuk wildcard yang dipisahkan spasi - ke dalam nama.
Charles Duffy
2
Orang-orang yang membuat skrip shell melakukan pembersihan file-file itu tidak repot-repot mengutip karena nama "tidak mungkin" gagal mencocokkan [0-9a-f]{24}. TB cadangan data yang digunakan untuk mendukung tagihan pelanggan hilang.
Charles Duffy
4
Setuju dengan @CharlesDuffy sepenuhnya. Tidak menangani kasus tepi hanya baik ketika Anda bekerja secara interaktif dan dapat melihat apa yang Anda lakukan. selectoleh desainnya adalah untuk solusi scripted , sehingga harus selalu dirancang untuk menangani kasus tepi.
Wildcard
2
@ilkkachu, tentu saja - Anda tidak akan pernah menelepon selectdari shell tempat Anda mengetik perintah untuk dijalankan, tetapi hanya di skrip, di mana Anda menjawab prompt yang disediakan oleh skrip itu , dan di mana skrip itu berada mengeksekusi logika yang telah ditentukan (dibangun tanpa sepengetahuan nama file yang dioperasikan) berdasarkan input itu.
Charles Duffy
4

Saya mungkin berada di luar yurisdiksi saya di sini, tetapi mungkin Anda dapat mulai dengan sesuatu seperti ini, setidaknya tidak ada masalah dengan spasi putih:

find -maxdepth 1 -type f -printf '%f\000' | {
    while read -d $'\000'; do
            echo "$REPLY"
            echo
    done
}

Untuk menghindari anggapan yang keliru, seperti disebutkan dalam komentar, perlu diketahui bahwa kode di atas setara dengan:

   find -maxdepth 1 -type f -printf '%f\0' | {
        while read -d ''; do
                echo "$REPLY"
                echo
        done
    }
Flerb
sumber
read -dadalah solusi cerdas; Terima kasih untuk ini.
DopeGhoti
2
read -d $'\000'adalah persis identik dengan read -d '', tetapi untuk menyesatkan orang-orang tentang kemampuan bash (menyiratkan, tidak benar, bahwa itu dapat mewakili NULs literal dalam string). Jalankan s1=$'foo\000bar'; s2='foo', dan kemudian coba temukan cara untuk membedakan antara kedua nilai tersebut. (Versi masa depan dapat dinormalkan dengan perilaku substitusi perintah dengan membuat nilai yang disimpan setara dengan foobar, tapi itu tidak terjadi hari ini).
Charles Duffy