Itérer sur une liste de fichiers avec des espaces

202

Je souhaite parcourir une liste de fichiers. Cette liste est le résultat d'une findcommande, j'ai donc trouvé:

getlist() {
  for f in $(find . -iname "foo*")
  do
    echo "File found: $f"
    # do something useful
  done
}

C'est bien sauf si un fichier a des espaces dans son nom:

$ ls
foo_bar_baz.txt
foo bar baz.txt

$ getlist
File found: foo_bar_baz.txt
File found: foo
File found: bar
File found: baz.txt

Que puis-je faire pour éviter la division des espaces?

Gregseth
la source
Il s'agit essentiellement d'un sous-cas spécifique de Quand entourer les guillemets d'une variable shell?
tripleee

Réponses:

253

Vous pouvez remplacer l'itération basée sur les mots par une itération basée sur les lignes:

find . -iname "foo*" | while read f
do
    # ... loop body
done
Martin Clayton
la source
31
C'est extrêmement propre. Et me fait me sentir plus agréable que de changer d'IFS en conjonction avec une boucle for
Derrick
15
Cela divisera un seul chemin de fichier contenant un \ n. OK, ceux-ci ne devraient pas être là mais ils peuvent être créés:touch "$(printf "foo\nbar")"
Ollie Saunders
4
Pour empêcher toute interprétation de l'entrée (barres obliques inverses, espaces de début et de fin), utilisez IFS= while read -r fplutôt.
mklement0
2
Cette réponse montre une combinaison plus sûre de findet une boucle while.
moi
5
On dirait que souligner l'aspect, mais dans presque tous les cas simples, -execva être plus propre que une boucle explicite: find . -iname "foo*" -exec echo "File found: {}" \;. De plus, dans de nombreux cas, vous pouvez remplacer ce dernier \;par +pour mettre beaucoup de fichiers dans la même commande.
naught101
153

Il existe plusieurs façons pratiques d'y parvenir.

Si vous vouliez vous en tenir à votre version originale, cela pourrait être fait de cette façon:

getlist() {
        IFS=$'\n'
        for file in $(find . -iname 'foo*') ; do
                printf 'File found: %s\n' "$file"
        done
}

Cela échouera toujours si les noms de fichiers contiennent des sauts de ligne littéraux, mais les espaces ne le cassent pas.

Cependant, jouer avec IFS n'est pas nécessaire. Voici ma façon préférée de le faire:

getlist() {
    while IFS= read -d $'\0' -r file ; do
            printf 'File found: %s\n' "$file"
    done < <(find . -iname 'foo*' -print0)
}

Si vous trouvez la < <(command)syntaxe peu familière, vous devriez lire à propos de la substitution de processus . L'avantage de ceci for file in $(find ...)est que les fichiers avec des espaces, des retours à la ligne et d'autres caractères sont correctement gérés. Cela fonctionne car findavec -print0utilisera un null(aka \0) comme terminateur pour chaque nom de fichier et, contrairement à la nouvelle ligne, null n'est pas un caractère légal dans un nom de fichier.

L'avantage de cela par rapport à la version presque équivalente

getlist() {
        find . -iname 'foo*' -print0 | while read -d $'\0' -r file ; do
                printf 'File found: %s\n' "$file"
        done
}

Est-ce que toute affectation de variable dans le corps de la boucle while est préservée. Autrement dit, si vous redirigez vers whilecomme ci-dessus, le corps du whileest dans un sous-shell qui peut ne pas être ce que vous voulez.

L'avantage de la version de substitution de processus find ... -print0 | xargs -0est minime: la xargsversion est correcte si tout ce dont vous avez besoin est d'imprimer une ligne ou d'effectuer une seule opération sur le fichier, mais si vous devez effectuer plusieurs étapes, la version en boucle est plus facile.

EDIT : Voici un joli script de test afin que vous puissiez avoir une idée de la différence entre les différentes tentatives de résolution de ce problème

#!/usr/bin/env bash

dir=/tmp/getlist.test/
mkdir -p "$dir"
cd "$dir"

touch       'file not starting foo' foo foobar barfoo 'foo with spaces'\
    'foo with'$'\n'newline 'foo with trailing whitespace      '

