Trucs cool en shell

Alice M. – Avril et mai 2018

Table des matières

Introduction

J'ai passé des années à me dire qu'il était inutile d'écrire un truc sur la programmation shell, mais j'ai fini par me rendre compte que j'étais rarement fan de ce qu'on peut trouver sur le web. Il manque toujours à ces documents deux trois choses que je trouve cool, pratiques ou importantes (et le français, quand il y en a, est parfois douteux). J'ai donc pris quelques notes, finalement. Enfin… Je dis « quelques », mais c'est vite devenu cinquante fois plus grand que prévu. Il faut dire que j'aime ce sujet.

D'autres choses qui m'ont motivé sont :

Pour les trucs vraiment basiques, je vous invite à consulter les manuels des commandes concernées (man blabla) ou à fouiner sur le net. Je vais vous supposer un minimum indépendants. Oh et ptet que ExplainShell.com pourra vous dépanner, aussi.

Je m'intéresse ici principalement à Bash. Une bonne partie des infos que je présente sont valables pour la plupart des shells pas trop ésotériques.

Note vite fait avant de commencer : le truc de coloration syntaxique que j'utilise n'a pas l'air parfait et fait des trucs chelous quand on est sous Chrome mobile avec un écran de genre moins de 550 pixels de large. Vous foutre en format paysage peut dépanner pour accroître la largeur, mais à vrai dire je ne sais pas si lire un truc pareil sur mobile (et utiliser Chrome) est une bonne idée, de toute manière.

Arrière-plan et premier plan

La plupart des gens qui ont déjà lancé des commandes savent qu'on peut lancer une commande en arrière-plan en ajoutant & à la fin de celle-ci :

sleep 2
sleep 2 &
!! [1] 2432

!! [1]+  Done                    sleep 2

Cependant, on ignore trop souvent que même quand une commande a été lancée au premier plan, il est possible de la mettre en pause et de la relancer en arrière-plan (et inversement). Ctrl-Z met la commande qui se trouve au premier plan en pause. On peut ensuite utiliser bg (« background ») ou fg (« foreground ») pour continuer l'exécution de la commande en arrière ou en premier plan, respectivement :

sleep 2
^Z
!! [1]+  Stopped                 sleep 2
bg
!! [1]+ sleep 2 &

!! [1]+  Done                    sleep 2
sleep 2 &
!! [1] 3252
fg
!! sleep 2

Ainsi, pas besoin d'ouvrir quarante-six nouveaux terminaux quand vous oubliez de lancer une commande en arrière-plan.

Motifs

Étoile

On utilise souvent un peu à l'arrache l'étoile * pour traîter plein de fichier à la fois. Il est cependant important de noter qu'il peut y avoir des effets secondaires bizarres quand l'étoile se balade sans rien avant. Regardez l'exemple suivant :

