La commande tr (et ses limites)

Sommaire

Introduction

La commande tr peut :

Bien que d’un maniement simple, elle tend toutefois des pièges à son utilisateur : notamment parce que la version installée sur les systèmes GNU/Linux (celle des GNU Core Utilities) ne gère pas les caractères codés sur plusieurs octets – ce qui est par exemple le cas caractères accentués.

Remplacer un caractère par un autre

La commande prend deux listes de caractères comme arguments

Dans les commandes de la forme tr liste1 liste2, tr remplace toutes les occurrences de l’énième caractère de liste1 par l’énième caractère de liste2.

Voici quelques exemples, en commençant par le cas le plus simple, celui dans lequel liste1 et liste2 se composent d’un seul caractère :

$ printf 'abc123\n' | tr 'a' 'd'
dbc123

On étoffe les listes :

$ printf 'abc123\n' | tr 'abc' 'def'
def123

Le tiret indique un intervalle de lettres ou de chiffres :

$ printf 'abc123\n' | tr a-z A-Z
ABC123
$ printf 'abc123\n' | tr 'a-c1-3' 'd-fx-z'
defxyz

On peut aussi utiliser les classes de caractères POSIX :

$ printf 'abc123\n' | tr '[:lower:]' '[:upper:]'
ABC123
$ printf 'abc123\n' | tr '[:lower:]1-9' '[:upper:]a-i'
ABCabc

Au passage, tr peut être utilisé pour décoder le chiffre de César – ici avec une distance de 3 :

$ printf 'pxirq\n' | tr 'a-z' 'd-za-c'
salut

Cas de listes de caractères de taille différente

Un comportement par défaut sur lequel on ne saurait compter

Si liste1 contient moins d’éléments que liste2, alors tous les éléments de liste1 dont le rang est supérieur au nombre d’éléments de liste2 sont remplacés par le dernier élément de liste2 :

$ printf 'abcde\n' | tr a-z 1-2
12222

Ce comportement, vérifié avec les commandes tr de GNU, de macOS et d’OpenBSD, n’est toutefois pas requis par la norme POSIX – on ne saurait donc le tenir pour garanti.

L’expression [x*n] définie par POSIX

Il est toutefois possible d’obtenir ce même résultat en utilisant l’expression [x*n] définie par POSIX, dans laquelle x est le caractère à répéter, et n le nombre de répétitions :

$ printf 'abcde\n' | tr a-z 1[2*4]
12222
$ printf 'abcde\n' | tr a-z 1[2*3]z
1222z

Si n n’est pas spécifié (forme [x*]), alors tr ajoute autant d’occurrences du caractère de remplacement qu’il faut pour que la deuxième liste fasse la taille de la première :

$ printf 'abcde\n' | tr a-z 1[2*]
12222

La forme [x*n] peut-être utilisée ailleurs qu’en fin de liste :

$ printf 'abcde\n' | tr a-z 1[2*2]z
122zz
$ printf 'abcde\n' | tr a-z 1[2*2][a*1]z
122az
$ printf 'abcde\n' | tr a-z 1[2*2][a*2]z
122aa
$ printf 'abcde\n' | tr a-z '[2*3]z'
222zz

Mais attention, si l’on utilise une expression de la forme [x*], les caractères qui la suivent ne sont pas pris en compte (testé sur GNU/Linux et OpenBSD) :

$ printf 'abcde\n' | tr a-z 1[2*]z
12222

L’option -t de la version GNU de tr

L’implémentation GNU de tr propose une option -t (ou --truncate-set-1) qui permet, si la deuxième liste de caractères est plus petite que la première, de tronquer la première liste afin que les deux soient de longeur égale :

$ printf 'abcde\n' | tr -t a-z 1-2
12cde

On peut utiliser le complément d’une liste

L’option -c sélectionne l’ensemble complémentaire de celui de la première liste passée en paramètre à tr (c’est-à-dire que le programme opérera sur tous les caractères qui ne sont pas indiqués dans liste1) :

$ printf 'abc123\n' | tr -c '\n' '.'
......
$  printf 'Quoi? Non!\n' | tr -c '[:alnum:]\n' '-'
Quoi--Non-

Il est utile d’ajouter le saut de ligne (indiqué par la séquence d’échappement \n) dans liste1, sans quoi toutes les lignes de l’entrée n’en forment plus qu’une sur la sortie. Comparer :