# while with process substitution, null terminated, empty IFS
getlist0() {
    while IFS= read -d $'\0' -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done < <(find . -iname 'foo*' -print0)
}

# while with process substitution, null terminated, default IFS
getlist1() {
    while read -d $'\0' -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done < <(find . -iname 'foo*' -print0)
}

# pipe to while, newline terminated
getlist2() {
    find . -iname 'foo*' | while read -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}

# pipe to while, null terminated
getlist3() {
    find . -iname 'foo*' -print0 | while read -d $'\0' -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}

# for loop over subshell results, newline terminated, default IFS
getlist4() {
    for file in "$(find . -iname 'foo*')" ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}

# for loop over subshell results, newline terminated, newline IFS
getlist5() {
    IFS=$'\n'
    for file in $(find . -iname 'foo*') ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}


# see how they run
for n in {0..5} ; do
    printf '\n\ngetlist%d:\n' $n
    eval getlist$n
done

rm -rf "$dir"
sorpigal
la source
1
Accepté votre réponse: la plus complète et la plus intéressante - je ne connaissais pas $IFSla < <(cmd)syntaxe. Reste encore une chose obscure pour moi, pourquoi l' $en $'\0'? Merci beaucoup.
gregseth
2
+1, mais vous devez ajouter ... while IFS= read... pour gérer les fichiers qui commencent ou se terminent par des espaces.
Gordon Davisson
1
Il y a un avertissement à la solution de substitution de processus. Si vous avez une invite à l'intérieur de la boucle (ou que vous lisez à partir de STDIN de toute autre manière), l'entrée sera remplie par les éléments que vous introduisez dans la boucle. (peut-être que cela devrait être ajouté à la réponse?)
andsens
2
@uvsmtid: Cette question a été balisée, bashdonc je me suis senti en sécurité en utilisant des fonctionnalités spécifiques à bash. La substitution de processus n'est pas portable pour d'autres shells (sh lui-même ne recevra probablement jamais une mise à jour aussi importante).
sorpigal
2
La combinaison IFS=$'\n'avec forempêche le fractionnement de mots interne à la ligne, mais soumet toujours les lignes résultantes à la globalisation, donc cette approche n'est pas entièrement robuste (sauf si vous désactivez également la globalisation en premier). Bien que cela read -d $'\0'fonctionne, il est légèrement trompeur en ce sens qu'il suggère que vous pouvez utiliser $'\0'pour créer des NUL - vous ne pouvez pas: un \0dans une chaîne entre guillemets C ANSI termine effectivement la chaîne, de sorte que -d $'\0'c'est effectivement le même que -d ''.
mklement0
29

Il existe également une solution très simple: s'appuyer sur bash globbing

$ mkdir test
$ cd test
$ touch "stupid file1"
$ touch "stupid file2"
$ touch "stupid   file 3"
$ ls
stupid   file 3  stupid file1     stupid file2
$ for file in *; do echo "file: '${file}'"; done
file: 'stupid   file 3'
file: 'stupid file1'
file: 'stupid file2'

Notez que je ne suis pas sûr que ce comportement soit le comportement par défaut mais je ne vois aucun paramètre spécial dans mon shopt donc j'irais dire qu'il devrait être "sûr" (testé sur osx et ubuntu).

marchelbling
la source
13
find . -iname "foo*" -print0 | xargs -L1 -0 echo "File found:"
Karoly Horvath
la source
6
en remarque, cela ne fonctionnera que si vous souhaitez exécuter une commande. Un shell intégré ne fonctionnera pas de cette façon.
Alex
11
find . -name "fo*" -print0 | xargs -0 ls -l

Tu vois man xargs.

Torp
la source
6

Étant donné que vous ne faites aucun autre type de filtrage avec find, vous pouvez utiliser ce qui suit à partir de bash4.0:

shopt -s globstar
getlist() {
    for f in **/foo*
    do
        echo "File found: $f"
        # do something useful
    done
}

Le **/correspondra à zéro ou plusieurs répertoires, donc le modèle complet correspondra foo*dans le répertoire actuel ou dans n'importe quel sous-répertoire.

chepner
la source
3

