Aller au contenu

Projet : compteur de mots en Rust

Dans ce projet, vous allez construire un petit outil, mais un vrai : un compteur de mots. Vous lui donnez un texte, et il vous dit quels mots reviennent le plus souvent. Les auteurs utilisent ce genre d’outil pour repérer les mots qu’ils répètent trop. Les moteurs de recherche se servent de la même idée pour comprendre de quoi parle une page.

Nous allons le construire par petites étapes. Chaque étape est un programme complet qui fonctionne : vous pouvez vous arrêter n’importe quand, faire des essais, puis continuer. En chemin, vous utiliserez les chaînes de caractères, les HashMap, le tri, l’affichage formaté et les arguments de ligne de commande.

À la fin, votre programme affichera quelque chose comme ceci :

MOT FOIS
----------------
the 6
cat 3
happy 2

Une précision avant de commencer : les textes d’exemple de cette page sont en anglais. Ainsi, les comptages et les tableaux sont exactement les mêmes que dans la version originale du tutoriel. Votre compteur fonctionne tout aussi bien avec du texte en français : to_lowercase et is_alphanumeric comprennent les lettres comme é ou ç.

Le premier travail consiste à découper le texte en mots. split_whitespace() coupe une chaîne au niveau des espaces, des tabulations et des retours à la ligne, et ignore les morceaux vides entre eux :

fn main() {
let text = "The cat sat on the mat. The dog sat on the log!";
let words: Vec<&str> = text.split_whitespace().collect();
println!("{:?}", words);
println!("{} mots", words.len());
}

Sortie :

["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]
12 mots
▶ Essayez par vous-même

Ça marche, mais regardez de plus près. "The" et "the" sont traités comme deux mots différents, et "mat." a gardé son point. Si nous comptions maintenant, les résultats seraient faux. Corrigeons ça d’abord.

Normaliser un mot, c’est le mettre sous une forme standard. Nous allons faire deux choses :

  1. Retirer la ponctuation au début et à la fin, avec trim_matches.
  2. Le passer en minuscules, avec to_lowercase.
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. Don't stop -- said the DOG!";
for word in text.split_whitespace() {
let clean = normalize(word);
if clean.is_empty() {
println!("{:>6} -> (ignoré)", word);
} else {
println!("{:>6} -> {}", word, clean);
}
}
}

Sortie :

The -> the
cat -> cat
sat -> sat
on -> on
the -> the
mat. -> mat
Don't -> don't
stop -> stop
-- -> (ignoré)
said -> said
the -> the
DOG! -> dog
▶ Essayez par vous-même

trim_matches prend une closure (fonction anonyme) qui décide quels caractères retirer. Ici, elle retire tout ce qui n’est pas une lettre ou un chiffre, mais seulement aux extrémités. C’est pour ça que l’apostrophe de don't survit. Un « mot » fait uniquement de ponctuation, comme --, devient une chaîne vide : nous l’ignorons.

Voici maintenant l’idée principale. Une HashMap associe à chaque mot le nombre de fois où nous l’avons vu. L’API entry rend le comptage très court :

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
println!("the : {}", counts["the"]);
println!("cat : {}", counts["cat"]);
println!("happy : {}", counts["happy"]);
println!("{} mots différents", counts.len());
}

Sortie :

the : 6
cat : 3
happy : 2
11 mots différents
▶ Essayez par vous-même

Lisons lentement *counts.entry(clean).or_insert(0) += 1; :

  • counts.entry(clean) trouve l’emplacement de ce mot dans la map.
  • .or_insert(0) y met un 0 si le mot est nouveau. Dans tous les cas, elle renvoie une référence mutable vers le compteur.
  • * suit cette référence jusqu’au nombre lui-même, et += 1 lui ajoute un.

Le \ à la fin de la première ligne de texte sert juste à continuer la chaîne sur la ligne suivante. Et counts["the"] cherche directement une clé. Ça provoque une panic si la clé n’existe pas, ce qui ne pose pas de problème ici, puisque nous savons que ces mots sont dans le texte. Pour des clés qui pourraient manquer, utilisez .get().

Pourquoi ne pas avoir affiché toute la map avec une boucle ? Une HashMap n’a pas d’ordre fixe. Si vous la parcourez, l’ordre peut changer à chaque exécution du programme. Pour obtenir un ordre stable et utile, il faut trier.

Nous déplaçons les paires de la map dans un Vec, puis nous le trions. Nous voulons le plus grand nombre d’abord. Quand deux mots ont le même nombre, nous les voulons dans l’ordre alphabétique, pour que la sortie soit toujours la même :

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (word, count) in &pairs {
println!("{word} : {count}");
}
}