ls
!! a  b  -c
grep 'plop' *
!! a:0
!! b:0
grep 'plop' ./*

Les commandes ont été exécutées dans un répertoire contenant les fichiers a, b et -c. Dans le premier cas, avec juste *, le motif constitué par cette étoile seule a été remplacé par le shell par a b -c. Résultat : le nom du fichier -c a été interprété comme une option pour grep (celle qui sert à compter les occurrences), d'où les 0 (aucune occurrence de « plop » dans a et b). Dans le second cas, cependant, on a obtenu ./a ./b ./-c, ce qui évite cette interprétation erronée.

Dans le même genre, certaines commandes ont une option spéciale -- qui prévient que tout ce qui vient derrière ne peut pas être une option. Voir le man des commandes pour savoir si elles ont un tel outil.

grep 'plop' -- *

C'est notamment cool quand vous utilisez des arguments dont l'origine est douteuse, genre si ils découlent d'une saisie d'un utilisateur.

Alternatives, etc.

Même s'il ne faut pas confondre les motifs du shell avec des expressions rationnelles, il peut être bon de garder dans un coin de sa tête qu'on peut écrire pas mal de motifs avec des listes de caractères, etc. :

wc ./??
!! 0 0 0 ./-c
wc [ab]
!! 0 0 0 a
!! 0 0 0 b
!! 0 0 0 total
Un truc qui est trop rarement mentionné, aussi, est la possibilité (en Bash, en tout cas) d'utiliser des accolades et des virgules pour décrire des alternatives afin que le shell génère plusieurs chaînes de caractères en une fois :
echo a{b,c}
!! ab ac
mkdir -pv a{b/{c,d},e,f/g/h}
!! mkdir: created directory ‘ab’
!! mkdir: created directory ‘ab/c’
!! mkdir: created directory ‘ab/d’
!! mkdir: created directory ‘ae’
!! mkdir: created directory ‘af’
!! mkdir: created directory ‘af/g’
!! mkdir: created directory ‘af/g/h’

Comme vous pouvez le voir sur cet exemple :

Faites gaffe

Une différence majeure entre les motifs classiques et ces histoires d'accolades est qu'avec ces dernières on se fiche de tomber ou non sur des noms de fichiers qui existent :

echo {a,b,c}
!! a b c
echo [abc]
!! [abc]
touch a b c
echo [abc]
!! a b c
Vous vous en foutrez probablement, mais je me suis fait chier à bidouiller l'outil de coloration syntaxique pour qu'il cesse de nier obstinément l'existence de builtins en Bash. Ainsi, je fous une couleur différente pour les trucs comme echo, printf ou read, qui sont intégrés au programme de l'interpréteur de commandes, et pour les programmes externes comme cat ou grep. Et bien entendu, à côté de ça, vous avez aussi les mots-clefs genre if ou for… Tout cela avait été sacrément mélangé et j'ai un peu fait le ménage.
Si je ne dis pas de conneries, les builtins offrent souvent un avantage en performances par rapport à un truc externe qu'il faut se faire chier à charger en mémoire.

Dans cet exemple, on constate que la sortie du echo avec les crochets est différente avant et après la création (via touch) des fichiers a, b et c : quand les fichiers n'existent pas, le motif est passé tel quel à la commande echo, car il n'a trouvé aucun fichier correspondant ! Cela peut donner des trucs très bizarres, notamment dans les boucles :

for fichier in ./*; do grep 'plop' "$fichier"; done
!! grep: ./*: No such file or directory

Ici, j'étais dans un répertoire vide. De ce fait, le motif donné au for n'a correspondu à aucun fichier et est resté tel quel. On s'est donc retrouvé avec le motif écrit en dur dans la variable fichier, et passé brutalement, plus loin, à la commande grep, qui n'a pas trop apprécié. Un moyen d'éviter ce type de choses consiste tout bêtement à sauter, dans la boucle, les fichiers non lisibles, par exemple :

for fichier in ./*
do
    test -r "$fichier" || continue
    grep 'plop' "$fichier"
done

Ici, concrètement, on utilise test (le manuel ne ferait pas de mal à pas mal de gens) pour vérifier que le fichier existe, est bien un fichier normal et pas un truc exotique à la con genre répertoire, et est lisible : -r (« read(able ?) », je suppose) fait tout ça. Grâce au ||, la commande suivante n'est exécutée que si ce test échoue (c'est-à-dire, si le fichier est chelou, inexistant ou que nous n'avons pas le droit de le dire). Cette « commande suivante » est continue, qui passe à l'élément suivant de la boucle courante (ou termine la boucle s'il n'y a plus d'élément à traiter).

Puisque ./*, dans le cas du répertoire vide, ne correspond évidemment pas à un fichier respectant les critères que j'ai cités, on zappe cet élément et on évite de passer de la daube en barre à grep.

Une version équivalente mais plus verbeuse serait :

for fichier in ./*
do
    if ! [ -r "$fichier" ]
    then
        continue
    fi
    grep 'plop' "$fichier"
done
Il est également possible de changer le comportement du shell vis-à-vis des motifs qui échouent en activant des options comme nullglob ou failglob, mais ça aura tendance à remplacer vos problèmes par d'autres.

Echos, redirections et cats inutiles

cat

Je vois souvent des trucs genre cat fichier | grep 'plop'. C'est complètement bourrin ! De manière générale, si…

… il est presque certain qu'il ne sert à rien. Dans notre exemple du cat fichier | grep 'plop', on pourrait simplement passer le fichier fichier en argument à grep et obtenir grep 'plop' fichier. Cela vaut bien entendu pour de nombreuses commandes, car grep est loin d'être le seul truc capable de prendre des fichiers d'entrée (et j'ai bien dit « des ») en argument…

Quand bien même on aurait vraiment besoin de balancer le contenu de notre fichier sur l'entrée standard d'une commande plutôt que de la passer en argument, on peut généralement se contenter d'une bête redirection (commande < fichier au lieu de cat fichier | commande). Cela peut même faire des différences sur les performances puisque les tubes et compagnie forcent le shell à dupliquer son processus et à exécuter des trucs à la con.

Rappelons que le nom de la commande cat « is derived from its function to concatenate files » (Wikipédia). Ça montre assez bien que dans pas mal de cas où on l'utilise alors qu'on est pas en train de concaténer des trucs, il y a une couille quelque part. Pas tous les cas, mais pas mal.

echo

Certains appels à echo sont également assez farfelus et peuvent être évités :

variable=$(
    # Générons plusieurs lignes
    # de texte à l’arrache pour
    # remplir la variable.
    seq 3
    yes 'pomme' | head -2
)

echo "$variable" | grep -c 'm'
grep -c 'm' <<< "$variable"

echo

echo 'patate' | tr -d 'a'
tr -d 'a' <<< 'patate'
2
2

ptte
ptte

Dans ces exemples, je vous présente deux manières équivalentes de faire des trucs. Celle qui évite des appels à la con à echo utilise des here strings. Notez que cela fonctionne aussi bien avec des variables à la con qu'avec du texte fixe, et qu'on peut mélanger les deux comme de la merde.

Bon, je vois d'ici ceux qui vont me dire que c'est chiant parce que ça met l'entrée à droite. Donc :

  1. En shell, il y a des chiées de contextes où il est tout à fait normal voire inévitable d'avoir les données du côté droit. D'ailleurs, si vous voyez ça comme un argument, il n'y a plus rien de choquant. On ne va quand même pas tout foutre en notation postfixée dans tous les langages.
  2. C'est pratique pour mettre au point une commande dans un terminal en testant plusieurs entrées différentes.
  3. Il ne me semble pas impossible que ça évite de faire popper quarante sous-shells à la con.

Chaînes, lisibilité, etc.

Chaînes, variables, guillemets…

À la base, les accolades de ${variable} servent surtout à indiquer où se termine le nom de la variable quand des caractères à la con traînent derrière :

x='patate'
echo "a) 1$x2"
echo "b) 1${x}2"
a) 1
b) 1patate2

Comme la coloration syntaxique nous le montre, dans le cas « a », le nom de la variable est pollué par le « 2 », qui, du même coup, se fait bouffer comme de la merde. On se retrouve à taper dans une variable x2 qui, évidemment, risque fort d'être vide ou de contenir un peu tout sauf ce dont nous avons besoin.

À part ça, je voudrais ajouter un petit point tout bête : évitez de mettre des guillemets doubles partout sans raison. Pour rappel, dans des guillemets simples, les dollars cessent de servir à chopper la valeur des variables. De ce fait, je recommande d'utiliser des guillemets simples pour toutes les chaînes qui ne contiennent pas de variables. Cela a le mérite de montrer aux mecs qui lisent le script ou la commande qu'il ne s'agit que de texte tout bête et qu'il n'est pas nécessaire d'y chercher des trucs tordus (ou la cause d'un bug, par exemple). C'est plutôt cool.

x='patate'
echo "a) vroum $x !"
echo 'b) vroum $x !'
a) vroum patate !
b) vroum $x !

Bon, après, les guillemets simples peuvent devenir sérieusement casse-falafels quand vous avez des apostrophes dans votre texte (sauf si vous allez chopper la jolie apostrophe Unicode « ’ », mais c'est bourrin). Ce cas relou a cependant le mérite de pouvoir servir d'exemple pour rappelez aux gens qu'il n'est pas si difficile de concaténer nawak en shell :

x='patate'
echo 'blabla l'"'"'arbre '"$x"
blabla l'arbre patate

Ne riez pas ! J'ai quelques définitions d'alias qui ont limite cette tronche… En gros,

  1. on ferme la chaîne de caractères avec un guillemet simple,
  2. on en ouvre une nouvelle immédiatement avec des guillemets doubles,
  3. on place un guillemet simple (qui sera interprété littéralement) dans cette nouvelle chaîne,
  4. on ferme cette chaîne et on en rouvre une avec des guillemets simples…

Au fond, il y a souvent des chiées de moyens différents de pondre une chaîne, quoi.

Here documents

J'ai déjà mentionné les here strings tout à l'heure, mais il existe aussi des here documents :

grep 'tat' <<- _TXT_
	patate vroum
	rond ratatiner
	rigolo
	suralimentation
	mou badaboum
_TXT_
patate vroum
rond ratatiner
suralimentation

En gros, on peut simuler un fichier en balançant son contenu directement dans le script. Le trait d'union de <<- est optionnel et permet de faire en sorte que les tabulations au début des lignes du « document » soient ignorées.

Hélas, cette « ignoration » ne fonctionne pas avec les espaces ; j'ai même dû faire du Ctrl-Shift-U 9 pour vous pondre cet exemple)

C'est super pratique pour rédiger l'aide d'un script, surtout qu'on peut choisir d'autoriser ou non l'expansion des variables et des expressions arithmétiques :

function aide {
    cat << _HELP_

    Usage:
        $0 FILE     Do stuff.
        $0 -a FILE  Do something else.
        $0 -h       Print this help.

_HELP_
}

if [ "$1" = '-h' ]
then
    aide
    exit 0
fi

# […]
bash script.sh -h
!! 
!!     Usage:
!!         script.sh FILE     Do stuff.
!!         script.sh -a FILE  Do something else.
!!         script.sh -h       Print this help.
!! 

Comprenez donc que depuis que je connais ce truc, je crise quand je vois des gens faire quarante appels à echo à la suite. Oh, à propos, ça n'a pas grand chose à voir mais j'en profite pour glisser un truc ici :

echo sans argument est équivalent à echo '' ou echo "", et peut donc être utilisé pour revenir à la ligne ou en sauter une. Merci de votre compréhension.

Je ne sais pas si la version sans expansion vous servira, mais je vous mets un exemple car ça me fait marrer ; j'ai découvert l'existence de cette alternative aujourd'hui même :

cat << '_X_'
a $b $(c)
$((d)) ${e}
_X_
a $b $(c)
$((d)) ${e}

En gros, il faut mettre l'étiquette entre guillemets (simples ou doubles, osef) lors de sa déclaration (mais pas à la fin du here document), et hop, plus besoin de mettre des chiées de barres obliques pour échapper des trucs. Bon, par contre, je suppose que si dans ce même document vous avez finalement besoin d'« expandre » un ou deux trucs, vous êtes fichus.

Vérifier qu'on a les outils qu'il faut

Parfois, au démarrage d'un script, on aimerait bien voir si tel ou tel programme est disponible, histoire de proposer à l'utilisateur plus ou moins de fonctionnalités ou simplement de voir si on s'apprête à déclencher l'apocalypse en essayant d'utiliser à des moments cruciaux des commandes qui ne seront pas trouvées. Fort heureusement, on peut faire des trucs sympas :

which bash grep awk
!! /bin/bash
!! /bin/grep
!! /usr/bin/awk
which padfeafke

La commande which affiche, pour chacun de ses arguments, le truc qui sera exécuté si on essaye de l'exécuter. Si aucune commande disponible ne colle au nom donné, rien n'est affiché. En plus de ça, le statut de sortie de la commande nous permet de savoir si tout s'est bien passé :

which grep; echo $?
!! /bin/grep
!! 0
which padfeafke; echo $?
!! 1
which grep padfeafke; echo $?
!! /bin/grep
!! 1
Il suffit qu'une seule des commandes ne soit pas trouvée pour que which ne soit pas content du tout.

Bref : quand on ne passe qu'un seul nom en argument,

Cela nous donne une foule de moyens de voir ce qu'il s'est passé, et on peut donc pondre des trucs comme ça :

which padfeafke || echo 'Attention: padfeafke non trouvé.'
!! Attention: padfeafke non trouvé.
which grep && echo 'Ouf: grep trouvé.'
!! /bin/grep
!! Ouf: grep trouvé.
test "$(which grep)" && echo 'grep encore trouvé.'
!! grep encore trouvé.
which grep > /dev/null && echo 'grep trouvé une dernière fois.'
!! grep trouvé une dernière fois.

Dans ces exemples, j'ai essayé de mettre en évidence un « problème » de which : puisque quand une commande est trouvée cela chie un vieux chemin à la con sur la sortie standard, which peut un peu polluer la sortie des scripts. Pour éviter ça, je propose deux méthodes :

En vrai, which, c'est pas ouf et je commence à passer à type, mais pas le temps d'écrire un truc là-dessus pour le moment.

Protection, bordel de dieu

Je ne me lasserai probablement jamais de le dire : il faut prendre l'habitude de protéger ses (expansions de) variables avec des guillemets. Voyez plutôt :

x='a b'
grep 'plop' 1 $x 2
!! grep: 1: No such file or directory
!! grep: a: No such file or directory
!! grep: b: No such file or directory
!! grep: 2: No such file or directory
grep 'plop' 1 "$x" 2
!! grep: 1: No such file or directory
!! grep: a b: No such file or directory
!! grep: 2: No such file or directory

Dans le premier cas (sans les guillemets), le contenu de la variable x a été interprété comme deux mots séparés, et grep est donc allé chercher deux fichiers a et b distincts (je me sers des messages d'erreurs de « nyanya n'existe pas » pour bien montrer ce qu'a cherché à faire notre ami grep). Dans le second cas, cependant, les guillemets ont permis au contenu de x d'être bien interprété comme un unique nom de fichier, qui comprend un espace.

Je vous vois venir gros comme des camions : « nyanyanya, mais si on veut que ça soit interprété comme deux mots ? » Oui non mais non. Enfin si mais non. Les cas dans lesquels ce découpage est souhaité me semblent assez rares ; c'est souvent des trucs à la con, genre quand la variable contient une commande qu'on veut exécuter. Je fais ça, par exemple, dans un de mes scripts, un peu de la manière suivante :

x='mkdir -v dir'
"$x"
!! mkdir -v dir: command not found
$x
!! mkdir: created directory 'dir'

Ici, en gros, si on protège l'expansion de la variable, le shell se met à chercher comme un gros teubé une commande appelée mkdir -v dir, espaces compris. À l'inverse, sans protection, on obtient bien un découpage qui fait que le shell va chercher une commande nommée juste mkdir, et que les mots suivants sont utilisés comme arguments par cette commande.

SAUF QUE : dans la majorité (totalité ?) des cas, on a meilleur compte d'utiliser eval et de lui passer le contenu de la variable en le protégeant. Regardez ce cas tordu :

x='grep "plop" a "b c" d'
$x
!! grep: a: No such file or directory
!! grep: "b: No such file or directory
!! grep: c": No such file or directory
!! grep: d: No such file or directory
eval "$x"
!! grep: a: No such file or directory
!! grep: b c: No such file or directory
!! grep: d: No such file or directory

Sans protection, on se retrouve à interpréter "b comme un nom de fichier !

Foutre une commande à l'arrache dans une variable toute basique, ça peut passer pour des cas pas trop tordus et quand on veut que l'utilisateur puisse rapidement configurer un script en changeant la valeur de ladite variable. Cependant, si vous devez construire plus ou moins dynamiquement une commande plus complexe, je vous conseilles d'utiliser un tableau, surtout si des bouts de la commande doivent être construits de manière différente en fonction de conditions à la con :

#! /usr/bin/env bash
# Fichier « build_cmd »
cmd=('grep')
cmd+=('plop')
if [ "$1" = 'patate' ]
then
    cmd+=('a b')
else
    cmd+=('c d')
fi
cmd+=('e')
"${cmd[@]}"
./build_cmd 
!! grep: c d: No such file or directory
!! grep: e: No such file or directory
./build_cmd patate
!! grep: a b: No such file or directory
!! grep: e: No such file or directory

Dans cet exemple un peu plus complexe, on crée un tableau contenant le mot grep (on aurait aussi pu créer un tableau vide avec =() puis ajouter cet élément dedans), puis on y ajoute progressivement les différents arguments. Finalement, on lance la commande en étalant côte-à-côte les éléments du tableau. On remarque que nos a b et c d ont bien été conservés comme des éléments à part entière (ils ne se sont pas fait découper la tronche en deux au niveau de l'espace).

Il est important de noter que lorsqu'on protège l'expansion du tableau, "${cmd[@]}" est équivalent à "${cmd[0]}" "${cmd[1]}"… et non à "${cmd[0]} ${cmd[1]}…", et c'est également foutrement pratique pour les boucles for et compagnie. Mais bon, ce genre de trucs, c'est dans le manuel du shell, donc bon, je m'arrête ici.

Bref, ne me faites pas le coup de « ouais nan mais je ne protège pas cette variable car IL ME SEMBLE QU'elle ne contiendra qu'un vieux nombre entier à cet endroit » et autres « ouais nan mais ça marche quand même, je crois » : prenez l'habitude de protéger un peu tout, sauf quand il est nécessaire de ne pas le faire, sinon vous aurez vite fait d'oublier de protéger des expansions cruciales. J'ai déjà croisé deux trois types comme ça (ça fait beaucoup, vu que je ne connais pas tant de gens que ça), qui perdaient un temps fou à cause de problèmes nés de mauvaises habitudes de protection.

Notez cependant que je commence à être tolérant pour certains cas à la con :

Abus de ls

On voit souvent des gens récupérer, dans un script, la sortie de ls pour en faire des trucs parfois assez casse-gueule. On voit aussi souvent des gens dire qu'il ne faut jamais faire ça. Et des gens qui disent qu'au fond, si, on peut. Bref, perso, je me situe clairement du côté de ceux qui trouvent que c'est super con. Il me semble assez clair que ls est avant tout fait pour donner des infos à un humain se trouvant devant un terminal, et donc pour être surtout utilisé hors des scripts.

Rappelons tout d'abord que sous Linux et compagnie, un nom de fichier peut contenir de la merde. Beaucoup de merde. Exécutez touch 'a'$'\n'♕$'\t''♥ ?Œ' et vous aurez un joli fichier dont le nom contient un retour à la ligne, des emojis, une tabulation, etc. Cela vous semble ridicule ? Il n'est pas si rare de se retrouver avec des noms à la con à cause d'erreurs de programmation, ou simplement parce qu'on nous refile des données sur lesquels on a peu de contrôle. Bref, ce genre de trucs donne n'importe quoi avec ls, et si vous voulez récupérer des données il est difficile de savoir, par exemple, où commence et se termine chaque nom de fichier.

En gros, vu le bordel qu'il faut faire pour que ça soit robuste avec ls, autant ne pas faire ça avec ls. find ou une bonne vieille boucle avec un motif shell peut vous chopper vos fichiers un par un, et si vous voulez des infos sur ces fichiers, la commande stat peut vous donnez toutes celles de ls, et même davantage, et vous pouvez choisir des formats stylés. Regardez donc son manuel.

# Création de fichiers, dont certains
# avec des noms (un peu) wtf.
touch 'a a a' $'b\nb' 'c'
# Affichage de manière un peu pourrie.
ls -l

echo

# Traitement assez robuste.
for file in ./*
do
    name=$(
        stat -c '%n' "$file"
    )
    owner=$(
        stat -c '%U' "$file"
    )
    size=$(
        stat -c '%s' "$file"
    )
    # Bon, je ne fais qu'afficher tout ça,
    # mais on pourrait imaginer
    # un traitement plus complexe.
    printf '« %s » (%s) → %d bits\n' \
            "$name" "$owner" "$size"
done
total 0
-rw-rw-r-- 1 alice alice 0 avril 22 21:34 a a a
-rw-rw-r-- 1 alice alice 0 avril 22 21:34 b
b
-rw-rw-r-- 1 alice alice 0 avril 22 21:34 c

« ./a a a » (alice) → 0 bits
« ./b
b » (alice) → 0 bits
« ./c » (alice) → 0 bits

Au milieu des débats (assez déséquilibrés, il faut le dire) qu'on trouve sur le net à ce sujet, on trouve des phrases sympas genre : « Si tu as l'impression que c'est la solution, c'est peut-être que ce que tu essayes de faire ne devrait pas être fait avec un script shell. »

Oh, et le globbing du shell est également plus rapide qu'un appel à ls dont on récupère la sortie. Genre… deux ou trois fois plus. Ce qui peut être cool quand on a des chiées de fichiers générés automatiquement.

Chopper des trucs de l'utilisateur quand une boucle bouffe l'entrée

Truc chiant et heureusement assez facile à résoudre : la lecture d'une entrée de l'utilisateur quand on est déjà dans une boucle qui cherche à lire sur l'entrée standard :

seq 10 | while read num
do
    read plop
    echo "$num → $plop"
done
1 → 2
3 → 4
5 → 6
7 → 8
9 → 10

Dans cet exemple, je montre ce qu'il peut se passer de foireux si on ne fait pas gaffe : le read plop situé à l'intérieur de la boucle voudrait récupérer une ligne tapée par l'utilisateur. Or, si vous exécutez ça, le script s'exécutera sans pause, sans permettre à l'utilisateur d'entrer quoi que ce soit. En effet, des chiées de nombres débarquent sur l'entrée standard du shell qui exécute la boucle, et le read plop fait « WOOOO DES TRUCS À BOUFFER !! » et se fait péter le bide.

Voici une version qui fonctionne déjà nettement mieux :

seq 10 | while read num
do
    read -p '? ⇒ ' plop < /dev/tty
    echo "$num → $plop"
done
bash script.sh 
!! ? ⇒ blabla
!! 1 → blabla
!! ? ⇒ poum
!! 2 → poum
!! ? ⇒ rond
!! 3 → rond
!! …

Rien de bien folichon : on branche explicitement l'entrée de read sur le terminal (« tty », pour « teletype », sijdipad'connerie). Quant à l'option -p, c'était juste pour mettre une invite à read afin de mieux différencier, dans le résultat, les lignes d'entrée (où j'ai eu à taper des trucs) et de sortie.

Calmer la joie de find et grep

Souvent, les gens veulent un truc (un fichier ou une occurrence d'un bout de texte) et les recherchent tous. Comme ça, sans raison. Quelle perte de temps. Tout ça parce qu'ils savent que seul un élément colle aux critères donnés. Cela dit, ceci n'est souvent même pas garanti, auquel cas c'est encore plus casse-gueule. Exemple :

touch a ab
un_fichier=$(
    find -type f -name 'a*'
)
grep 'plop' "$un_fichier"
grep: ./a
./ab: No such file or directory

Ici, on s'attend à ne trouver qu'un seul fichier et on en choppe deux. Hop, on passe en mode gros teubé à grep une chaîne à la con composée des noms des deux fichiers mis bout à bout, avec un vieux retour à la ligne en plein milieu. Cette chaîne est alors traitée comme s'il s'agissait d'un nom de fichier unique, et bam, la commande nous engueule car bien entendu le fichier correspondant n'existe pas. C'est quand même con. Et la même chose peut arriver, comme je le disais, quand on cherche une occurrence d'un truc dans un fichier avec grep. Bref, si vous savez que vous ne voulez qu'un certain nombre de trucs, précisez-le dès le départ :

touch a ab
un_fichier=$(
    find -type f -name 'a*' -print -quit
)
grep 'plop' "$un_fichier"
(Pas d'erreur)

Ici, on dit à find « Wèsh, quand t'arrives à passer les tests sur le nom et tout, affiche le nom du fichier puis stoppe ton exécution car on s'en tape du reste. »

Le -print de find est fait implicitement dans pas mal de cas, mais ici il faut le demander explicitement vu qu'on veut faire un truc (le -quit) après.

Pour d'autres nombres, je suppose que vous pouvez faire un head ou un truc de ce genre, mais utilisez des octets nuls pour séparer les noms sinon ça va être la merde :

# Création de deux fichiers dont
# un avec un nom dégueulasse.
touch a b$'\n'b

# Cas foireux qui coupe allègrement
# le nom crade en deux.
find -type f | sort | head -2 \
        | while read -r nom
do
    echo "« $nom »"
done

echo

# Cas OK avec -print0, -z et -d ''.
find -type f -print0 \
        | sort -z | head -2z \
        | while read -rd '' nom
do
    echo "« $nom »"
done
« ./a »
« b »

« ./a »
« ./b
b »

Mon dieu, cet exemple m'a fait découvrir avec horreur qu'un de mes PC avait une version de head ne supportant pas -z

Quant à grep, bah regardez le manuel. L'option -m est cool.

seq 999 | grep -m 3 '5'
5
15
25

La combinaison avec -o est un peu moins cool, par contre : s'il y a une chiée d'occurrences sur la première ligne qui contient ce que vous cherchez, vos allez toutes vous les bouffer (pour cette ligne) malgré la limite :

grep -om 1 'a' <<< 'aa'
a
a

Dans de tels cas, il faudra balancer des commandes à la con (genre head, encore, je suppose) derrière.

Oh, j'allais presque oublier awk.

seq 999 | awk '
    /[135]0/ && $1 % 2 != 0 {
        print
        exit
    }
'
101

Pensez au exit de awk. Vous pouvez aussi lui passer un vieux statut et tout.

Boucles et tubes

C'est bien beau, les tubes, mais il faut quand même garder en tête que ça fait naître des sous-shells dans tous les sens. Ici, je ne parle pas de performances à proprement parler, mais bien de sévères influences sur ce que vous obtiendrez.

k=0
while [ "$k" -lt 3 ]
do
    ((k++))
    echo "k = $k"
done
echo "Fin: k = $k"
k = 1
k = 2
k = 3
Fin: k = 3
k=0
seq 3 | while read line
do
    ((k++))
    echo "k = $k"
done
echo "Fin: k = $k"
k = 1
k = 2
k = 3
Fin: k = 0

On constate que dans le second cas nos incrémentations sont perdues : la variable k revient à la valeur qu'elle avait avant la boucle. En effet, dans ce cas-là, à cause du tube, toute la boucle while s'est exécutée dans un sous-shell plutôt que dans celui qui exécutait le script. Résultat : puisque le shell parent n'est absolument pas mis au courant des changements de valeurs de variables qui surviennent chez son marmot, lesdits changements se perdent dans la nature quand le shell fils termine son boulot. Cela peut donner des bugs assez chiants à dénicher quand on ne connaît pas ce délire-là.

Fort heureusement, il existe un moyen pas trop relou (en Bash, en tout cas) d'éviter de créer un sous-shell.

k=0
while read line
do
    ((k++))
    echo "k = $k"
done < <(seq 3)
echo "Fin: k = $k"
k = 1
k = 2
k = 3
Fin: k = 3

La syntaxe peut être assez déroutante lorsqu'on la voit pour la première fois. Il ne faut pas confondre ce < <(blabla) avec des << BLABLA ou $(blabla). Il y a bien un espace entre les deux chevrons, et le premier de ces chevrons est tout bêtement là pour indiquer qu'on redirige l'entrée d'une commande (ici, l'entrée de la boucle while elle-même) sur un fichier.

Pour piger un peu ce qu'il se passe, on peut faire un truc à la con comme ça :

echo <(true)
!! /dev/fd/63

Poum ! On constate que le <(blabla) a été remplacé par… un nom de fichier à la con. En gros, cette technique, appelée « substitution de processus » ou une connerie de ce genre, crée un genre de fichier temporaire (un tube nommé, pour être précis) qui reçoit, en guise de contenu, la sortie des commandes se trouvant entre les parenthèses. Ainsi, personne ne nous empêche de manipuler ce fichier, par exemple pour en lire le contenu :

cat <(echo 'poire')
!! poire

Bon, on a toujours qu'un seul chevron, mais en gros, là où on en a deux, c'est quand on veut se servir du contenu du fichier temporaire chelou comme entrée pour une commande ou un groupe de commandes :

tr 'a' '_' < <(echo 'patate'; echo 'Valhalla')
!! p_t_te
!! V_lh_ll_

Et surtout, n'hésitez pas à mettre tout ça en forme de manière pas trop crade : on est bien plus libres que certains ont l'air de le croire :

while read line
do
    echo "$line"
done < <(
    txt=$(
        echo 'pomme'
        echo 'poire'
    )
    echo "$txt $txt"
)
pomme
poire pomme
poire

Cependant, en voyant cet exemple, vous vous êtes peut-être dit : « Mais putain, c'est n'importe quoi : on va quand même pas mettre cent commandes dans ce truc, pis en plus après on se retrouve avec un truc tout en bas qui s'exécute avant le truc du haut et qui lui donne son entrée. » Eh bien, oui, donc idéalement il faudrait faire des fonctions pour rendre tout cela un peu moins ignoble…

function fruits {
    local txt
    
    txt=$(
        echo 'pomme'
        echo 'poire'
    )
    echo "$txt $txt"
}

while read line
do
    echo "$line"
done < <(fruits)
pomme
poire pomme
poire

… mais il n'y a pas de solution miracle, à ma connaissance.

Dites « non » aux backticks

Si la syntaxe `commandes` (avec des « backticks ») est encore supportée par les interpréteurs, c'est avant tout pour ne pas niquer les vieux scripts, mais une rapide recherche sur le web vous apprendra que c'est deprecated, notamment parce que c'est chiant à imbriquer et pas méga-lisible. Tenez-vous-en donc à $(commandes) lorsque vous souhaitez récupérer la sortie d'un ensemble de commandes.

Comme avec la récupération de valeur de variable (et un peu tout ce qui contient un $, en fait…), il vous faudra parfois spammer avec de la protection via guillemets. Or, certains ont l'air d'avoir du mal à piger comment marche la combinaison de $(…) et des guillemets, donc je vais faire un rappel vite fait.

printf '[%s]\n' $(
    echo a
    echo b
)

echo

printf '[%s]\n' "$(
    echo a
    echo b
)"

echo

printf '[%s]\n' "$(
    printf '(%s)\n' "$(
        echo a
        echo b
    )"
)"
[a]
[b]

[a
b]

[(a
b)]

Que se passe-t-il, ici ?

  1. Dans le premier cas, la sortie des deux echos n'est pas protégée et est donc considérée comme deux éléments séparés.
  2. Dans le second cas, on passe bien à printf un unique argument après son format, cet argument étant constitué de l'intégralité de la sortie des commandes qui sont dans notre $(…).
  3. Dans le troisième cas, on fait ceci mais en plus on a un second niveau de $(…). Ce second niveau est lui aussi protégé avec des guillemets doubles. Et c'est là que certaines personnes sont bloquées : elles ont peur de mettre ces guillemets car elles croient que l'interpréteur est plus bête qu'il ne l'est vraiment ; elles pensent que cela fermerait la première chaîne de caractères ouverte lors de la protection du premier $(…). Sauf que ce n'est pas du tout le cas ! En gros, quand on rentre dans un $(…), on oublie si on était dans une chaîne de caractères ou non ; on entre dans un contexte tout propre dans lequel rien ne nous empêche de rouvrir une chaîne. C'est une sorte de script à part entière. Et heureusement, d'ailleurs.

Bon, par contre, faut pas déconner, hein : dans un bon gros script, évitez d'enchaîner quarante structures de ce genre : faites de bonnes vieilles fonctions. Cela évitera du même coup de violer la coloration syntaxique, car souvent ça galère un peu dans ce type de cas. Ainsi, une version moins « mec bourré » du dernier cas de tout à l'heure donnerait :

function print_a_and_b {
    echo a
    echo b
}

function add_parenth {
    printf '(%s)\n' "$1"
}

printf '[%s]\n' "$(
    add_parenth "$(
        print_a_and_b
    )"
)"
[(a
b)]

En plus, ça permet de rajouter un peu de sémantique par-ci par-là.

Expansions qui font peur

J'ai longtemps fui les expansions de variables cheloues, à la fois parce que ça fait peur quand on ne les connaît pas, et aussi parce que j'avais remarqué que certaines personnes en abusent. Cependant, certaines techniques mériteraient d'être plus connues.

Initialisation

Il est bien souvent inutile de pondre des blocs conditionnels bordéliques et verbeux pour initialiser des paramètres avec des valeurs par défaut en cas de vacuité. Voyez plutôt :

function f {
    x="$1"
    echo "1) ${x:-plop}"
    echo "2) $x"
    echo "3) ${x:=plop}"
    echo "4) $x"
}

f 'pas vide'
echo
f
1) pas vide
2) pas vide
3) pas vide
4) pas vide

1) plop
2) 
3) plop
4) plop

J'appelle deux fois une fonction à la con – la première fois avec un paramètre non vide, et la seconde fois sans paramètre (ce qui donne un $1 vide). À chaque fois, la valeur de ce premier paramètre est stockée dans une variable x. Dans le premier cas, où x n'est pas vide, chaque echo affiche bêtement la valeur de x. Le second cas demande un peu plus d'attention et permet de piger ce que font ces instructions cheloues :

  1. La syntaxe avec le :- permet d'utiliser localement une valeur par défaut (le fameux plop qui traîne derrière) si et seulement si la variable considérée est vide.
  2. J'affiche ensuite x sans magouille afin de montrer que sa valeur n'a pas été altérée et est donc encore vide.
  3. La syntaxe avec le := est un peu plus violente : en plus de donner, là où on l'utilise, la valeur par défaut (là encore si la variable est vide), elle procède à une affectation. Comme le montre le quatrième echo, x vaut ensuite plop.
Si j'ai utilisé une variable intermédiaire x plutôt que directement $1, c'est surtout parce que la syntaxe du := ne peut pas être employée directement avec les paramètres positionnels, puisqu'il n'est pas si trivial que ça de changer la valeur de ces derniers (« bash: $1: cannot assign in this way »). Notez cependant qu'on peut très bien faire ${1:-plop}.

Quand j'ai découvert le :=, ça a un peu donné…

« WOOOOOO j'vais pouvoir faire des chiées d'initialisations en début de script ! »

Cependant, un truc a failli calmer ma joie :

${plop:=plup}
script.sh: line 1: plup: command not found

Quand je me suis retrouvé face à ce truc, j'avoue que je n'ai pas pigé tout ce suite ce qu'il s'était passé. Cela dit, c'est tout con, en fait : comme les appels à echo de l'exemple de tout à l'heure l'ont montré, toute la partie ${plop:=plup} est remplacée par le shell par la valeur de plop ou par la valeur par défaut plup. Résultat : l'interpréteur se retrouve avec une ligne contenant plup. Ça et rien d'autre. Que fait alors ce pauvre interpréteur ? Eh bien, il essaye d'exécuter ça, pardi. Il cherche donc vaillamment une commande plup et se chie magistralement dessus.

Réaction :

« Mais putain ! Comment dire à l'interpréteur qu'on se b… de ce qui résulte de l'expansion ? »

J'ai réfléchi un moment (tout seul dans mon coin, car je n'étais pas bien sûr de ce que j'aurais pu mettre dans une requête à DuckDuckGo mais aussi par fierté mal placée) et trouvé une solution toute conne (que je n'ai encore vu personne utiliser, mais je n'ai pas tant cherché que ça) :

: ${plop:=plup}

En gros, : est un équivalent de true. Or, true ignore superbement ses arguments et est même considéré comme le « no-op » (opération vide) de la programmation shell. Ainsi, on conserve l'affectation du := et on ne se fait pas emmerder par le résultat de l'expansion.

Tout est tellement bazardé qu'on peut même se passer de doubles guillemets autour du ${…:=…}, et ce même si vos valeurs contiennent quarante-six retours à la ligne ! De plus, vous pouvez faire plusieurs affectations pour un même no-op : : ${a:=b} ${c:=d} ${e:=f}.

Le no-op est également pratique quand vous voulez laisser un then ou un else pour plus tard ou pour n'y mettre que des commentaires expliquant pourquoi rien ne se passe à un endroit. En effet, l'interpréteur n'aime pas trop les blocs vides.

if true
then
    # Hop
    :
else
    # Re-hop
    :
fi

Vérifications

Dans le genre « trucs souvent faits de manière overkill alors que c'est gérable avec des expansions à la con », il y a aussi la vérification de paramètres. J'entends par là l'opération consistant à s'assurer qu'une ou plusieurs variables ne sont pas vides et à stopper l'exécution d'un script si cette vérification ne passe pas.

function f {
    printf '%s\t%s\n' "${1:?}" "${2:?}"
}

f plop plup
f '' plap
f plip plep
plop	plup
script.sh: line 2: 1: parameter null or not set

Ici, le second appel à la fonction f a provoqué l'arrêt du script (avec un message d'erreur) à cause de la vacuité du premier paramètre. De ce fait, le troisième appel n'a même pas pu avoir lieu.

Vous pouvez définir votre message d'erreur en foutant un truc derrière le :? : ${x:?Wèsh, badaboum boing.}. C'est cool pour les paramètres positionnels, vu que les noms de variables genre « 1 » ou « 2 » ne sont pas des plus explicites.

Comme pour le :=, on peut se servir d'un no-op pour faire des chiées de :? sans être emmerdé par le fait que des valeurs à la con poppent à la place du ${…}. Certaines personnes semblent préférer attendre la première utilisation de la valeur de la variable considérée et remplacer à cet endroit le $variable par ${variable:?}. Je vais être franc : je trouve ça super con :

  1. non seulement ça empêche les codeurs de vite piger que la variable doit impérativement être initialisée (il faut fouiner pour tomber sur le :?…),
  2. mais en plus, si on modifie le code (chose fréquente[citation needed]), on risque de se retrouver avec de nouvelles tentatives d'utilisation de la valeur de la variable avant l'endroit où on a foutu le :?, et BOUM.

Bref, dans mes scripts (récents), vous verrez plutôt des trucs de ce genre (vers le début) :

TRUC_PERSONNALISABLE='/bin/grep'

: ${1:?Veuillez donner un fruit en premier argument.}
: ${2:?Veuillez donner une fleur en second argument.}
: ${TRUC_PERSONNALISABLE:?}

# […]
bash script.sh
!! script.sh: line 3: 1: Veuillez donner un fruit en premier argument.
bash script.sh pomme
!! script.sh: line 4: 2: Veuillez donner une fleur en second argument.

(Et même principe pour les valeurs par défaut vues tout à l'heure.)

Autres

Il y a deux trois autres trucs de ce genre, mais je les utilise moins. Ça peut quand même être cool de savoir qu'ils existent, donc je vous invite à jeter un œil à la section « Parameter Expansion » du manuel de Bash, par exemple.

S'interroger sur le if, même si ça peut sembler ridicule

Syntaxe et tout ça

Les programmeurs sont tellement habitués à voir des structures du type « if, then, else » que ça ne leur vient pas à l'esprit qu'ils peuvent, dans un langage donné, se planter sur leur façon de percevoir ces trucs. Pourtant, j'ai l'impression que plus de la moitié des gens avec qui j'ai pu parler de shell n'avaient pas pigé comment le if qu'on y trouve fonctionne.

Ce qui vient tout de suite derrière le if du shell n'est pas un test booléen à proprement parler ou une connerie de ce genre, mais une commande. Cette commande est exécutée de manière tout à fait normale, et on va ensuite dans le then si cette commande s'est « bien passée », et dans le else sinon.

if mv zblork zblurk
then
    echo 'mv OK'
else
    echo 'mv foirage'
fi

if printf '%d\n' 12
then
    echo 'printf OK'
else
    echo 'printf foirage'
fi
mv: cannot stat ‘zblork’: No such file or directory
mv foirage
12
printf OK

Là, certains me feront peut-être « Mais tu dis n'importe quoi ! Quand on écrit if true, on balance bien un booléen, non ? ». Oui mais non. Non. Avez-vous déjà essayé de faire man true ?

TRUE(1)

NAME
       true - do nothing, successfully

SYNOPSIS
       true [ignored command line arguments]
       true OPTION

DESCRIPTION
       Exit with a status code indicating success.

(Putain, j'aime ce manuel.) Oh, et bien sûr :

FALSE(1)

NAME
       false - do nothing, unsuccessfully

SYNOPSIS
       false [ignored command line arguments]
       false OPTION

DESCRIPTION
       Exit with a status code indicating failure.

Bref, true et false ne sont pas des constantes ou des booléens ou que sais-je encore : ce sont des commandes, tout autant que echo ou grep. De ce fait, merci de ne pas essayer d'exécuter des trucs saugrenus comme if 0 ou if 1, car le pauvre interpréteur va essayer de trouver des commandes appelées « 0 » ou « 1 » et ça risque de mal se passer.

À propos de zéros et de uns, rappelons rapidement qu'en shell, la définition du succès est d'avoir une rolex un statut de sortie nul, tandis que les codes positifs (jusqu'à 255, si je ne dis pas de connerie) signifient qu'un truc a chié quelque part dans votre fonction ou votre script. Il y a de quoi en choquer certains, vu qu'on est habitués à gérer des « faux » équivalents à la valeur numérique zéro et à traiter tout le reste comme un « vrai » lors des conversions sauvages en booléens.

function return_zero {
    return 0
}

function return_one {
    return 1
}

if return_zero
then
    echo 'return_zero OK'
else
    echo 'return_zero foirage'
fi

if return_one
then
    echo 'return_one OK'
else
    echo 'return_one foirage'
fi
return_zero OK
return_one foirage

Enfin, un énième rappel : n'utilisez pas la valeur de retour pour trimballer des données ; foutez-vos données dans des variables, des fichiers, ou sur la sortie standard et laissez le code de retour à la gestion d'erreurs.

… Je suis en train de dériver vers test et ses potes

Point suivant : les syntaxes genre if [ … ] ou if [[ … ]]. Eh bien, il s'agit là encore de commandes à la con. Faites man [ et vous devrez atterrir sur le manuel de la commande test. Il est même probable que vous ayez un exécutable à la con nommé [ dans /usr/bin. (Pour [[, ça sera plutôt help [[, car c'est géré par l'interpréteur lui-même.) Ensuite, notez que le test par défaut avec ces syntaxes n'est pas un test numérique mais un truc de « chaîne vide ou non » :

if [ 0 ]
then
    echo '0 vrai'
else
    echo '0 faux'
fi

if [[ 1 ]]
then
    echo '1 vrai'
else
    echo '1 faux'
fi
0 vrai
1 vrai

Autre truc sournois : cette fameuse commande test a un -eq ET un = pour les tests d'égalités, mais contrairement à ce qu'on pourrait penser c'est -eq et non = qui est là pour s'occuper des tests numériques. Rappelez-vous que les variables en shell trimballent toujours des chaînes de caractères quoi qu'il arrive (bon, il y a aussi des tableaux, mais genre des tableaux qui stockent des chaînes en les indexant parfois avec d'autres chaînes…) ; ça explique probabement au moins en partie pourquoi ce sont les tests sur les chaînes de caractères qui sont considérés en priorité.

x=001

if [ "$x" = 1 ]
then
    echo 'a) vrai'
else
    echo 'a) faux'
fi

if [ "$x" -eq 1 ]
then
    echo 'b) vrai'
else
    echo 'b) faux'
fi
a) faux
b) vrai

J'ai du mal à m'arrêter

Voilà, je crois que c'est à peu près tout pour le if. Merci, donc, d'arrêter de faire des trucs qui reviennent à construire un tank pour réinventer la roue, genre les if [ "$?" -eq 0 ] quand vous pouvez juste balancer la commande précédente au if à l'arrache. Pensez aussi à regarder le manuel des commandes pour savoir dans quelles conditions elles se terminent avec un statut nul ou non. Bon, je vais vous larguer des exemples un peu à la bourrin histoire de :

x='patate'

if grep -q 'atat' <<< "$x"
then
    echo 'Trouvé.'
fi

function user_is_zblork {
    test "$(whoami)" = 'zblork'
}

if ! user_is_zblork
then
    echo 'Pas zblork.'
fi
Trouvé.
Pas zblork.

Notez au passage le -q (« quiet ») passé à grep pour lui faire fermer sa figure. Pas mal de commandes ont des options de ce type, et c'est assez utile quand seul le code de retour nous intéresse. En plus, dans ce cas-ci, j'ai l'impression que ça implique un -m 1 (je parle du -m ici), vu que le statut de sortie sera le même qu'il y ait une ou cinquante occurrences. Ça peut aller foutrement plus vite :

time seq 9999999 | grep 1 > /dev/null
!! 
!! real	0m1.200s
!! user	0m1.370s
!! sys 	0m0.075s
time seq 9999999 | grep -q 1
!! 
!! real	0m0.002s
!! user	0m0.000s
!! sys 	0m0.003s

Retours et sorties explicites inutiles

Certaines personnes auront tendance à écrire ça :

function et_bam {
    false &&
    return 0 ||
    return 1
}

et_bam &&
exit 0 ||
exit 1

Cela pose quelques problèmes :

  1. C'est verbeux sa mère.
  2. On nique le statut de sortie de la commande et on perd donc de l'information sur l'erreur qui a pu survenir en renvoyant systématiquement un 1 dans ces cas-là. Bon, il faut imaginer qu'à la place du vieux false de l'exemple on a un traitement hyper complexe, hein.

La réponse de certains à ces problèmes est une autre manière de faire, presque autant dégueulasse :

function et_bam {
    false
    return $?
}

et_bam
exit $?

Ici, on commence déjà à avoir davantage d'infos : le mec qui se sert de la fonction voire carrément du script peut obtenir le code d'erreur précis. Cependant, il y a encore deux étapes à franchir avant d'obtenir mon approbation. Première étape :

function et_bam {
    false
    return
}

et_bam
exit

Oui : return et exit sans argument refilent le statut de sortie de la dernière commande exécutée. Pas besoin de se faire chier, donc. À vrai dire, dans mes scripts récents, je ne mentionne qu'assez rarement $? directement.

Sauf que voilà, on peut faire encore mieux :

function et_bam {
    false
}

et_bam
bash script.sh
echo $?
!! 1

Autrement dit, une fonction a, si on atteint sa fin sans rencontrer de return, un statut de sortie égal à celui de sa dernière commande (comme s'il y avait un return implicite), et il en va de même pour les scripts eux-mêmes (mais en remplaçant « return » par « exit » dans ce que j'ai dit).

Trouver et lister des fichiers sans se prendre les pieds dans l'tapis

Find

On a déjà vu qu'on pouvait faire des trucs sympas avec des ./* et compagnie, mais il faut quand même avouer que des fois ça choppe un peu n'importe quoi : il y a peu de véritables critères de sélection, et le motif essaye de se faire passer pour un nom de fichier si on ne trouve rien. Parfois, un petit find dépanne bien.

unset -v files
while read -rd '' f
do
    files+=("$f")
done < <(
    find -regextype 'posix-extended' \
            -maxdepth 1 -type f \
            -iregex '.*\.(mp3|flac|wav)' \
            -print0 | sort -zV
)

Dans cet exemple, on fout carrément nos fichiers dans un tableau, comme ça on peut ensuite taper dedans comme on veut (for x in "${files[@]}"), savoir combien il y en a (${#files[@]}), etc.

Lorsqu'on veut traiter la sortie de find, il est recommandé dans 98 % des cas d'utiliser -print0 pour avoir des données null-separated. Si vous êtes juste en train de vérifier des trucs dans un terminal, OK pour l'affichage tout con, mais dans un script ou une vraie commande, surtout pas. Je viens d'ailleurs de voir que le manuel de find disait, assez tôt : « If no expression is given, the expression -print is used (but you should probably consider using -print0 instead, anyway). »

Au cas où des trucs vous intriguent :

Ça peut sembler con, mais si vous voulez ne récupérer que les fichiers tout bêtes avec find, n'oubliez pas le -type f, même si vous avez la certitude d'être dans un répertoire ne contenant pas d'autres répertoires. En effet, vous risquez de vous retrouver avec un . (le répertoire courant) parmi les résultats.

Xargs

xargs, c'est rigolo. Parfois, on se dit « tain, là, j'ai des fichiers qui débarquent sur l'entrée standard et j'aurais préféré qu'ils soient donnés en arguments de la commande suivante… » et on a pas forcément envie d'imbriquer quarante "$(…)". xargs permet de se sortir de manière pas trop reloue de ce genre de situations, offre quelques outils supplémentaires, et gère bien les données séparées par des octets nuls.

touch a b c d e f
find -type f -print0 \
        | sort -Vz \
        | xargs -0n 3 \
        printf '1 %s 2 %s 3 %s 4\n'
1 ./a 2 ./b 3 ./c 4
1 ./d 2 ./e 3 ./f 4

Ici, on fait des « paquets » de trois éléments, et pour chaque paquet on appelle la commande spécifiée (printf).

  1. Le premier appel donne printf 'leformat' ./a ./b ./c,
  2. et le second la même chose mais avec ./d ./e ./f.

On peut aussi avoir besoin de glisser ces arguments ailleurs qu'à la fin de notre commande :

echo a | xargs -I patate echo 1 patate 2
1 a 2

On déclare avec -I que la chaîne patate sera utilisée pour invoquer les arguments, puis… bah… on écrit patate là où on veut dans la commande, quoi. Les gens utilisent souvent {} comme chaîne pour cet usage.

Dernier truc que je veux évoquer à ce sujet : parfois, il ne faut pas que xargs exécute quoi que ce soit s'il n'a rien pu lire à partir de son entrée standard. Dans ces cas-là, il faut lui dire de calmer sa joie. Pas évident d'y penser à chaque endroit où c'est logique de le faire, mais bon.

: | xargs touch
!! touch: missing file operand
!! Try 'touch --help' for more information.
: | xargs --no-run-if-empty touch
!! (Rien ne se passe.)
: | xargs -r touch
!! (Équivalent court mais moins lisible.)
Certains vous diront peut-être qu'ils font les trois quarts de ces trucs avec le -exec de find plutôt qu'avec xargs. Chacun son délire, dans ce domaine. Ça fait des années que je n'ai pas touché à -exec, que je trouve lourdingue et peu souple, mais son usage peut parfois se justifier pour des questions de performances ou autres.

&& et ||

Vrai et faux fonctionnement

Les && et les || sont un peu sournois car on croit souvent avoir pigé comment ils marchent, jusqu'au jour où on se mange des bugs bien sales. En plus de ça, on les croit souvent moins permissifs qu'ils ne le sont vraiment niveau syntaxe, ce qui fait que les gens ont tendance à pondre du code moche avec des lignes à rallonge.

Reprenons depuis le début, avec un cas con :

grep 'a' zblorp &&
echo 'OK' ||
echo 'Foire'
grep: zblorp: No such file or directory
Foire

Ça a beau être un cas basique, certains seront peut-être déjà choqués : je suis revenu à la ligne avant chaque commande. Hé oui, vous n'êtes pas obligés d'écrire des trucs à rallonge genre commande_1 && commande_2 && … sur une ligne. D'ailleurs, ce n'est même pas que vous n'êtes pas obligés, c'est plutôt que vous n'aurez que rarement de bonnes raisons de le faire. Plus ça va et plus je ne me l'autorise que pour faire un vieux exit (ou return, break ou continue), personnellement. On ne va quand même pas mochifier notre code sans rien y gagner. Bon, après, si vous êtes dans un terminal plutôt qu'un script, c'est une autre histoire.

Rappelons le principe, mais volontairement de manière grossière pour bien montrer que l'explication classique est merdique : le && exécute la commande suivante si tout s'est bien passé, et le || ne l'exécute que si quelque chose a chié. Pour les définitions de « bien se passer » et « chier », voir la section où je gueule au sujet des if.

Oui mais voilà : ce qui me gêne dans cette façon de présenter les choses, c'est qu'elle donne un peu trop l'impression que ces séparateurs lient une commande à une autre, alors que ce n'est pas vraiment le cas. Ils lient plutôt un groupe de commandes à la commande suivante. En effet, si j'écris un truc comme ça…

true ||
false &&
echo 'BIM!'
BIM!

… vous serez bien embêtés, car ça donne l'impression que l'appel à echo est lié à false par un && et qu'il ne devrait donc pas s'exécuter. Or, il s'exécute. Il y a plusieurs manières de voir les choses pour mieux piger ce qu'il se passe :

Cette deuxième explication, que je n'ai pour l'instant vue nulle part ailleurs qu'ici (mais je n'ai pas non plus passé trois plombes à chercher), peut sembler tordue, mais elle a le mérite de rendre presque limpides des cas à la con comme celui-ci :

true ||
efzfevez ||
fefzfe ||
eofenofnoz &&
echo 'Pif' &&
false &&
adpked &&
daofjacap &&
anddkna ||
echo 'Paf'
Pif
Paf

Notez vite fait qu'aucun des six faux noms de commandes que j'ai tapés n'ont été lus par l'interpréteur de commandes, et vous verrez déjà que l'illustration de la gentille chaîne de commandes liées deux à deux est un peu bancale.

Ensuite, décortiquons ce qu'il se passe : on a un bloc de dix commandes à cause des && et des ||. Si j'avais ajouté un vieux echo à la fin sans mettre de && ou de || entre lui et le echo 'Paf', ce nouvel echo aurait été hors du bloc. On peut ensuite isoler la première commande et associer chaque autre commande au séparateur qui la précède :

→ true
|| efzfevez
|| fefzfe
|| eofenofnoz
&& echo 'Pif'
&& false
&& adpked
&& daofjacap
&& anddkna
|| echo 'Paf'
Ça n'a rien d'une syntaxe valide ; c'est juste pour l'explication.
  1. First things first, on exécute true, qui, bien entendu, ne se chie pas dessus.
  2. On passe en mode « WÈSHEUUUU la dernière commande exécutée a marché du tonnerre de Dieu ! »
  3. On dégage true qui a fini son boulot, et on descend dans notre liste à la recherche de la première commande associée à un &&.
  4. Ce faisant, on tombe sur echo 'Pif' après avoir mis un gros vent aux trois premières fausses commandes avec les noms pourris.
  5. On affiche notre « Pif », et puisque tout se passe bien on retourne chercher un &&.
  6. Il se trouve que cette fois-ci il y en a un juste derrière, associé à la commande false. On exécute donc cette commande.
  7. Diantre ! Quelle surprise ! false s'est chié dessus ! On passe en mode « WÈSHEUUUU la dernière commande exécutée a chié ! »
  8. On part à la recherche d'un || à cause de cet échec. Le premier est celui de echo 'Paf', qu'on exécute.
  9. Voilà, on a terminé d'exécuter le bloc.

Pour piger encore un peu mieux, on peut ressortir ce qui, je crois, est la version officielle : chaque ensemble de && successifs forme un groupe de commandes dont le statut de sortie global est « OK » si et seulement si toutes les commandes étaient OK, tandis que les || successifs créent eux aussi des groupes, sauf que cette fois-ci le statut global n'a besoin que d'une réussite dans le groupe pour être « OK ». Il y a là une grosse analogie avec les « et » et « ou » logiques auxquels les programmeurs sont habitués, mais je trouve ça casse-gueule, car typiquement on ne profite pas des règles de priorités qui s'appliquent généralement à ces opérateurs logiques : la liste de commandes est bêtement parcourue dans l'ordre, comme je le montrais à l'instant.

Courts-circuits

Même si je trouve que l'appellation « “et” et “ou” logiques du shell » est plus gênante qu'autre chose pour comprendre le fonctionnement de ces trucs, il y a quand même un gros point commun avec les opérateurs booléens classiques un point commun bien utile. En effet, il est possible de court-circuiter les expressions : dans un « machin ou truc », inutile de consulter « truc » si machin est déjà vrai, puisqu'on sait déjà que l'expression globale vaudra vrai. À l'inverse, le moindre échec dans une suite de « et » sera fatal à cette suite de « et ».

De la même manière qu'un développeur Java ou C exploitera ces courts-circuits pour vérifier la validité d'une référence avant de l'exploiter dans la même condition, un script shell pourra s'assurer qu'un fichier est disponible pour en faire un truc dans la foulée :

fic='aojdfiafie'
if test -r "$fic" &&
    grep -q 'plop' "$fic"
then
    echo 'OK'
else
    echo 'Foire'
fi
Foire

Ici, j'ai fait exprès d'utiliser explicitement test plutôt que la syntaxe avec le vieux crochet afin de vous montrer au passage un truc rigolo : vous pouvez en réalité glisser un nombre arbitraire de commandes entre le if et le then. C'est, là encore, le statut de sortie de la dernière commande exécutée qui fera foi en arrivant à la fin de cette suite de commandes. Dans cet exemple précis, donc, on foire le test -r car le fichier n'est pas trouvé ou pas lisible, on ignore le grep puisqu'il est associé à un &&, et on arrive au moment fatidique du choix entre le then et le else. Or, la dernière commande exécutée est notre test -r, qui a foiré, donc on va dans le else.

Bon, n'abusez pas des commandes placées entre le if et le then (faites des fonctions et tout ça), mais je trouve amusant de savoir qu'on peut faire des trucs de ce genre (avec les while, qui suivent sensiblement les mêmes règles) :

x=''
while x+='a'
    grep -q 'ca$' <<< "$x" &&
    x+='b' ||
    x+='c'
    test "${#x}" -lt 10
do
    echo "$x"
done
ac
acab
acabac
acabacab

On peut ainsi simuler des « do… while… », voire faire des hybrides plus ou moins monstrueux. Notez qu'il n'est même pas vital de lier toutes les commandes situées avant le do (ou le then, dans le cas du if) avec des && ou des ||.

Bon, je voulais quand même parler de courts-circuits, à la base… En gros, là où je voulais en venir, c'est que si vous faites…

f='zblorp'
if [ -r "$f" -a "$(grep 'plop' "$f")" ]
then
    true
fi

… ça va faire de la merde :

grep: zblorp: No such file or directory

En effet, il faut interpréter toute la partie [ -r "$f" -a "$(grep 'plop' "$f")" ] comme une unique commande (c'est exactement ce que c'est, en fait) qui ne peut être exécutée qu'une fois que tous ses arguments ont été bien déterminés. Le shell est donc obligé d'exécuter le grep 'plop' "$f" pour fournir à test / [ tout le matos nécessaire à son exécution. Or, on va droit dans le mur puisqu'on a pas encore fait le -r "$f" censé vérifier que le fichier est bien dispo.

Une solution consiste à faire ceci :

if [ -r "$f" ] && [ "$(grep 'plop' "$f")" ]

Cela divise le test en deux commandes et permet d'éviter de taper dans un fichier inexistant. Bon, et le [ "$(grep 'plop' "$f")" ] pourrait être remplacé par un grep -q 'plop' "$f", mais c'est une autre histoire.

Gare aux abus

Quand j'ai commencé à comprendre comment ces trucs marchaient (et même un peu avant, malheureusement), je me suis mis à écrire des trucs crades en exploitant les accolades pour grouper des commandes :

# NE PAS FAIRE ÇA
true &&
{
    echo 'plop'
    echo 'plup'
} ||
{
    echo 'plip'
    echo 'plap'
}
plop
plup

Ne réinventez pas la roue avec des syntaxes cheloues juste pour économiser quelques caractères : faites de bons vieux « if then else ». Gardez les && et les || pour ce genre de cas :

truc_vital_pour_le_script || exit

commande_a &&
commande_b_dénuée_de_sens_si_a_foire &&
commande_c_dénuée_de_sens_si_b_foire

for x in a b c d
do
    est_interessant "$x" || continue
    traitement_bourrin
done

En général, mélanger les « et » et les « ou » au sein d'un même bloc est dangereux (on verra ça tout à l'heure) et nique la lisibilité. Oh, et si vous pensez qu'utiliser ces trucs pour foutre des valeurs par défaut ici et là comme ça est une bonne idée :

# JE N'AIME PAS ÇA
# Remplacement si non vide.
test "$y" &&
x='plop'
# Défaut si vide.
test "$y" ||
y='plup'

Si vous trouvez ça cool, donc, eh bien vous devriez aller voir ma section sur l'expansion, car il existe déjà des syntaxes pour les valeurs par défaut et les valeurs de remplacement, donc c'est con de faire tout un pataquès conditionnel.

&& || ≠ if then else

On croit souvent qu'une structure en cmd_1 && cmd_2 || cmd_3 est un parfait équivalent du « if then else », et certains s'en servent comme d'un genre d'opérateur ternaire sans trop réfléchir à ce qu'il se passe en coulisse. Si vous compilez tout ce que j'ai dit dans cette section, vous réaliserez qu'il est tout à fait possible que cmd_2 ET cmd_3 soient exécutés !

echo 'plop' &&
grep 'a' zblorp ||
echo 'plup'
plop
grep: zblorp: No such file or directory
plup

En effet, si la seconde commande foire, la dernière se dira « wooo, le dernier truc exécuté a foiré et je suis précédé d'un || ! » Et pouf, ça s'exécute. On a donc davantage affaire à un truc genre « if then if not then else », ou une connerie de ce style, avec la dernière commande qui apparaît à deux endroits. Ainsi, si vous tenez vraiment à écrire des structures de ce type, tenez-vous-en à des affectations ou à de l'affichage tout bête pour la seconde commande. Mais globalement, c'est chiant d'avoir à se demander si ce qu'on appelle est bancal ou non, donc faites des if et ça sera plus simple (et probablement plus lisible).

if echo 'plop'
then
    grep 'a' zblorp
else
    echo 'plup'
fi
plop
grep: zblorp: No such file or directory

Je parie que c'est en grande partie à cause de la fausse croyance selon laquelle le if ne peut être utilisé qu'avec [ … ] ou [[ … ]] qu'autant de gens pondent des gros pavés ignobles et peu robustes à base de && et de ||

Groupement de commandes

Ne pas dégueulasser son environnement

Il est possible de regrouper des commandes avec des parenthèses ou des accolades, et certains pensent (je crois) que ce sont des notations équivalentes, alors que non.

n=0
(
    ((n++))
    echo "$n"
)
echo "$n"
1
0
n=0
{
    ((n++))
    echo "$n"
}
echo "$n"
1
1

Si vous avez bien appris votre leçon, vous aurez pigé ce qu'il s'est passé avec les parenthèses : elles ont pondu un sous-shell dans lequel se sont exécutées les commandes du groupe. La modification de la valeur de n est donc restée cloîtrée dans ce sous-shell, et a été perdue lorsque nous sommes remontés au père.

Là, certains diront peut-être : « mais c'est de la m****, les parenthèses, alors ! Autant foutre des accolades partout ! » Sauf que non, il y a des fois où c'est cool d'oublier des trucs en sortant du groupe :

(
    cd /tmp || exit
    IFS=','
    while read a b
    do
        echo "$a/$b"
    done <<< $'1 2,34\n56,7 8'
)

pwd
echo "IFS = [$IFS]"
1 2/34
56/7 8
/home/alice/…/pas_tmp
IFS = [ 	
]

Ici, on a pu aller dans un répertoire à la con et dire bien salement au shell qu'il devait utiliser les virgules (et uniquement les virgules) pour distinguer un mot de son suivant, et POUF, quand on sort du groupe de commandes (et donc du sous-shell) on revient à notre état tout propre d'avant. C'est cool. Plus besoin de mettre de côté le répertoire courant et la valeur de variables d'environnement à la con ; tout est réinitialisé pour nous.

Comme on l'a vu, les tubes ont tendance à faire popper des sous-shells, si bien qu'on aura parfois un comportement similaire à celui des parenthèses avec les accolades :
{ cd /tmp; }; pwd
!! /tmp
cd; pwd
!! /home/alice
{ cd /tmp; } | :; pwd
!! /home/alice
Ça peut surprendre.

Factoriser les redirections

Les groupes sont aussi cool pour rediriger la sortie ou l'entrée de plusieurs commandes de la même manière. Ça évite d'avoir à gérer trop à la main des concaténations à la con et surtout d'écrire quarante fois la même chose (genre >> fichier) :

{
    echo 'plop'
    seq 3
    grep 'plup' zblorp
} 2>&1 | tr -cd '[:alnum:]\n'
plop
1
2
3
grepzblorpNosuchfileordirectory

Comme cet exemple dénué de sens vous le montre avec brio, ça marche aussi bien avec les redirections qu'avec les tubes. Vous pouvez également balancer un groupe de commandes en arrière-plan, genre { sleep 2; echo 'poire'; } &.

Il existe une différence de syntaxe à la con entre les accolades et les parenthèses : on peut écrire ( true; true ), mais le point-virgule final est obligatoire dans { true; true; }. Mais bon, si vous commencez à avoir besoin de ces outils dans un terminal, c'est probablement que vous êtes en train de complètement craquer et que vous devriez créer un vrai script.

Trucs utiles divers

Exécuter des trucs pas exécutables

Je vois parfois des mecs se faire chier à balancer du chmod (parfois en regardant le manuel (je ne me moque pas, juste que ça rend l'action plus longue encore)) pour rendre exécutable un script dont ils n'ont de toute manière prévu de se servir qu'une seule fois, afin de pouvoir le lancer en faisant ./nom_du_script. Or, si vous savez vite fait pour quel genre de shell le script a été écrit, vous pouvez juste faire bash nom_du_script, sh nom_du_script, zsh nom_du_script, etc. Je n'ai d'ailleurs pas arrêté de faire ça en écrivant le présent document.

Cette technique peut également être sympa si vous devez exécuter un script depuis un autre script. Les droits d'exécution ont tendance à sauter quand on copie des scripts sur certains supports de stockage. Enfin bon, faut quand même savoir ce qu'on fait : n'allez pas passer à bash un script Python, quoi. Oh, et il y a des options marrantes pour le débug, comme bash -x qui vous montre les commandes qui sont exécutées après expansion des variables et compagnie.

Fouiner dans l'historique

J'aurais peut-être dû balancer ça plus tôt, mais : avec pas mal de shells (à condition de ne pas avoir utilisé d'options cheloues), on peut assez vite fouiner dans l'historique en spammant Ctrl-R et en tapant des bouts de la commande qu'on cherche. C'est généralement moins relou que de faire des grep sur la sortie de history (que je ne maîtrise peut-être pas des masses, cela dit).

yes 'poire' | head -3 | nl
!!      1	poire
!!      2	poire
!!      3	poire
(reverse-i-search)`poire’: yes 'poire' | head -3 | nl

Enfin bon, l'important est de savoir que ça existe ; après, vous pouvez aller chercher les détails sur le net.

Caractères à la con de manière triviale

Fut un temps, je faisais des trucs assez tordus pour mettre des retours à la ligne ou des tabulations dans des variables ou pour en passer en argument à des commandes…

x='plop
plup	plap'
echo "[$x]"
[plop
plup	plap]

On peut effectivement revenir à la ligne au beau milieu d'une chaîne de caractères, « en dur » dans le code, mais ça n'est pas le truc le plus ouf qui soit et ça fiche le bazar dans l'indentation du code. Vous risquez même d'insérer par erreur des espaces dans votre chaîne en indentant des lignes ! Quant aux tabulations, on peut balancer le caractère en dur là aussi (genre Ctrl-Shift-U 9 si votre touche de tabulation vous insère des espaces), mais ça n'est pas hyper flagrant que c'est une tabulation, pis ça fait un vieux trou, etc.

La solution que j'adopte souvent : les chaînes en $'blabla'. L'interpréteur les remplace par la version sans le dollar après avoir traité toutes les séquences à la con genre \n, \t ou \r en foutant les caractères nécessaires à la place.

x=$'plop\nplup\tplap'
echo "[$x]"
x='plop'$'\n''plup'$'\t''plap'
echo "[$x]"
[plop
plup	plap]
[plop
plup	plap]

J'ai tendance à isoler les caractères « problématiques » quitte à mettre des chiées de guillemets, bien qu'on puisse souvent construire la chaîne en un seul bout. Je vous comprendrai si vous me dites que vous trouvez ça overkill. Dans tous les cas, ça reste mieux que de s'embêter à appeler printf pour trois fois rien ou à utiliser des options à la portabilité douteuse pour faire faire à echo des choses qu'on ne devrait pas trop lui confier.

À mes yeux, l'usage le plus courant de cette technique consiste à passer une vieille tabulation en paramètre à une commande, souvent pour spécifier un séparateur ou un truc de ce style :

{
    echo $'a b\tc d'
    echo $'e f\tg h\t'
} | while read -d $'\t' line
do
    echo "[$line]"
done
[a b]
[c d
e f]
[g h]

Ici, on dit joyeusement à read que la définition d'une « ligne » est « un truc qui se finit par une tabulation ». Résultat : la deuxième fournée de données est à cheval sur deux véritables lignes. Autre exemple : forcer awk à se comporter comme le fait cut par défaut, c'est-à-dire ne considérer que les tabulations comme des séparateurs de champs plutôt que les tabulations et les espaces :

awk '{ print $1 }' <<< 'a b'
awk -F $'\t' '{ print $1 }' <<< 'a b'
a
a b

Tant que nous y sommes, donnons une tabulation à manger à cut, pour la forme :

cut -f 1 <<< 'a b'
cut -f 1 <<< $'a\tb'
a b
a

column

Je crois qu'un jour j'ai eu une discussion qui ressemblait à ça :

— Comment on fait des colonnes, en Bash ?

— … Bah « column ».

Ça semble presque trop facile, mais ça envoie vraiment du houmous.

Il est important de distinguer deux modes de fonctionnement de la commande column : soit vous avez juste une chiée de données sous forme de liste…

seq 40 | head -4
echo '…'
echo
seq 40 | column
1
2
3
4
…

1	5	9	13	17	21	25	29	33	37
2	6	10	14	18	22	26	30	34	38
3	7	11	15	19	23	27	31	35	39
4	8	12	16	20	24	28	32	36	40

… soit vous avez un truc qui a déjà une dégaine de tableau :

function aff {
    printf '%s\t' "$@"
    echo
}

function donnees {
    aff a b c
    aff 133731 26427427642 2426247
    aff plop plap plup
}

donnees
echo
donnees | column -ts $'\t'
a	b	c	
133731	26427427642	2426247	
plop	plap	plup	

a       b            c
133731  26427427642  2426247
plop    plap         plup

Concrètement, -t prévient qu'on a déjà un genre de tableau, et -s, qui prend un argument, sert à dire avec quoi on a séparé nos colonnes. column se charge ensuite de remplacer nos séparateurs par le bon nombre d'espaces pour que les colonnes soient joliment alignées. Le résultat est plutôt cool quand on veut zieuter rapidement des données. Mais voilà : j'ai bien dit « remplacer nos séparateurs ». Si vous voulez par exemple aligner les colonnes d'un CSV (avec des colonnes séparées par des virgules, donc) et conserver un CSV en sortie, vous devrez balancer de petites expressions rationnelles ou quelque chose comme ça :

function aff {
    printf '%s,%s,%s\n' "$1" "$3" "$2"
}

function donnees {
    aff a b c
    aff 133731 26427427642 2426247
    aff plop plap plup
}

donnees
echo
# Virgules bouffées :
donnees | column -ts ','
echo
# Virgules préservées :
donnees | sed 's/,/,\t/g' | column -ts $'\t'
# Une version plus robuste serait :
#   's/,[ \t]*/,\t/g'
# afin de gérer proprement les cas
# où des espaces sont déjà présents.
a,c,b
133731,2426247,26427427642
plop,plup,plap

