bash: utilisation procédurale sûre de l'espace de recherche dans select

12

Étant donné ces noms de fichiers:

$ ls -1
file
file name
otherfile

bash lui-même fait parfaitement bien avec les espaces blancs intégrés:

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

Cependant, parfois, je ne souhaiterais peut-être pas travailler avec tous les fichiers, ou même strictement dans $PWD, c'est là findqu'intervient. Qui gère également les espaces nominalement:

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

J'essaie de concocter une version sécurisée de ce scriptlet qui prendra la sortie de findet la présentera dans select:

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

Cependant, cela explose avec des espaces dans les noms de fichiers:

$ 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

Habituellement, je contournerais cela en jouant avec IFS. Pourtant:

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

Quelle est la solution pour ceci?

DopeGhoti
la source
1
Si vous utilisez uniquementfind pour sa capacité à correspondre à un nom de fichier particulier, vous pouvez simplement utiliser select file in **/file*(après le réglage shopt -s globstar) en bash4 ou version ultérieure.
chepner

Réponses:

14

Si vous avez seulement besoin de gérer les espaces et les tabulations (pas les sauts de ligne incorporés), vous pouvez utiliser mapfile(ou son synonyme readarray) pour lire dans un tableau, par exemple donné

$ ls -1
file
other file
somefile

ensuite

$ 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

Si vous avez besoin de gérer les sauts de ligne, et votre bashversion offre une valeur NULL délimité par mapfile1 , vous pouvez alors modifier ce à IFS= mapfile -t -d '' files < <(find . -type f -print0). Sinon, assemblez un tableau équivalent à partir d'une findsortie délimitée par des null à l' aide d'une readboucle:

$ 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 l' -doption a été ajoutée à mapfiledans la bashversion 4.4 IIRC