Sortie :

the : 6
cat : 3
happy : 2
is : 2
on : 2
sat : 2
dog : 1
log : 1
mat : 1
very : 1
yes : 1
▶ Essayez par vous-même

La ligne du tri est la partie la plus délicate de ce projet, alors décortiquons-la :

  • sort_by nous donne deux éléments, a et b, et demande « lequel passe en premier ? ».
  • b.1.cmp(&a.1) compare les nombres (.1 est la deuxième partie du tuple). Nous avons écrit b avant a exprès : ça inverse l’ordre, donc les grands nombres passent en premier.
  • .then(a.0.cmp(&b.0)) ne sert que quand les nombres sont égaux. Il compare les mots dans l’ordre normal, de a vers b, donc les ex æquo sont rangés par ordre alphabétique.

Un détail si vous comptez du texte en français : cet ordre « alphabétique » compare le code de chaque caractère, donc les mots qui commencent par é ou à se retrouvent après le z.

La page Trier et rechercher contient d’autres exemples avec sort_by et ses cousines.

Étape 5 : Afficher un tableau des N premiers mots

Section intitulée « Étape 5 : Afficher un tableau des N premiers mots »

Une longue liste est difficile à lire. Affichons seulement les premiers mots, en colonnes bien alignées. Les spécificateurs de format s’occupent de l’alignement à notre place :

  • {:<10} veut dire « aligner à gauche dans un espace de 10 caractères de large ».
  • {:>5} veut dire « aligner à droite dans un espace de 5 caractères de large ». Les nombres sont généralement plus lisibles alignés à droite.

Les en-têtes MOT (3 lettres) et FOIS (4 lettres) tiennent dans ces largeurs, donc le tableau reste aligné sur la ligne de 16 tirets.

Nous allons aussi ranger le code dans des fonctions, pour que main se lise comme une petite histoire :

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "MOT", "FOIS");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let pairs = sorted_counts(count_words(text));
print_table(&pairs, 5);
}

Sortie :

MOT FOIS
----------------
the 6
cat 3
happy 2
is 2
on 2
▶ Essayez par vous-même

.take(top_n) s’arrête après top_n éléments. S’il y a moins de mots que ça, il s’arrête simplement plus tôt : vous n’avez jamais à vous soucier de dépasser la fin.

Remarquez que print_table prend un &[(String, usize)], c’est-à-dire une slice (une tranche). Elle se contente donc d’emprunter la liste, et elle fonctionne avec n’importe quel Vec de paires.

Un vrai compteur de mots doit lire un fichier. Nous allons prendre le chemin du fichier sur la ligne de commande, avec un deuxième argument optionnel pour le nombre de lignes à afficher. En cas de problème, nous affichons un message clair avec eprintln! (qui écrit sur la sortie d’erreur) et nous quittons avec le code 1.

Créez le projet et un fichier texte pour faire vos essais :

Terminal window
cargo new word_counter
cd word_counter
echo "The cat sat on the mat. The dog sat on the log!" > story.txt

Mettez ceci dans src/main.rs. Cette version est faite pour votre propre machine, car le Playground ne peut pas lire vos fichiers :

use std::collections::HashMap;
use std::env;
use std::fs;
use std::process;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "MOT", "FOIS");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
let args: Vec<String> = env::args().collect();
// args[0] est le nom du programme, args[1] le chemin du fichier
let Some(path) = args.get(1) else {
eprintln!("Utilisation : word_counter <fichier> [top_n]");
process::exit(1);
};
// le deuxième argument est optionnel ; on prend 10 s'il manque ou n'est pas un nombre
let top_n: usize = match args.get(2) {
Some(n) => n.parse().unwrap_or(10),
None => 10,
};
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) => {
eprintln!("Impossible de lire {path} : {e}");
process::exit(1);
}
};
let pairs = sorted_counts(count_words(&text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} mots, {} différents\n", total, pairs.len());
print_table(&pairs, top_n);
}

Lancez-le. N’oubliez pas que les arguments destinés à votre programme se placent après -- :

Terminal window
cargo run -- story.txt 3
12 mots, 7 différents
MOT FOIS
----------------
the 4
on 2
sat 2

Et si vous oubliez le nom du fichier, vous obtenez le message d’utilisation au lieu d’un plantage :

Terminal window
cargo run
Utilisation : word_counter <fichier> [top_n]

