bash: whitespace-sichere prozedurale Verwendung von find in select

12

Angesichts dieser Dateinamen:

$ ls -1
file
file name
otherfile

bash Selbst mit eingebettetem Whitespace ist das völlig in Ordnung:

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

Manchmal möchte ich jedoch möglicherweise nicht mit jeder Datei arbeiten oder sogar nur mit der Datei, in der $PWDdie Datei gespeichert ist find.

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

Ich versuche, eine Whispace-sichere Version dieses Scriptlets zu erstellen, die die Ausgabe von findund Präsentation in select:

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

Dies explodiert jedoch mit Leerzeichen in den Dateinamen:

$ 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

Normalerweise würde ich das umgehen, indem ich herumspiele IFS. Jedoch:

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

Was ist die Lösung dafür?

DopeGhoti
quelle
1
Wenn Sie nur die findFähigkeit verwenden, einem bestimmten Dateinamen zu entsprechen, können Sie select file in **/file*(nach dem Einstellen shopt -s globstar) einfach bash4 oder höher verwenden.
Chepner

Antworten:

14

Wenn Sie nur Leerzeichen und Tabulatoren (keine eingebetteten Zeilenumbrüche) verwenden müssen, können Sie mapfile(oder sein Synonym readarray) verwenden, um in ein Array einzulesen, das z. B. angegeben wurde

$ ls -1
file
other file
somefile

dann

$ 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

Wenn Sie tun müssen , um Griff Zeilenumbrüche und Ihre bashVersion bietet eine Null-separierte mapfile1 , dann können Sie das ändern IFS= mapfile -t -d '' files < <(find . -type f -print0). Andernfalls stellen Sie findmithilfe einer readSchleife ein äquivalentes Array aus einer durch Nullen getrennten Ausgabe zusammen :

$ 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 Die -dOption wurde mapfilein bashVersion 4.4 iirc hinzugefügt

Stahlfahrer
quelle
2
+1 für ein anderes Verb, das ich vorher nicht benutzt habe
roaima
In der Tat mapfileist eine neue für mich auch. Ein dickes Lob.
DopeGhoti
Die while IFS= readVersion funktioniert bereits in Bash V3 (wichtig für Benutzer von MacOS).
Gordon Davisson
3
+1 für die find -print0Variante; meckern Sie , wenn Sie es nach einer bekanntermaßen falschen Version einfügen und nur dann beschreiben, wenn Sie wissen, dass Sie mit Zeilenumbrüchen umgehen müssen. Wenn man das Unerwartete nur an Orten behandelt, an denen es erwartet wird, wird man das Unerwartete überhaupt nicht behandeln.
Charles Duffy
8

Diese Antwort bietet Lösungen für alle Arten von Dateien. Mit Zeilenumbrüchen oder Leerzeichen.
Es gibt Lösungen für aktuelle Bash sowie alte Bash- und sogar alte Posix-Shells.

Der in dieser Antwort unten aufgeführte Baum [1] wird für die Tests verwendet.

wählen

Es ist einfach, selectmit einem Array zu arbeiten:

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

Oder mit den Positionsparametern:

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

Das einzige wirkliche Problem besteht also darin, die "Liste der Dateien" (korrekt abgegrenzt) in einem Array oder in den Positionsparametern abzurufen. Weiter lesen.

Bash

Ich sehe das Problem, das Sie mit Bash melden, nicht. Bash kann in einem bestimmten Verzeichnis suchen:

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

Oder, wenn Sie eine Schleife mögen:

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

Beachten Sie, dass die obige Syntax mit jeder (vernünftigen) Shell korrekt funktioniert (zumindest nicht mit csh).

Die obige Syntax beschränkt sich nur darauf, in andere Verzeichnisse zu gelangen.
Aber bash könnte das tun:

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

Um nur einige Dateien auszuwählen (wie die, die auf file enden ), ersetzen Sie einfach das *:

$ 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

Wenn Sie einen „raum- platzieren sicher “ in den Titel, ich gehe davon aus, dass , was Sie meinten „war robust “.

Die einfachste Möglichkeit, Leerzeichen (oder Zeilenumbrüche) zu vermeiden, besteht darin, die Verarbeitung von Eingaben mit Leerzeichen (oder Zeilenumbrüchen) abzulehnen. Eine sehr einfache Möglichkeit, dies in der Shell zu tun, besteht darin, mit einem Fehler zu beenden, wenn ein Dateiname mit einem Leerzeichen erweitert wird. Es gibt mehrere Möglichkeiten, dies zu tun, aber die kompakteste (und posix) (jedoch auf einen Verzeichnisinhalt beschränkt, einschließlich suddirectories-Namen und Vermeiden von Punktdateien) ist:

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

Wenn die verwendete Lösung in einem dieser Punkte robust ist, entfernen Sie den Test.

In bash konnten Unterverzeichnisse sofort mit dem oben erläuterten ** getestet werden.

Es gibt verschiedene Möglichkeiten, Punktdateien einzuschließen. Die Posix-Lösung lautet:

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

finden