$ printf 'abc\ndef\n123\n' | tr -c '[:alpha:]\n' '.'
abc
def
...
$ printf 'abc\ndef\n123\n' | tr -c '[:alpha:]' '.'
abc.def.....$

(Le $ à la fin de la dernière ligne de l’exemple est l’invite de commande qui, faute de saut de ligne à la fin de la sortie de commande, réapparaît sur la même ligne que celle-ci.)

Supprimer des caractères

Dans la forme tr -d liste, l’option -d supprime tous les caractères lus qui figurent dans liste :

$ printf 'abc123\n' | tr -d 'abc'
123
$ printf 'abc123\n' | tr -d '[:alpha:]'
123
$ printf 'abc123\n' | tr -d '1-3'
abc
$ printf 'abc123\n' | tr -d '[:digit:]bc'
a

On peut combiner cette option avec -c, mais il est préférable, là encore d’ajouter le caractère de fin de ligne :

$ printf 'abc123\n' | tr -dc '[:alpha:]\n'
abc

Condenser des suites de caractères

Condenser seulement

Dans sa forme tr -s liste, la commande remplace toute suite de chacun des caractères de liste par une seule occurrence dudit caractère :

$ printf "aaabbbccc\n" | tr -s 'a'
abbbccc
$ printf "aaabbbccc\n" | tr -s 'abc'
abc

On peut s’en servir pour supprimer des lignes vides :

$ printf "abc\n\n123\n" 
abc

123
$ printf "abc\n\n123\n" | tr -s '\n'
abc
123

Remplacer puis condenser

Dans sa forme tr -s liste caractère, tr effectue deux actions successives :

$ printf "abc123\n" | tr 'abc' '-'
---123
$ printf "abc123\n" | tr 'abc' '-' | tr -s '-'
-123
$ printf "abc123\n" | tr -s 'abc' '-'
-123

En somme, c’est une forme raccourcie pour un enchaînement de commandes telles que : tr liste caractère | tr -s caractère.

J’ajoute deux exemples, qui montrent bien que tr effectue le remplacement des caractères avant de condenser, et que la condensation s’opère sur toute la chaîne, non pas sur les parties qui ont fait l’objet du remplacement :

$ printf "abc123\n" | tr -s 'a-c2-3' 'z'
z1z
$ printf "abc123\n" | tr -s 'a-c2-3' '1'
1

Dans la dernière ligne, on obtient 1 et non pas 111.

Points épineux

Attention aux crochets

J’ai souvent écrit – par erreur – des lignes contenant des intervalles ou des listes de caractères indiqués entre crochets.

$ printf 'abc\n' | tr '[a-z]' '[A-Z]'
ABC

Or les crochets sont inutiles :

$ printf 'abc\n' | tr 'a-z' 'A-Z'
ABC

Ma façon d’écrire la commande aboutit seulement à remplacer un crochet ouvrant (premier élément de la liste [a-z]) par un crochet ouvrant (premier élément de la liste [A-Z]) – et pareil pour le crochet fermant, dernier élément des listes.

Je subissais probablement l’influence de la façon dont on indique listes de caractères ou intervalles dans les expansions de nom de fichiers du shell (par exemple : echo t[a-z0-9]*) ou dans les expressions régulières (par exemple : grep '[a-z0-9]').

L’erreur est sans conséquence dans le cas d’un remplacement de caractères, le résultat étant celui attendu. Mais si l’on fait usage de l’option ‑d, alors on supprime des crochets en voulant supprimer des lettres. Comparer :

$ printf 'abc[…].\n' | tr -d '[a-z]' 
….
$ printf 'abc[…].\n' | tr -d 'a-z' 
[…].

Caractères codés sur plusieurs octets : les limites de tr

Un codage de caractères informatique est une mise en correspondance de caractères d’une part, et d’octets d’autre part.

Mon shell (mais c’est aujourd’hui la norme) utilise le codage UTF-8, dans lequel les caractères sont codés sur un nombre variable d’octets. Par exemple :

En UTF-8, les caractères codés sur un seul octet sont ceux du répertoire du codage ASCII  : chiffres, lettres de l’alphabet latin sans accent, un nombre réduit de symboles et de signes de ponctuation.

