Faire une boucle sur une série de fichiers trouvés avec find

On sait bien que pour trouver des fichiers dans un dossier (et dans ses sous-dossiers éventuellement), la commande find est vraiment très pratique. Si on veut rechercher les fichiers avec l'extension .tex (à la casse près) dans un dossier et dont le contenu correspond à une regex particulière, on peut faire usage de l'option -exec :

find /dossier -type f -iname '*.tex' \
    -exec grep -EHn -e 'ma regex' {} \;

Mais souvent le traitement sur chaque fichier détecté par la commande find peut être suffisamment complexe pour ne pas pouvoir se résumer à une seule commande. Par exemple, on peut vouloir effectuer un test sur chaque fichier et exécuter une commande qui dépende du résultat de ce test etc. Dans ce cas, l'option -exec est trop limitée et il faut s'y prendre autrement. Voici comment faire une telle boucle :

find /dossier -type f -iname '*.tex' | while IFS=$'\n' read f ; do

    # On teste si le fichier est exécutable et on inverse
    # les bits x en conséquence. (C'est un exemple... idiot.)
    if [ -x "$f" ]; then
        chmod a-x "$f"
    else
        chmod a+x "$f"
    fi

done

(En fait, on verra plus loin qu'il y a un peu mieux comme code.) Mine de rien, ce code demande un certain nombre d'explications. Pour ce faire, on va partir d'un fichier fictif fichier.txt qui symbolisera la sortie de la commande find et qui contiendra très exactement ceci :

a       aaaa    aa
    bbb     b       
ccc    ccc        
    ddd     ddd           

Remarque importante pour la suite : les lignes 2 et 4 de ce fichier commencent et se terminent par des espaces. Le but est de faire une boucle sur les lignes de ce fichier en encapsulant la totalité du contenu de chaque ligne dans une variable. Si on teste alors ce mini script :

cat fichier.txt | while read ligne ; do 
    echo $ligne
done

on obtient alors ceci :

a aaaa aa
bbb b
ccc ccc
ddd ddd

On n'obtient pas alors le résultat souhaité puisque la sortie ne correspond pas exactement aux lignes du fichier de départ. Le problème se trouve notamment au niveau de la commande echo. En effet, le bash fonctionne par tokens (le manuel du bash utilise l'expression « mot ») et ces tokens sont en général délimités par des espaces ou des tabulations (voir la rubrique DEFINITIONS du manuel du bash pour des explications plus exhaustives et plus précises). Ainsi, la commande :

echo $ligne

est développée par exemple en :

echo a       aaaa    aa

Mais la primitive echo lit ses arguments sous la forme de tokens, ce qui donne les tokens "a", "aaaa" et "aa". Puis, comme l'explique le manuel du bash, la primitive echo affiche ses arguments en les séparant par un espace et en mettant le caractère \newline à la fin. Voilà pourquoi les nombreux espaces qui séparent les trois tokens "a", "aaaa" et "aa" se fondent chacun en un seul espace pour donner au final comme résultat la ligne :

a aaaa aa

Pour pouvoir préserver les espaces, il faut mettre la variable ligne entre doubles quotes. La double quote n'est pas un délimiteur en bash, c'est un caractère qui fait perdre la signification de certains métacaractères comme l'espace et la tabulation. L'espace et la tabulation sont des métacaractères qui permettent de délimiter les tokens (les mots). Entre doubles quotes, l'espace et la tabulation ne remplissent plus cette fonction et deviennent des caractères «normaux». Pour des explications précises, voir les rubriques DEFINITONS et QUOTING de la page du manuel du bash. Par conséquent, testons maintenant ce code :

cat fichier.txt | while read ligne ; do 
    # Avec les doubles quotes qui permettent l'expansion
    # des variables comme var, contrairement aux 
    # simples quotes.
    echo "$ligne"
done

On obtient alors ceci :

a       aaaa    aa
bbb     b
ccc    ccc
ddd     ddd

C'est mieux, mais ce n'est pas encore parfait car les lignes 2 et 4 du fichier commencent et se terminent par des espaces ce qui n'est pas le cas du résultat obtenu. Où sont partis ces caractères espace ? Ce n'est pas à cause de la commande echo car les doubles quotes au niveau de la variable ligne nous garantissent leur conservation. La perte s'est produite au niveau de la primitive read. Là, il faut se plonger dans la rubrique SHELL BUILTIN COMMANDS de la page manuel du bash pour lire le fonctionnement de la primitive read. Cette primitive lit en entrée une ligne et affecte le premier token de la ligne à la première variable, le second token de la ligne à la seconde variable etc. La dernière variable se voit affectée l'ensemble des mots restants avec tous les séparateurs qu'il y a entre eux, tels quels. Voici un exemple :