a       c        b
133731  2426247  26427427642
plop    plup     plap

a,       c,        b
133731,  2426247,  26427427642
plop,    plup,     plap

Ça a plein d'applications auxquelles on ne pense pas nécessairement tout de suite, genre pour les chemins de fichiers :

find 2018/ | column -ts '/'
!! […]
!! 2018  dessins                     salon_impact.png
!! 2018  dessins                     conscrits.png
!! 2018  dessins                     galette.png
!! 2018  shell
!! 2018  shell                       toc.js
!! 2018  shell                       prism.css
!! 2018  shell                       trucs_shell.html
!! 2018  shell                       styles.css
!! 2018  shell                       prism.js
!! 2018  merzbow_-_pulse_demon.html
!! 2018  zik_janvier_2018.html

Respectez mes yeux

Pas mal de gens se la raclent avec des pseudo-one-liners à la con en sed, sauf que les mecs ils écrivent ça de manière dégueulasse, sans espaces ni rien, et font parfois tout un pataquès pour exécuter plusieurs commandes alors que sed est censé pouvoir en gérer des chiées en un appel. Voici donc des écritures équivalentes pour un filtre à la con :

seq 6 | sed -ne '/2/,5s/./a&a/' -e 's/a/b/g' -e '/[246]/d' -e 'p'
# Rassemblement des commandes, car bon, faudrait
# quand même pas oublier l'existence des
# points-virgules… Oh et ça rend -e inutile, hein.
seq 6 | sed -n '/2/,5s/./a&a/;s/a/b/g;/[246]/d;p'
# On a tout à fait le droit de foutre des
# espaces après les points-virgules, hein.
seq 6 | sed -n '/2/,5s/./a&a/; s/a/b/g; /[246]/d; p'
# Oh et puis merde, revenons à la ligne bien qu'on
# soit dans une chaîne de caractères du shell.
seq 6 | sed -n '
    /2/,5s/./a&a/
    s/a/b/g
    /[246]/d
    p