J'aime vraiment les boucles et l'itération de tableau, donc je pense que j'ajouterai cette réponse au mélange ...

J'ai également aimé l'exemple de fichier stupide de marchelbling. :)

$ mkdir test
$ cd test
$ touch "stupid file1"
$ touch "stupid file2"
$ touch "stupid   file 3"

Dans le répertoire de test:

readarray -t arr <<< "`ls -A1`"

Cela ajoute chaque ligne de liste de fichiers dans un tableau bash nommé arravec toute nouvelle ligne de fin supprimée.

Disons que nous voulons donner à ces fichiers de meilleurs noms ...

for i in ${!arr[@]}
do 
    newname=`echo "${arr[$i]}" | sed 's/stupid/smarter/; s/  */_/g'`; 
    mv "${arr[$i]}" "$newname"
done

$ {! arr [@]} se développe à 0 1 2 donc "$ {arr [$ i]}" est le i ème élément du tableau. Les guillemets autour des variables sont importants pour préserver les espaces.

Le résultat est trois fichiers renommés:

$ ls -1
smarter_file1
smarter_file2
smarter_file_3
terafl0ps
la source
2

finda un -execargument qui fait une boucle sur les résultats de la recherche et exécute une commande arbitraire. Par exemple:

find . -iname "foo*" -exec echo "File found: {}" \;

Ici {}représente les fichiers trouvés, et leur encapsulation ""permet à la commande shell résultante de traiter les espaces dans le nom de fichier.

Dans de nombreux cas, vous pouvez remplacer ce dernier \;(qui démarre une nouvelle commande) par un \+, qui mettra plusieurs fichiers dans la même commande (pas nécessairement tous en même temps cependant, voir man findpour plus de détails).

naught101
la source
0

Dans certains cas, ici, si vous avez juste besoin de copier ou de déplacer une liste de fichiers, vous pouvez également diriger cette liste vers awk.
Important \"" "\"autour du terrain $0(en bref vos fichiers, une liste de lignes = un fichier).

find . -iname "foo*" | awk '{print "mv \""$0"\" ./MyDir2" | "sh" }'
Steve
la source
0

Ok - mon premier post sur Stack Overflow!

Bien que mes problèmes avec cela aient toujours été dans csh pas bash, la solution que je présente fonctionnera, j'en suis sûr, dans les deux. Le problème est avec l'interprétation du shell des retours "ls". Nous pouvons supprimer "ls" du problème en utilisant simplement l'extension shell du *caractère générique - mais cela donne une erreur "no match" s'il n'y a aucun fichier dans le dossier actuel (ou spécifié) - pour contourner ce problème, nous étendons simplement le expansion pour inclure des fichiers dot ainsi: * .*- cela donnera toujours des résultats depuis les fichiers. et .. sera toujours présent. Donc, dans csh, nous pouvons utiliser cette construction ...

foreach file (* .*)
   echo $file
end

si vous voulez filtrer les fichiers dot standard, c'est assez simple ...

foreach file (* .*)
   if ("$file" == .) continue
   if ("file" == ..) continue
   echo $file
end

Le code dans le premier post sur ce fil serait écrit ainsi: -

getlist() {
  for f in $(* .*)
  do
    echo "File found: $f"
    # do something useful
  done
}

J'espère que cela t'aides!

Andy Foster
la source
0

Une autre solution pour le travail ...

L'objectif était:

  • sélectionner / filtrer les noms de fichiers récursivement dans les répertoires
  • gérer chaque nom (quel que soit l'espace dans le chemin ...)
#!/bin/bash  -e
## @Trick in order handle File with space in their path...
OLD_IFS=${IFS}
IFS=$'\n'
files=($(find ${INPUT_DIR} -type f -name "*.md"))
for filename in ${files[*]}
do
      # do your stuff
      #  ....
done
IFS=${OLD_IFS}

Vince B
la source
Thx pour une remarque constructive, mais: 1- c'est un problème réel, 2- shell aurait pu évoluer dans le temps ... comme tout le monde je suppose; 3- Aucune réponse ci-dessus ne pourrait satisfaire une résolution DIRECTE du pb sans changer le problème ou dissertation :-)
Vince B