let Some(path) = args.get(1) else { ... }; s’appelle un let-else. Ça veut dire « si ce motif correspond, on continue ; sinon, on exécute le bloc else, qui doit arrêter l’exécution (ici, en quittant le programme) ». La page Lire et écrire des fichiers explique read_to_string et ses erreurs plus en détail. Si le fichier n’existe pas, {e} affiche quelque chose comme No such file or directory (os error 2) (« fichier ou dossier introuvable »). Ce message vient du système, c’est pourquoi il est en général en anglais.

Voici le même programme avec le texte directement dans le code, pour que vous puissiez l’exécuter dans le Playground. Seule la fonction main change :

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "MOT", "FOIS");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
// on fait comme si ce texte venait d'un fichier
let text = "Rust is fast. Rust is safe. Rust is fun!
Is it hard? It takes practice, and practice is fun too.";
let top_n = 4;
let pairs = sorted_counts(count_words(text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} mots, {} différents\n", total, pairs.len());
print_table(&pairs, top_n);
}

Sortie :

20 mots, 11 différents
MOT FOIS
----------------
is 5
rust 3
fun 2
it 2
▶ Essayez par vous-même

Oublier le * en comptant. or_insert vous donne une référence vers le nombre, pas le nombre lui-même. Sans *, Rust refuse de lui ajouter quoi que ce soit :

*counts.entry(word).or_insert(0) += 1; // correct
counts.entry(word).or_insert(0) += 1; // erreur
error[E0368]: binary assignment operation `+=` cannot be applied to type `&mut {integer}`
--> src/main.rs:6:9
|
6 | counts.entry(word).or_insert(0) += 1;
| -------------------------------^^^^^
| |
| cannot use `+=` on type `&mut {integer}`
|
help: `+=` can be used on `{integer}` if you dereference the left-hand side
|
6 | *counts.entry(word).or_insert(0) += 1;
| +

Le compilateur dit qu’on ne peut pas utiliser += sur une référence &mut, et il vous montre même la correction : ajouter le * pour déréférencer le côté gauche.

Afficher la HashMap directement. println!("{:?}", counts) fonctionne, mais l’ordre est aléatoire et peut changer d’une exécution à l’autre. Triez toujours dans un Vec d’abord quand l’ordre compte.

Trier uniquement par nombre. Si vous triez seulement avec b.1.cmp(&a.1), les mots qui ont le même nombre peuvent apparaître dans n’importe quel ordre. Ajoutez le départage .then(...) pour que la sortie soit toujours la même.

Vous avez un outil qui fonctionne. Voici quelques façons de vous l’approprier, de la plus facile à la plus difficile :

  • Ignorer les mots vides. Des mots comme « the », « a », « is » et « and » (ou « le », « la », « de », « et » en français) sont partout et pas très intéressants. Mettez-les dans un HashSet et sautez tous les mots qui s’y trouvent.
  • Afficher des pourcentages. À côté de chaque nombre, affichez la part qu’il représente sur l’ensemble des mots, par exemple the 6 28.6%. Utilisez {:.1} pour afficher une décimale.
  • Lire stdin quand aucun fichier n’est donné. Utilisez std::io::read_to_string(std::io::stdin()) pour pouvoir envoyer du texte par un pipe : cat story.txt | cargo run.
  • Compter plusieurs fichiers. Parcourez chaque chemin de args[1..] et ajoutez tous leurs mots dans une seule map.
  • Utiliser clap pour les arguments. Lancez cargo add clap --features derive et ajoutez de vraies options comme --top 5 et --ignore-case, avec en prime un message --help gratuit. La page Arguments de ligne de commande en donne un exemple.
  • Écrire des tests. Déplacez les fonctions dans src/lib.rs et ajoutez quelques tests pour normalize et count_words.
  • split_whitespace() découpe le texte en mots ; trim_matches et to_lowercase les normalisent.
  • *map.entry(key).or_insert(0) += 1 est la façon standard de compter des choses avec une HashMap.
  • Une HashMap n’a pas d’ordre. Rassemblez les paires dans un Vec et utilisez sort_by avec un critère de départage pour une sortie stable.
  • {:<10} et {:>5} alignent le texte en colonnes ; .take(n) limite le nombre de lignes affichées.
  • Lisez le chemin du fichier avec env::args(), gérez les erreurs avec un message clair, et quittez avec process::exit(1).

Petit quiz

1. Que renvoie "Hello!".trim_matches(|c: char| !c.is_alphanumeric()) ?

2. Pourquoi rassemble-t-on la HashMap dans un Vec avant de l'afficher ?

3. Dans pairs.sort_by(|a, b| b.1.cmp(&a.1)), pourquoi b est-il écrit avant a ?

4. Que fait {:>5} dans println! ?

Score : 0 / 4