'
# Et pour finir, ajoutons un espace entre
# les critères de sélection et les commandes.
seq 6 | sed -n '
    /2/,5 s/./a&a/
    s/a/b/g
    /[246]/ d
    p
'
# Ah et on peut commenter, aussi.
seq 6 | sed -n '
    # From regex /2/ until line 5,
    # put characters between “a”s.
    /2/,5 s/./a&a/
    # Turn “a”s into “b”s.
    s/a/b/g
    # Skip lines containing
    # a 2, a 4 or a 6.
    /[246]/ d
    # Print transformed line.
    p
'

Je n'exagère même pas : j'ai déjà vu, sur StackOverflow, des mecs balancer fièrement des trucs deux fois plus longs que ça sans espaces ni retours à la ligne (donc bien entendu sans commentaires non plus), avec des blocs de commandes ({…}) imbriqués et compagnie. Truc de fou.

Et si vous êtes curieux au sujet du résultat de cette commande débile…

1
b3b
b5b

Yaaay.

C'est aussi valable pour awk, hein. Marre de voir des awk '/plop/&&NF>2{x+=2;print}END{print x}'. On a l'impression qu'on est deux kilomètres sous l'eau et que la pression nique tout l'air qu'il pourrait y avoir dans le script…

Un vieux read contre l'apocalypse

