Trucs cool en shell
Alice M. – Avril et mai 2018
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 :
- le fait que même si quarante tutos existent sur un sujet, en faire un de plus peut être utile étant donné que les gens n'accrocheront pas forcément au même style d'explications. Pis certains ont encore la flemme de lire de l'anglais ;
- le fait que pas mal de gens sont parfois forcés par des circonstances à la con à bidouiller avec un shell, et que ça peut être un gouffre temporel quand on est mal préparé et mal entouré.
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.
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 :
- une version de la chaîne est générée pour chaque valeur donnée entre les accolades, et
- les valeurs sont séparées par des virgules. De plus,
- ces couples d'accolades peuvent être imbriqués afin de donner des alternatives d'alternatives.
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
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.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
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…
- vous utilisez
cat
au tout début d'une suite de commandes reliées par des tubes, - que ce
cat
ne lit qu'un seul fichier, et - qu'il n'a pas d'options particulières…
… 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.
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 :
- 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.
- C'est pratique pour mettre au point une commande dans un terminal en testant plusieurs entrées différentes.
- 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,
- on ferme la chaîne de caractères avec un guillemet simple,
- on en ouvre une nouvelle immédiatement avec des guillemets doubles,
- on place un guillemet simple (qui sera interprété littéralement) dans cette nouvelle chaîne,
- 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.
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
which
ne soit pas content du tout.Bref : quand on ne passe qu'un seul nom en argument,
- dans un cas, on a un truc sur la sortie standard et un statut à zéro (ce qui veut dire que la commande était contente, genre « rien à déclarer »),
- et dans un autre la commande n'affiche rien et balance un statut vénèr.
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 :
- se baser uniquement sur le statut de sortie de
which
et faire se perdre sa sortie standard dans le néant avec> /dev/null
, - ou au contraire capturer la sortie standard de
which
avec$(…)
, et demander àtest
de vérifier qu'on a choppé au moins un caractère.
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).
"${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 :
- Il semblerait que la protection soit plus ou moins automatique quand on fait
variable=$(commandes)
. - Certaines variables spéciales, comme
$!
(identifiant du dernier processus balancé en arrière-plan),$#
(nombre d'arguments),$$
(identifiant du processus courant) ou$?
(statut de sortie de la dernière commande) ne peuvent pas contenir autre chose que de vieux entiers.
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. »
-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 ?
- 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.
- 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$(…)
. - 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à.
echo
, c'est casse-gueule
J'ai souvent vu des gens sur le net dire qu'il fallait faire attention avec echo
, qui est pourtant tant utilisé. Je n'y ai qu'à moitié prêté attention, jusqu'au jour ou un echo
un peu ambitieux a décapité les notes de version d'un logiciel de ma boîte et mutilé quelques autres trucs.
En gros, vous pouvez avoir des différences bizarres entre les systèmes, genre un qui va par défaut interpréter les \n
et un autre qui aura besoin d'une option à la con pour ça… tandis que d'autres systèmes n'auront pas d'options du tout pour leur echo
.
Pour faire court, citons cette source (The Open Group Base Specifications) :
It is not possible to useecho
portably across all POSIX systems unless both-n
(as the first argument) and escape sequences are omitted.
En d'autres termes, pas d'options et pas de \n
, \t
et compagnie dans vos appels à echo
. Sachant que je préconise, dans les scripts, de toujours partir du principe qu'on ne maîtrise jamais vraiment ce qu'une variable peut contenir, je dirais que ça ne nous laisse que les fois où on a affaire à une chaîne fixe (ne découlant pas de variables) voire pas d'argument du tout (quand on veut juste revenir à la ligne).
Coment gérer les autres cas, alors ? Eh bien, il faudra s'appuyer sur printf
. C'est souvent un peu plus verbeux, mais les normes laissent nettement moins de latitude à cette commande et vous aurez donc moins de surprise. Et puis, vous pourrez peut-être en profiter pour changer des commandes d'affichage dégueulasses en jolis trucs qui utilisent une chaîne de format lisible et maintenable.
x='pomme \normal poire'
printf '%s\n' "$x"
!! pomme \normal poire
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 :
- La syntaxe avec le
:-
permet d'utiliser localement une valeur par défaut (le fameuxplop
qui traîne derrière) si et seulement si la variable considérée est vide. - J'affiche ensuite
x
sans magouille afin de montrer que sa valeur n'a pas été altérée et est donc encore vide. - 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èmeecho
,x
vaut ensuiteplop
.
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.
${…:=…}
, 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.
:?
: ${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 :
- 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
:?
…), - 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 :
- C'est verbeux sa mère.
- 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.
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 :
- Je commence par défoncer la figure de la variable avec
unset
avant d'entrer dans la boucle, car cela pourrait être un tableau contenant déjà des choses, ou pire : un tableau associatif, auquel cas on aurait ensuite une erreur puisque la syntaxe d'ajout d'éléments n'est pas la même. Le-v
est peut-être un peu overkill, mais je crois avoir vu je ne sais plus où que c'était cool pour la portabilité. - J'utilise la syntaxe vue dans cette autre section pour pouvoir conserver la valeur de la variable
files
. - Les expressions rationnelles de
find
doivent coller à tout le chemin du fichier, d'où le.*
et l'absence de$
. - Une profondeur maximale de
1
permet de juste fouiner dans le répertoire indiqué (ou le répertoire courant, par défaut). - Balancer un délimiteur vide au
-d
deread
lui dit de considérer « des trucs séparés par un octet nul » plutôt que des lignes. - Le
+=(…)
concatène le tableau courant avec un nouveau tableau, dans lequel on place simplement la valeur def
, qui contient un chemin vers un fichier. - L'option
-V
desort
sert à la base pour trier les numéros de versions de logiciels, et ça donne des trucs stylés sur les noms de fichiers. Ainsi,sort
(sans option de type de tri) etsort -n
considèrent tous les deuxa10b
comme « plus petit » quea1b
, mais passort -V
.
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
).
- Le premier appel donne
printf 'leformat' ./a ./b ./c
, - 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.)
-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 :
- Manière 1, dont je ne suis pas nécessairement très fan, mais au fond les autres ne sont pas ouf non plus : « Toute la partie
|| false
a été bouffée puisque la commande précédente (true
) s'est bien passée, et ça a donnétrue && echo 'BIM!'
. » - Manière 2 : « Les commandes reliées par des
&&
ou des||
forment des blocs. On exécute la première commande du bloc, puis :- si la commande s'est bien passée, on descend dans la liste des commandes du bloc et on exécute la première commande trouvée qui est précédée d'un
&&
; - si la commande a chié, on descend dans la liste des commandes du bloc et on exécute la première commande trouvée qui est précédée d'un
||
.
- si la commande s'est bien passée, on descend dans la liste des commandes du bloc et on exécute la première commande trouvée qui est précédée d'un
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'
- First things first, on exécute
true
, qui, bien entendu, ne se chie pas dessus. - On passe en mode « WÈSHEUUUU la dernière commande exécutée a marché du tonnerre de Dieu ! »
- 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&&
. - Ce faisant, on tombe sur
echo 'Pif'
après avoir mis un gros vent aux trois premières fausses commandes avec les noms pourris. - On affiche notre « Pif », et puisque tout se passe bien on retourne chercher un
&&
. - 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. - Diantre ! Quelle surprise !
false
s'est chié dessus ! On passe en mode « WÈSHEUUUU la dernière commande exécutée a chié ! » - On part à la recherche d'un
||
à cause de cet échec. Le premier est celui deecho 'Paf'
, qu'on exécute. - 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.
{ 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'; } &
.
( 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.
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 :
read
balance un statut de sortie d'erreur s'il n'arrive pas à lire ce qu'il voulait lire ;- 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.
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).
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
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 :
- Les dollars sont facultatifs pour chopper les valeurs des variables dans les
((…))
et$((…))
(puisque de toute façon accéder à la valeur d'une variable est la seule raison pour laquelle on pourrait vouloir foutre des lettres dans ces contextes-là). -
Le
$((…))
est remplacé par son résultat et sert donc à refiler ledit résultat…- à une commande :
head -n $((…))
; - à une variable :
n=$((…))
, mais il faudra souvent plutôt regarder du côté de((…))
; - à une chaîne à la con :
txt="plop$((…))plup"
.
- à une commande :
- Le
((…))
n'est remplacé par rien du tout et sert principalement à mettre à jour la valeur de variables :((x++, y *= x + 1))
.
((…))
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…
- … à se foutre en mode interactif s'il n'a rien à manger sur son entrée standard, et ce même si on lui a passé des fichiers en arguments (il traite quand même les fichiers, mais glandouille ensuite en attendant qu'on tape des trucs, à moins qu'il se soit bouffé un
quit
à la fin du dernier fichier), et - à confondre un calcul avec un nom de fichier si on le passe en argument.
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 :
- Sans le
-l
, qui charge une bibliothèque à la con, la division nous balance un vieux résultat entier façon((…))
. - Quand on balance des nombres décimaux à un truc genre
printf
, il faut faire gaffe à la valeur deLC_NUMERIC
, car selon la langue choisie le séparateur décimal attendu changera…
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
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
$((…))
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
…
- … affiche automatiquement la liste des choix qu'on lui passe (ici, « pomme », « poire » et « fleur ») ;
- demande à l'utilisateur de choisir un numéro correspondant à l'une des propositions ;
- remplit la variable ayant le nom indiqué (ici,
x
) avec la chaîne de caractères correspondant au choix de l'utilisateur, ou avec du vide si le mec a fait nawak.
À 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
:
- Utiliser un tableau pour stocker la liste des choix est super cool quand cette liste doit être générée dynamiquement ou que certains des choix contiennent des espaces ou des trucs à la con comme ça.
- L'invite affichée lors de la demande du numéro de choix est simplement le contenu de la variable
PS3
(non, ce n'est pas un placement de produit) ou un truc par défaut si cette variable n'a jamais reçu de valeur. On peut donc personnaliser cet aspect. - Ctrl-D permet de sortir à l'arrache de la boucle en laissant la variable vide ; il vaut donc mieux faire un petit test en sortie histoire d'éviter les emmerdes. Il faudrait d'ailleurs que j'aille retaper certains de mes vieux scripts histoire d'être cohérent avec moi-même, haha.
- Si on veut vraiment savoir ce que le mec a tapé plutôt que la chaîne correspondant à son choix, on peut fouiner dans la variable
REPLY
, qui est remplie automatiquement. - Si rien n'a pu être lu (c'est-à-dire, si le mec a spammé Entrée sans rien avoir tapé), la liste des choix est affichée de nouveau.
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 :
- la première en tapant de la merde ;
- la deuxième sans rien entrer avant de faire Entrée ;
- 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 :
- 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 ;
- stocker la valeur initiale de
PS3
dans une variable et faire l'affectation inverse après leselect
; - 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.