tournevis
la source
2
+1 pour un autre verbe que je n'ai pas utilisé auparavant
roaima
En effet, mapfilec'est nouveau pour moi aussi. Gloire.
DopeGhoti
La while IFS= readversion fonctionne en bash v3 (ce qui est important pour ceux d'entre nous qui utilisent macOS).
Gordon Davisson
3
+1 pour la find -print0variante; grogner pour l'avoir mis après une version incorrecte connue, et le décrire uniquement pour une utilisation si l'on sait qu'ils doivent gérer les retours à la ligne. Si l'on ne gère l'inattendu qu'aux endroits où il est attendu, on ne le manipulera jamais du tout.
Charles Duffy
8

Cette réponse propose des solutions pour tout type de fichiers. Avec des nouvelles lignes ou des espaces.
Il existe des solutions pour les bash récents ainsi que les anciens bash et même les anciens obus posix.

L'arbre ci-dessous dans cette réponse [1] est utilisé pour les tests.

sélectionner

Il est facile de selecttravailler soit avec un tableau:

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

Ou avec les paramètres positionnels:

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

Ainsi, le seul vrai problème est d'obtenir la "liste des fichiers" (correctement délimitée) à l'intérieur d'un tableau ou à l'intérieur des paramètres positionnels. Continue de lire.

frapper

Je ne vois pas le problème que vous signalez avec bash. Bash est capable de rechercher dans un répertoire donné:

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

Ou, si vous aimez une boucle:

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

Notez que la syntaxe ci-dessus fonctionnera correctement avec n'importe quel shell (raisonnable) (pas csh au moins).

La seule limite de la syntaxe ci-dessus est de descendre dans d'autres répertoires.
Mais bash pourrait faire ça:

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

Pour sélectionner uniquement certains fichiers (comme ceux qui se terminent par un fichier), remplacez simplement le *:

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

robuste

Lorsque vous placez un "espace sûr " dans le titre, je vais supposer que ce que vous vouliez dire était " robuste ".

Le moyen le plus simple d'être robuste sur les espaces (ou les retours à la ligne) est de rejeter le traitement des entrées qui ont des espaces (ou des retours à la ligne). Un moyen très simple de le faire dans le shell est de quitter avec une erreur si un nom de fichier se développe avec un espace. Il y a plusieurs façons de le faire, mais le plus compact (et posix) (mais limité à un contenu de répertoire, y compris les noms suddirectories et en évitant les fichiers dot) est:

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

Si la solution utilisée est robuste dans l'un de ces éléments, supprimez le test.

En bash, les sous-répertoires pouvaient être testés à la fois avec le ** expliqué ci-dessus.

Il existe plusieurs façons d'inclure des fichiers dot, la solution Posix est la suivante:

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

trouver

Si find doit être utilisé pour une raison quelconque, remplacez le délimiteur par un 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

Pour créer une solution POSIX valide où find n'a pas de délimiteur NUL et où il n'y a pas -d(ni -a) de lecture, nous avons besoin d'une approche entièrement différente.

Nous devons utiliser un complexe -execde find avec un appel à un shell:

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

Ou, si ce qui est nécessaire est une sélection (la sélection fait partie de bash, pas 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] Cet arbre (les \ 012 sont des sauts de ligne):

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

Pourrait être construit avec ces deux commandes:

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

Vous ne pouvez pas définir une variable devant une construction en boucle, mais vous pouvez la définir devant la condition. Voici le segment de la page de manuel:

L'environnement de toute commande ou fonction simple peut être temporairement augmenté en le préfixant avec des affectations de paramètres, comme décrit ci-dessus dans PARAMETRES.

(Une boucle n'est pas une simple commande .)

Voici une construction couramment utilisée illustrant les scénarios d'échec et de réussite:

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

Malheureusement, je ne vois pas de moyen d'incorporer une modification IFSdans la selectconstruction tout en ayant une incidence sur le traitement d'un associé $(...). Cependant, rien n'empêche d' IFSêtre placé en dehors de la boucle:

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

et c'est cette construction avec laquelle je peux voir fonctionner select:

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

Lors de l'écriture de code défensif, je recommanderais que la clause soit exécutée dans un sous-shell, et IFSet SHELLOPTSenregistrée et restaurée autour du bloc:

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
la source
5
Supposer que cela IFS=$'\n'est sûr n'est pas fondé. Les noms de fichiers sont parfaitement capables de contenir des littéraux de nouvelle ligne.
Charles Duffy
4
J'hésite franchement à accepter de telles affirmations sur son possible ensemble de données à leur valeur nominale, même lorsqu'elles sont présentes. Le pire événement de perte de données pour lequel j'ai été présent est un cas où un script de maintenance responsable du nettoyage des anciennes sauvegardes a tenté de supprimer un fichier qui avait été créé par un script Python à l'aide d'un module C avec un mauvais déréférencement de pointeur qui a jeté des ordures aléatoires - y compris un caractère générique séparé par des espaces - dans le nom.
Charles Duffy
2
Les gens qui construisaient le script shell en nettoyant ces fichiers n'ont pas pris la peine de citer parce que les noms «ne pouvaient pas» ne pouvaient pas correspondre [0-9a-f]{24}. Des To de sauvegardes de données utilisées pour prendre en charge la facturation client ont été perdus.
Charles Duffy
4
D'accord avec @CharlesDuffy complètement. Ne pas gérer les cas marginaux ne convient que lorsque vous travaillez de manière interactive et pouvez voir ce que vous faites. selectpar sa conception même est pour les solutions scriptées , il doit donc toujours être conçu pour gérer les cas de bord.
Wildcard
2
@ilkkachu, bien sûr - vous n'appeleriez jamais à selectpartir d'un shell où vous tapez les commandes à exécuter, mais uniquement à partir d'un script, où vous répondez à une invite fournie par ce script , et où se trouve ce script exécuter une logique prédéfinie (construite à l'insu des noms de fichiers utilisés) sur la base de cette entrée.
Charles Duffy
4

Je suis peut-être hors de ma juridiction ici, mais vous pouvez peut-être commencer par quelque chose comme ça, au moins cela n'a aucun problème avec les espaces blancs:

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

Pour éviter toute fausse hypothèse potentielle, comme indiqué dans les commentaires, sachez que le code ci-dessus est équivalent à:

   find -maxdepth 1 -type f -printf '%f\0' | {
        while read -d ''; do
                echo "$REPLY"
                echo
        done
    }
flerb
la source
read -dest une solution intelligente; Merci pour cela.
DopeGhoti
2
read -d $'\000'est exactement identique à read -d '', mais pour tromper les gens sur les capacités de bash (impliquant, à tort, qu'il est capable de représenter des NULs littéraux dans des chaînes). Exécutez s1=$'foo\000bar'; s2='foo', puis essayez de trouver un moyen de distinguer les deux valeurs. (Une future version pourrait se normaliser avec un comportement de substitution de commande en rendant la valeur stockée équivalente à foobar, mais ce n'est pas le cas aujourd'hui).
Charles Duffy