Technique toute conne qui sert bien : le vieux read sans argument pour donner le temps à un mec d'annuler un truc :

echo 'Entrée pour continuer, Ctrl-C pour annuler.'
read
echo 'TRUCS DANGEREUX ICI'

Même si c'est toujours cool de coder un truc qui lit la réponse du mec et qui réagit en fonction, ou oublie souvent qu'un read sans argument est déjà bien sympa. Et puis, en rajoutant deux trois trucs à la con, on peut déjà s'amuser un peu :

echo 'Entrée pour continuer, Ctrl-D pour annuler.'
if read -s
then
    echo 'TRUCS DANGEREUX ICI'
else
    echo 'Annulation'
fi

Ici, j'exploite le fait que :

  1. read balance un statut de sortie d'erreur s'il n'arrive pas à lire ce qu'il voulait lire ;
  2. Faire un Ctrl-D balance un end of file et dit donc à read d'aller se faire voir.

En bonus, le -s empêche read d'afficher ce qu'on tape, et donc de laisser un vieux trou à la con quand on appuie sur Entrée.

Pour les utilisations plus tordues, vous devrez passer un nom de variable en argument à read puis fouiner dedans, je suppose, et ça devient déjà plus chiant, à tel point qu'on a parfois la flemme de le faire et qu'on finit par laisser des trucs sensibles sans protection. D'où, selon moi, l'intérêt des méthodes un peu cheap.