$ echo "   aaa   bbb   " | { read var && echo "$var"; }
aaa   bbb

# Remarque au passage
# En faisant cela :
$ echo "   aaa   bbb   " | read var
$ echo "$var"
$ # Jamais rien ne s'affichera car la variable var est
  # définie dans un sous-shell (car avec bash, un pipe comme « A | B »
  # exécute les deux commandes A et B dans des sous-shells)
  # et donc la variable var est inconnue du shell parent, à
  # savoir le shell courant.

La primitive read reçoit la ligne envoyée par echo et la dernière variable (qui est aussi la première en l'occurrence) se voit affectée les deux tokens "aaa" et "bbb" avec tous les séparateurs entre ces deux tokens, mais les espaces au début et à la fin, eux, passent à la trappe. Mais comment faire pour conserver ces espaces de début et de fin pour qu'il n'y ait aucune perte entre l'entrée et la sortie ? Comme ceci :

echo "   aaa   bbb   " | { IFS=$'\n' read var && echo "$var"; }
   aaa   bbb   

En fait, la primitive read utilise la variable d'environnement IFS (Internal Field Separator) pour découper la ligne d'entrée en tokens. Cette variable contient par défaut les trois caractères \space\tab\newline. Si on modifie cette variable pour qu'elle contienne uniquement \newline, alors la commande read verra la ligne d'entrée comme un seul et unique token qui sera affecté à la variable var et il n'y aura aucune perte. Mais il est dangereux de modifier cette variable directement dans le shell. Heureusement, il est possible de modifier une variable «localement», c'est-à-dire uniquement durant l'exécution d'une commande, avec la syntaxe suivante :

var="truc" commande...

# Et donc par exemple :
IFS=$'\n' read var
# Une chaîne de la forme $'xxx' permet d'intégrer des caractères 
# particuliers avec l'antislash. Par exemple \n signifie \newline.
# Une fois la commande read exécutée, la variable IFS retrouve 
# aussitôt sa valeur par défaut.

Conclusion, afin qu'il n'y ait aucune perte entre l'entrée (constituée par notre fichier fictif) et la sortie, il faut faire :

cat fichier.txt | while IFS=$'\n' read ligne ; do 
    echo "$ligne"
    # Et là on fait le traitement que l'on veut.
done

Maintenant que tout est clair, il faut avouer qu'on peut s'épargner la bidouille commise sur la variable d'environnement IFS en faisant ceci :

cat fichier.txt | while read ; do 
    echo "$REPLY"
    # Et là on fait le traitement que l'on veut.
done

Si aucun argument n'est donné à la commande read, alors la variable REPLY se voit affectée la totalité de la ligne. Du coup, les choses deviennent encore plus simples. Dans notre exemple, le fichier fichier.txt symbolise la sortie d'une commande, comme la commande find par exemple. Mais si le fichier existe bel et bien, alors il faudra préférer cette construction :

while read ; do 
    echo "$REPLY"
    # Et là on fait le traitement que l'on veut.
done < fichier.txt

à la construction précédente qui utilise la commande cat et qui constitue ce qu'on appelle un UUOC (Useless Use Of Cat, c'est-à-dire une « utilisation inutile de la commande cat »). En effet :

  • avec « wc < fichier.txt » la commande wc lit directement le fichier ;
  • avec « cat fichier | wc », cat fait une copie du fichier de mémoire à mémoire et envoie la copie dans le flux d'entrée de la commande wc.

Dans les deux cas, le résultat final est identique mais le second schéma impose au système une copie inutile du fichier et peut ralentir la commande. Pour reprendre notre exemple initial avec la commande find, il faut donc procéder ainsi :

find /dossier -type f -iname '*.tex' | while read ; do
    # On fait le traitement que l'on veut, sachant que
    # "$REPLY" contient la totalité d'une ligne.
done

Ou bien ainsi :

find /dossier -type f -iname '*.tex' > fichier.txt
while read ; do
    # On fait le traitement que l'on veut, sachant que
    # "$REPLY" contient la totalité d'une ligne.
done < fichier.txt
Cette entrée a été publiée dans Shell bash, avec comme mot(s)-clef(s) , , , . Vous pouvez la mettre en favoris avec ce permalien.

Les commentaires sont fermés.