Comme on va le voir, la version GNU de tr ne gère pas les caractères codés sur plusieurs octets – il faut donc renoncer à s’en servir dans bon nombre de cas. Je ne me risquerais pas à expliquer à quoi cela tient. Le lecteur curieux d’en savoir plus peut consulter la page « GNU Coreutils - Multibyte/unicode support » sur le site crashcourse.housegordon.org. Assaf Gordon, son auteur, a travaillé voici quelques années sur une possible prise en charge des caractères à plusieurs octets par la commande tr de GNU ; si cela n’a pas abouti, il a toutefois mis en ligne sa documentation sur le sujet.

Voici des exemples tirés de l’utilisation de la version de tr fournie par GNU, avec des parallèles avec celle de macOS, qui fonctionne bien.

Commençons par des remplacements simples de caractères accentués. Sur GNU/Linux, tr échoue :

$ echo 'été roi' | tr 'ét' 'ub'
ubbub roi

Sur macOS, c’est bon :

$ echo 'été roi' | tr 'ét' 'ub'
ubu roi

Les lettres accentuées et les ligatures ne figurent pas dans l’intervalle a-z, ni avec GNU tr ni sur macOS. C’est, me semble-t-il, le comportement attendu en UTF-8 : l’intervalle a-z (points de code U+0061 à U+007A) ne contient que les 26 lettres de l’alphabet.

$ echo 'aéœ' | tr -d a-z
éœ
$ printf 'À l’été\n' | tr a-z A-Z
À L’éTé

Mais dans la version GNU de tr, diacritiques et ligatures ne figurent pas non plus dans dans les classes de caractères POSIX telles que [:alpha:], [:lower:] ou [:print:] :

$ echo 'aéœ' | tr -d [:alpha:]
éœ
$ echo 'aéœ' | tr -d [:print:]
éœ
$ printf 'À l’été\n' | tr [:lower:] [:upper:]
À L’éTé

Sur macOS, les classes POSIX donnent le résultat attendu :

$ echo 'aéœ' | tr -d [:alpha:]

$ printf 'À l’été\n' | tr [:lower:] [:upper:]
À L’ÉTÉ

Si je veux remplacer un é ou l’apostrophe typographique par un tiret, j’obtiens deux et trois tirets avec la version GNU de tr – comme si la commande comptait comme un caractère distinct chacun des deux octets qui composent le é ou des trois octets qui composent l’apostrophe :

$ echo é | tr é -
--
$ echo ’ | tr ’ -
---
$ echo '😂' | tr  '😂' 'a'
aaaa
$ echo '😂' | tr  '😂' 'abcd'
abcd

Sur macOS, aucun problème :

$ echo ’ | tr ’ -
-
$ echo é | tr é -
-
$ echo '😂' | tr  '😂' 'a'
a
$ echo '😂' | tr  '😂' 'abcd'
a

Un autre exemple, avec GNU d’abord :

$ for i in z é ☕ 😂 ; do echo "$i abc" | tr "$i" '☕' ; done
abc
abc
☕ abc
☕ abc

On attendrait quatre fois la ligne ☕ abc, mais ce n’est pas le cas. Sur macOS, c’est bon :

$ for i in z é ☕ 😂 ; do echo "$i abc" | tr "$i" '☕' ; done
☕ abc
☕ abc
☕ abc
☕ abc

Conclusion consolatoire adressée aux utilisateurs de tr sur un système GNU/Linux

Si tr ne peut donc pas faire grand-chose pour les utilisateurs de systèmes GNU/Linux, ces derniers peuvent toutefois se tourner vers sed, et notamment vers les commandes s et y. Car le sed de GNU gère bien les caractères à plusieurs octets – mais cela implique de se mettre aux expressions régulières (ce qui est de toute façon un des meilleurs services qu’on puisse se rendre).

Quelques exemples sans explications :

$ printf 'été roi\n' | sed 'y/ét/ub/'
ubu roi
$ printf 'À l’été\n' | sed 's/[[:lower:]]//g'
À ’
$ printf 'À l’été\n' | sed 's/.*/\U&/g'
À L’ÉTÉ
$ printf 'À l’été\n' | sed 's/.*/\U&/g' | sed 's/.*/\L&/g'
à l’été

(Attention, les formes \U&/ et \L&/ sont propres au sed de GNU.)

Et enfin :

$ for i in z é ☕ 😂 ; do echo "$i abc" | sed "y/$i/☕/" ; done
☕ abc
☕ abc
☕ abc
☕ abc