Si vous avez finalement besoin d'utiliser de manière non interactive un script contenant ce type de sécurités, vous pouvez toujours lui donner à manger la sortie de yes, qui a plus ou moins été conçu pour ça : yes | script_relou.

Chargement de sources

Vous savez peut-être que faire . nom_de_fichier permet de charger le contenu du fichier comme si on exécutait tout son contenu directement dans le shell courant. Cela permet notamment de faire des réglages à la con : options du shell, valeurs de variables… On l'utilise surtout pour recharger son fichier de profil (~/.bashrc et compagnie) après l'avoir modifié, ou pour gérer des sortes de bibliothèques d'outils.

Là où il m'arrive de gueuler, c'est quand les gens se mettent à utiliser ce vieux . pas hyper lisible dans des scripts. En effet, Bash a introduit il y a déjà pas mal de temps un alias pour cette opération : source. C'est quand même plus cool. Gardez la version abrégée pour quand vous vous faites chier à essayer d'aller vite en tapant des daubes dans un terminal.

Tableaux associatifs

Une bonne vieille espèce de table de hachage peut souvent éviter d'avoir à écrire des chiées de trucs dans des vieux fichiers temporaires et de se poser de futiles questions sur le formatage de nos données de travail :

declare -A t
t[patate]='gugume'
t[poire]='fruit'
t[tomate]='???'
t[a$'\n'b$'\t'c]='!'