Wenn find aus irgendeinem Grund verwendet werden muss, ersetzen Sie das Trennzeichen durch eine 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

Um eine gültige POSIX-Lösung zu erstellen, bei der find kein NUL-Trennzeichen hat und es kein -d(noch -a) lesbares gibt, benötigen wir einen völlig anderen Ansatz.

Wir müssen einen Komplex -execaus find mit einem Aufruf an eine Shell verwenden:

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

Oder wenn ein select benötigt wird (select ist Teil von bash, nicht 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] Dieser Baum (die \ 012 sind Zeilenumbrüche):

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

Könnte mit diesen beiden Befehlen erstellt werden:

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

Sie können eine Variable nicht vor ein Schleifenkonstrukt setzen, aber Sie können sie vor die Bedingung setzen. Hier ist das Segment aus der Manpage:

Die Umgebung für einen einfachen Befehl oder eine Funktion kann vorübergehend erweitert werden, indem Parameterzuweisungen vorangestellt werden, wie oben unter PARAMETER beschrieben.

(Eine Schleife ist kein einfacher Befehl .)

Hier ist ein häufig verwendetes Konstrukt, das die Fehler- und Erfolgsszenarien demonstriert:

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

Leider sehe ich keine Möglichkeit, eine Änderung IFSin das selectKonstrukt einzubetten, obwohl sie sich auf die Verarbeitung eines assoziierten Objekts auswirkt $(...). Es gibt jedoch nichts zu verhindern IFS, außerhalb der Schleife festgelegt zu werden:

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

und es ist dieses Konstrukt, mit dem ich arbeiten kann select:

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

Wenn defensiven Code schreiben würde ich empfehlen , dass die Klausel entweder in einer Subshell ausgeführt werden, oder IFSund SHELLOPTSgespeichert und um den Block wieder hergestellt:

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
quelle
5
Die Annahme, dass dies IFS=$'\n'sicher ist, ist unbegründet. Dateinamen können problemlos Newline-Literale enthalten.
Charles Duffy
4
Ich zögere offen gesagt, solche Aussagen über den möglichen Datensatz zum Nennwert zu akzeptieren, selbst wenn vorhanden. Das schlimmste Datenverlustereignis, für das ich anwesend war, war ein Fall, in dem ein Wartungsskript, das für die Bereinigung alter Sicherungen verantwortlich ist, versuchte, eine Datei zu entfernen, die von einem Python-Skript mit einem C-Modul mit einer fehlerhaften Zeiger-Dereferenzierung erstellt wurde, bei der zufälliger Müll ausgegeben wurde - Einschließlich eines durch Leerzeichen getrennten Platzhalters - in den Namen.
Charles Duffy
2
Die Leute, die das Shell-Skript für die Bereinigung dieser Dateien erstellt haben, haben sich nicht die Mühe gemacht, Anführungszeichen zu setzen, weil die Namen "unmöglich" nicht übereinstimmen konnten [0-9a-f]{24}. Die TB der zur Unterstützung der Kundenabrechnung verwendeten Datensicherungen gingen verloren.
Charles Duffy
4
Stimmen Sie mit @CharlesDuffy vollständig überein. Die Bearbeitung von Edge Cases ist nur dann in Ordnung, wenn Sie interaktiv arbeiten und sehen, was Sie tun. selectAufgrund seines Designs ist es für skriptbasierte Lösungen gedacht, daher sollte es immer für Edge-Cases ausgelegt sein.
Wildcard
2
@ilkkachu, natürlich - Sie würden niemals selectvon einer Shell aus aufrufen, in der Sie die auszuführenden Befehle eingeben, sondern nur von einem Skript, in dem Sie auf eine von diesem Skript bereitgestellte Eingabeaufforderung antworten und in dem sich das Skript befindet Ausführen einer vordefinierten Logik (erstellt ohne Kenntnis der Dateinamen, mit denen gearbeitet wird) basierend auf dieser Eingabe.
Charles Duffy
4

Vielleicht bin ich hier nicht zuständig, aber vielleicht können Sie mit so etwas anfangen, zumindest hat es keine Probleme mit dem Leerzeichen:

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

Beachten Sie zur Vermeidung möglicher falscher Annahmen, wie in den Kommentaren angegeben, dass der obige Code äquivalent ist zu:

   find -maxdepth 1 -type f -printf '%f\0' | {
        while read -d ''; do
                echo "$REPLY"
                echo
        done
    }
flerb
quelle
read -dist eine clevere Lösung; Danke dafür.
DopeGhoti
2
read -d $'\000'ist genau identisch mit read -d '', aber für irreführende Leute über die Fähigkeiten von bash (was fälschlicherweise impliziert, dass es in der Lage ist, wörtliche NULs in Strings darzustellen). Führen Sie aus s1=$'foo\000bar'; s2='foo', und versuchen Sie dann, eine Möglichkeit zu finden, zwischen den beiden Werten zu unterscheiden. (Eine zukünftige Version kann sich mit dem Befehlsersetzungsverhalten normalisieren, indem der gespeicherte Wert gleichgesetzt wird foobar, aber das ist heute nicht der Fall.)
Charles Duffy