for clef in "${!t[@]}"
do
    printf '[%s] → [%s]\n' \
            "$clef" \
            "${t[$clef]}"
done
[tomate] → [???]
[a
b	c] → [!]
[patate] → [gugume]
[poire] → [fruit]

Comme vous pouvez le voir, on peut utiliser un peu nawak comme clef (ce qui remplace les indices des tableaux normaux).

Avec un tableau normal, puisque les indices sont de vieux nombres, les noms de variables sont directement interprétés comme il faut même sans dollar quand on écrit t[variable]. Avec un tableau associatif, cependant, variable sera interprété comme une bête chaîne de caractères qui servira de clef ; il vous faudra écrire t[$variable]. Par contre, pas besoin de foutre des guillemets, si j'en crois mes essais persos : l'interpréteur attendra sagement le « vrai » crochet fermant, même si la variable utilisée a une valeur tordue genre a]]]]b]]c.

Bon, par contre, n'essayez pas de convertir à l'arrache un tableau normal en tableau associatif ou inversement :

echo 1
t=(a b c)
declare -A t &&
echo 'OK'

echo 2
unset -v t
declare -A t &&
echo 'OK'
1
script: line 3: declare: t: cannot convert indexed to associative array
2
OK

Il vaut mieux poutrer la figure de la variable avant de tenter d'en faire un tableau de quelque type que ce soit. Ça vous assurera du même coup que vous n'allez pas taper dans un tableau contenant de la daube en croyant qu'il est vide. On est jamais bien certain de connaître le contexte dans lequel sera appelée une fonction ou un script, en plus, donc bon.

Dernières occurrences et tout ça

Parfois, on veut chopper par exemple les dernières occurrences d'un truc dans des données. Dans de tels cas, il est quand même dommage de demander à grep (ou whatever vous utilisez) de se taper tout le texte, non ? Une commande hélas méconnue peut faire des merveilles pour ce genre de cas à la con, qui arrive rarement mais à un peu tout le monde, au fond : tac.

seq 4
!! 1
!! 2
!! 3
!! 4
seq 4 | tac
!! 4
!! 3
!! 2
!! 1

Vous avez pigé l'idée : on renverse l'ordre des lignes, et pouf !

seq 999 | tac | grep -m 2 '55' | tac
855
955
Si vous vous fichez de l'ordre dans lequel apparaissent les résultats ou si vous fixez la limite de grep à 1, le second appel à tac (à la fin) est bien entendu superflu.

Ça peut faire des grosses différences de rapidité sur de gros fichiers, puisqu'on n'analyse pas des tas de trucs pour que dalle. Ça peut aussi être cool si vous savez que ce que vous cherchez est plutôt vers la fin… ou simplement si vous avez besoin de renverser un truc pour une raison quelconque, en fait.

Il y a aussi des options pour faire des trucs un peu perchés, genre utiliser un autre séparateur que le retour à la ligne seul…

seq 6 | tac -s '4'$'\n'
5
6
1
2
3
4

basename et dirname

Je vois parfois des gens faire des trucs assez perchés pour récupérer, à partir d'une chaîne de caractères décrivant un chemin vers un fichier, le nom de base du fichier ou un chemin vers le répertoire dans lequel il se trouve. Or, il existe des outils spécialement faits pour…

basename '../plop/.././plup/plap.txt'
!! plap.txt
dirname '../plop/.././plup/plap.txt'
!! ../plop/.././plup
basename "$(dirname '../plop/.././plup/plap.txt')"
!! plup

Je ne veux donc plus vous voir faire des "${fichier##*/}" et autres "${fichier%/*}". Sérieusement, j'ai beau trouver fascinante l'expansion de variables, n'allez pas me dire que c'est super explicite pour les gens qui lisent votre code. De plus, il est fort pénible d'avoir à mettre la donnée dans une variable (ça n'est guère viable dans une suite de commandes liées par des tubes). Oh, et évitez également les appels à sed et compagnie pour ce type de cas, hein. Pas abuser, quand même.

Oh, au fait, il y a aussi des problèmes de robustesse quand vous essayez de faire vos bidouilles à la main. Ainsi, un dirname sur un chemin genre plop vous donnera ., ce qui en soi est correct et permet de faire des concaténations de chemins sans aller dans le mur, tandis que la technique du "${chemin%/*}" avec chemin='plop' vous donnera plop. De la même manière, basename saura très bien gérer un chemin se terminant par un slash alors que "${chemin##*/}" vous balancera joyeusement… une chaîne vide !

f='abc'
echo 'abc, dirname :'
dirname "$f"
echo 'abc, wtf :'
echo "${f%/*}"

echo

f='a/b/c/'
echo 'a/b/c/, basename :'
basename "$f"
echo 'a/b/c/, wtf :'
echo "${f##*/}"
abc, dirname :
.
abc, wtf :
abc

a/b/c/, basename :
c
a/b/c/, wtf :

AWK

AWK, c'est rigolo. S'il n'y a pas eu de couille, mon espèce de tuto sur le sujet doit se trouver ici : www.alicem.net/files/txts/awk_alice.pdf.

Switch, case, ou appelez ça comme vous voulez

Je voulais juste balancer un exemple vite fait, car généralement soit les gens croient qu'on ne peut balancer que des chaînes fixes à la con aux case, soit ils se plantent dans la syntaxe.

function test_case {
    printf "$1\t"
    case "$1" in
        [pf]lop|pat*te)
            echo '[pf]lop|pat*te'
            ;;
        a|b|c|??)
            echo 'a|b|c|??'
            ;;
        *)
            echo '*'
            ;;
    esac
}

{
    test_case plop
    test_case flop
    test_case patate
    test_case patte
    test_case a
    test_case b
    test_case c
    test_case xx
    test_case zblork
} | column -ts $'\t'
plop    [pf]lop|pat*te
flop    [pf]lop|pat*te
patate  [pf]lop|pat*te
patte   [pf]lop|pat*te
a       a|b|c|??
b       a|b|c|??
c       a|b|c|??
xx      a|b|c|??
zblork  *

En résumé, sans partir dans les trucs exotiques qui peuvent demander d'activer des options du shell, vous avez droit au même genre de trucs que quand vous écrivez des motifs pour chopper des chiées de fichier. L'étoile peut coller à n'importe quel bout de chaîne, le point d'interrogation peut correspondre à n'importe quel caractère mais il faut qu'il y en ait exactement un, et les crochets donnent des listes de caractères autorisées (avec la possibilité d'en faire une liste de caractères exclus en foutant un ^ au début). Enfin, la barre verticale permet d'associer plusieurs motifs au même bloc d'actions.

Second petit exemple vite fait pour couvrir le sujet des espaces, car dans les motifs ils ont une fâcheuse tendance à être ignorés, voire à provoquer des erreurs de syntaxe à la con. Fort heureusement, on peut les échapper, et on a même des chiées de moyens pour ça.

function test_case {
    printf "[$1]\t"
    case "$1" in
        a )
            echo 'a_'
            ;;
        b\ )
            echo 'b\_'
            ;;
        c' ')
            echo "c' '"
            ;;
        ' d d ')
            echo "' d d '"
            ;;
        *)
            echo '???'
            ;;
    esac
}

{
    test_case a
    test_case 'a '
    test_case b
    test_case 'b '
    test_case c
    test_case 'c '
    test_case ' d d '
} | column -ts $'\t'
[a]      a_
[a ]     ???
[b]      ???
[b ]     b\_
[c]      ???
[c ]     c' '
[ d d ]  ' d d '

On constate que dans le cas du a ) l'espace à la fin du motif a été bouffé. Résultat : un pauvre a tout seul tombe dans ce bloc, tandis qu'un a suivi d'un espace se fait injustement rejeter comme une loque.

Arithmétique et compagnie

Chiées de parenthèses

Il est probable que vous sachiez déjà vous servir des doubles parenthèses, mais je vais faire un rappel à la con car ça dépanne bien, surtout pour les boucles for :

n=23
max=9

for ((i = 4;  i <= max;  i++))
do
    ((n += ((2 * i + 3) / 2) % 3))
    echo "3n = $((3 * n))"
done
3n = 75
3n = 75
3n = 78
3n = 84
3n = 84
3n = 87

Bref, je ne vais pas recopier le manuel, mais :

À moins d'utiliser un shell exotique, vous serez restreints aux nombres entiers dans les ((…)) et les $((…)) (mais en fait, c'est souvent ce que l'on souhaite). Pour les opérations plus tordues, continuer à lire.

bc pour la forme

bc gère un langage à la con pour faire des calculs.

# fichier
x = 42
y = 23

x + y % 2
3 * x + (y - 4) * 2
bc < fichier
!! 43
!! 164

Notez que le fichier présenté ici n'est pas un script shell, mais bien un bête bout de texte que je donne à manger à bc sur son entrée standard. Dès qu'il y a une ligne avec un vieux calcul sans affectations ni rien, poum, il affiche le résultat. Et il peut utiliser des variables.

Il faut faire gaffe à la façon dont on appelle bc, car il a tendance…

En plus, en mode interactif, il chie des trucs au sujet de sa licence.

De ce fait, j'utilise le plus souvent bc un peu comme ça :

LC_NUMERIC=en_GB.UTF-8

for ((i = 10;  i < 13;  i++))
do
    printf 'Sans -l : %.2f\n' "$(
        bc <<< "$i / 4"
    )"
    printf 'Avec -l : %.2f\n' "$(
        bc -l <<< "$i / 4"
    )"
done
Sans -l : 2.00
Avec -l : 2.50
Sans -l : 2.00
Avec -l : 2.75
Sans -l : 3.00
Avec -l : 3.00

Plusieurs trucs à remarquer, encore une fois :

echo "$LC_NUMERIC"
!! fr_FR.UTF-8
printf '%f\n' 1.2
!! ./to_prism.sh: line 68: printf: 1.2: invalid number
!! 0,000000
printf '%f\n' 1,2
!! 1,200000
Vous pouvez demander zéro décimales à printf pour arrondir un flottant. C'est rigolo.
x=4,73
x=$(printf '%.0f' "$x")
echo "$x"
5

Tout plein de joie en perspective. Et après les gens se demandent pourquoi il m'arrive d'utiliser awk pour mes calculs… Certains font ça avec Python, aussi, je crois…

x=4.23
awk "BEGIN { printf(\"%.0f\n\", sqrt($x / 2 * 3)) }"
!! 3
awk "BEGIN { print sqrt($x / 2 * 3) }"
!! 2.51893
awk '{ print sqrt($1 / 2 * 3) }' <<< $'273.4\n12\n200'
!! 20.2509
!! 4.24264
!! 17.3205
awk -v x=500 'BEGIN { print x / (2378 - 3 * x) }'
!! 0.569476
Gardez en tête que si vous appelez un programme externe trop bourrin toutes les deux secondes pour des calculs débiles, cela peut ralentir vos scripts. Tenez-vous-en aux $((…)) et ((…)) quand c'est possible, notamment.

Sélection de trucs

Les bases

J'ai mis un temps fou à découvrir l'existence du select du shell (je crois que je suis tombé dessus par hasard en cherchant autre chose dans le manuel…), donc je vais m'assurer que vous sachiez que ça existe.

select x in pomme poire fleur
do
    echo "choix : [$x]"
done
1) pomme
2) poire
3) fleur
#? patate
choix : []
#? 4
choix : []
#? 2
choix : [poire]

Le select

À partir de ça, on peut assez facilement sortir de la boucle avec un vieux break quand la variable n'est pas vide, et ainsi se retrouver avec un choix valide en sortie. Inutile, donc, de se faire chier à coder quarante menus complexes à la main lorsque vous voulez proposer plusieurs choix à l'utilisateur.

D'autres trucs

Quelques infos supplémentaires pour tirer le maximum du select :

En combinant un peu tout ça, on peut obtenir un truc de ce genre :

function choix_foire {
    echo 'Sélection annulée.'
    exit 1
}

touch 'a' 'b b' 'c_c_c'
liste=()
for fic in ./*
do
    test -f "$fic" &&
    liste+=("$fic")
done

(
    PS3='Choix ?'$'\n''→ '
    select x in "${liste[@]}"
    do
        echo "[$REPLY] → [$x]"
        test "$x" && break
    done || choix_foire
    
    echo "Choix final : $x"
)
bash ../script.sh
!! 1) ./a
!! 2) ./b b
!! 3) ./c_c_c
!! Choix ?
!! → plop
!! [plop] → []
!! Choix ?
!! → 
!! 1) ./a
!! 2) ./b b
!! 3) ./c_c_c
!! Choix ?
!! → 2
!! [2] → [./b b]
!! Choix final : ./b b
bash ../script.sh
!! 1) ./a
!! 2) ./b b
!! 3) ./c_c_c
!! Choix ?
!! → 
!! Sélection annulée.

Dans cet exemple, j'ai exécuté deux fois le script. La première fois, j'ai fait trois tentatives :

  1. la première en tapant de la merde ;
  2. la deuxième sans rien entrer avant de faire Entrée ;
  3. la dernière en effectuant un choix valide.

une deuxième exécution montre la gestion de l'annulation via Ctrl-D : je me sers du fait que la boucle select a un statut de sortie égal à celui de la dernière commande qu'elle a exécutée. Puisque le Ctrl-D empêche la commande de lecture de faire son boulot, elle gueule, et vu que c'est la dernière commande exécutée, on récupère un statut d'erreur en sortie de boucle, ce qui nous permet de nous contenter d'un vieux || pour enchaîner avec une manière de gérer ce cas particulier. Ici, on quitte le script via un exit rangé dans une fonction, mais on pourrait très bien imaginer des cas où cela a du sens de continuer l'exécution, par exemple en récupérant un choix par défaut afin de permettre à l'utilisateur pressé de juste faire Ctrl-D sans réfléchir ni lire la liste des choix.

Notez que j'ai crée un groupe de commandes avec des parenthèses à la con, autour de la boucle select. Cela permet d'annuler les changements de valeurs de variables en sortant du groupe de commandes, et donc de réinitialiser PS3. Cependant, on perd évidemment également le choix effectué. Si cela vous embête trop (genre si le traitement dépendant du choix est supra long), vous pouvez :

  1. vous battre les falafels de tout ça et attendre la fin du script pour que les choses soient remises comme elles étaient, mais il faut savoir ce que vous faites, quand même, hein ;
  2. stocker la valeur initiale de PS3 dans une variable et faire l'affectation inverse après le select ;
  3. exploiter le fait que la liste de choix et l'invite sont affichés sur la sortie d'erreur et balancer vous-mêmes le choix de l'utilisateur sur la sortie standard, puis récupérer ce choix dans une variable :
function choix {
    PS3='patate : '
    select x in a b c;
    do
        test "$x" && break
    done
    echo "$x"
}

res=$(choix)
test "$res" || exit 1

echo "Le choix est [$res]."
echo "PS3 vaut maintenant [$PS3]."
1) a
2) b
3) c
patate : 2
Le choix est [b].
PS3 vaut maintenant [].

Le truc marrant, c'est que puisque le $(…) crée un vieux sous-shell, les variables sont automatiquement réinitialisées.

Bon, ces derniers détails peuvent sembler overkill (à chaque fois que j'utilise ce terme j'ai l'impression d'entendre Lemmy faire « Wooovegill! Wooovegill! ») pour personnaliser un vieux prompt, mais ce sont des infos qui sont aussi valables pour d'autres contextes et d'autres variables, donc bon. C'est toujours utile de piger ce qu'il se passe, pourquoi ça peut être pénible, et de savoir comment résoudre ces problèmes.

ShellCheck

(Ce nom me semble toujours inspiré de shell shock, ce que je trouve assez flippant.)

ShellCheck est un analyseur de code qui traîne notamment dans les dépôts d'Ubuntu. C'est tout léger et je vous recommande de faire ceci immédiatement si ça n'est pas déjà fait :

sudo apt install shellcheck

Ensuite, vous pouvez lui donner à manger du code, et voir si il gueule.

nl script
!!      1	head $1
!!      2	echo *
shellcheck script
!! 
!! In script line 1:
!! head $1
!!      ^-- SC2086: Double quote to prevent globbing and word splitting.
!! 
!! 
!! In script line 2:
!! echo *
!!      ^-- SC2035: Use ./* so names with dashes won't become options.
!! 

Et là, c'est marrant : on retombe sur les trucs que je dis un peu partout dans ce document, haha. D'ailleurs, j'ai redécouvert cet outil en préparant cet espèce de tutoriel, car moi-même je ne l'utilise pas assez. Les versions récentes vous sortent même des « Note that A && B || C is not if-then-else. C may run when A is true. » (voir cette section) ou encore des « Quotes/backslashes will be treated literally. Use an array. » (voir cette section-ci).

Bref, il peut être intéressant de lancer ShellCheck même sur des scripts que l'on pense irréprochables.

Conclusion

Ma conclusion est qu'il y a sacrément de daube dans mes anciens scripts, et même dans certains des plus récents. Fait chier.

Côté technique, j'ai coloré le code avec Prism.js (faudrait que je leur balance une pull request, d'ailleurs, car il manquait des mots-clefs, etc. j'ai mis la misère à leur définition du Bash), et j'ai pondu pour me marrer des petits bouts de JavaScript pour la table des matières et les notes avec le gros point d'exclamation sur la gauche (plus le style). Vous pouvez en faire ce que vous voulez si ça vous amuse. Idem pour ma gestion des couleurs (qui n'est pas ouf, mais bon).

Bouh-bye.