Aller au contenu

Documentation en Rust : commentaires de doc et doc tests

Chaque page de docs.rs et chaque page de la documentation de la bibliothèque standard a été écrite par un programmeur, directement dans le code source, avec des commentaires spéciaux. Rust transforme ces commentaires en site web avec une seule commande. Vous pouvez faire exactement la même chose pour votre propre code.

Il y a une deuxième bonne raison de s’y intéresser : les exemples de code de votre documentation sont de vrais tests. Quand vous lancez cargo test, Rust les compile et les exécute. Si vous modifiez votre fonction en oubliant de mettre l’exemple à jour, le test échoue. Votre documentation ne peut donc pas devenir obsolète en silence. C’est rare dans les langages de programmation, et c’est l’un des plus beaux atouts de Rust.

Vous avez vu les commentaires normaux sur la page Les commentaires. Les commentaires de documentation (doc comments) leur ressemblent, mais avec trois caractères :

  • /// documente l’élément situé en dessous : une fonction, une struct, un enum, un champ ou un module.
  • //! documente l’élément dans lequel il se trouve. Tout en haut de src/lib.rs ou de src/main.rs, il décrit la crate entière.
//! Un tout petit programme de recettes.
/// Partage `total` grammes en `portions` parts égales.
fn split_evenly(total: u32, portions: u32) -> u32 {
total / portions
}

La première ligne d’un commentaire de documentation doit être une courte phrase de résumé. Les outils affichent cette ligne dans les listes et les résultats de recherche : soignez-la.

Les commentaires de documentation ne changent rien à ce que fait votre programme. Voici une fonction documentée, appelée depuis main :

/// Partage `total` grammes en `portions` parts égales.
///
/// Les grammes en trop sont ignorés : 10 grammes en 3 parts donnent 3.
///
/// # Panics
///
/// Panique si `portions` vaut zéro.
fn split_evenly(total: u32, portions: u32) -> u32 {
if portions == 0 {
panic!("portions doit être supérieur à zéro");
}
total / portions
}
fn main() {
println!("{}", split_evenly(600, 3));
println!("{}", split_evenly(10, 3));
}

Sortie :

200
3
▶ Essayez par vous-même

Dans la plupart des éditeurs, si vous survolez split_evenly dans main, ce texte s’affiche maintenant dans une bulle. Rien que pour ça, l’effort en vaut la peine.

Du Markdown dans les commentaires de documentation

Section intitulée « Du Markdown dans les commentaires de documentation »

Les commentaires de documentation s’écrivent en Markdown, le format utilisé sur GitHub et dans beaucoup d’applications de discussion. Vous pouvez utiliser `code`, **gras**, *italique*, des listes, des liens et des titres :

/// Renvoie la température du four pour un plat, en **degrés Celsius**.
///
/// Plats connus :
///
/// - `"bread"` : 220 degrés
/// - `"cake"` : 180 degrés
/// - tout le reste : 200 degrés
///
/// Vérifiez *toujours* avec votre propre four, car chaque four est différent.
/// Voir [Degré Celsius sur Wikipédia](https://fr.wikipedia.org/wiki/Degr%C3%A9_Celsius).
pub fn oven_temperature(dish: &str) -> u32 {
match dish {
"bread" => 220,
"cake" => 180,
_ => 200,
}
}

Laissez une ligne /// vide entre deux paragraphes, comme vous laisseriez une ligne vide dans un fichier Markdown normal. Entourez les noms de fonctions, de types et de valeurs de backticks (accents graves) pour qu’ils s’affichent comme du code.

Les programmeurs Rust utilisent toujours les mêmes quelques titres, pour que les lecteurs sachent où chercher. Vous en avez déjà vu un. Voici les plus courants :

  • # Examples : comment utiliser l’élément. Presque chaque élément public devrait en avoir un.
  • # Panics : quand la fonction peut déclencher une panic, pour que l’appelant puisse l’éviter.
  • # Errors : quand une fonction qui renvoie un Result renvoie un Err, et pourquoi.
  • # Safety : pour une unsafe fn, les règles que l’appelant doit respecter (voir Unsafe en Rust).

Ces titres restent en anglais, même dans une documentation écrite en français : c’est la convention de tout l’écosystème Rust, et certains outils (comme le linter Clippy) les cherchent sous ce nom exact.

Voici # Errors et # Safety dans un fichier de bibliothèque, src/lib.rs :

/// Lit une quantité comme `"250g"` et renvoie le nombre de grammes.
///
/// # Errors
///
/// Renvoie une erreur si le texte ne se termine pas par `g`,
/// ou si ce qui précède n'est pas un nombre entier.
pub fn parse_grams(text: &str) -> Result<u32, String> {
let number = text
.strip_suffix('g')
.ok_or(format!("'{text}' devrait se terminer par g"))?;
number
.parse()
.map_err(|_| format!("'{number}' n'est pas un nombre entier"))
}
/// Renvoie le premier ingrédient sans vérifier que la liste n'est pas vide.
///
/// # Safety
///
/// L'appelant doit s'assurer que `ingredients` n'est pas vide.
/// L'appeler sur une slice vide est un comportement indéfini.
pub unsafe fn first_unchecked(ingredients: &[String]) -> &String {
unsafe { ingredients.get_unchecked(0) }
}

Remarquez que les titres utilisent un seul #. Dans un commentaire de documentation, # est un titre Markdown, pas un attribut Rust.

Dans la suite de cette page, nous allons travailler sur une petite bibliothèque. Créez-la avec cargo new --lib kitchen. Puis, à l’intérieur du projet, lancez :

Terminal window
cargo doc --open
Documenting kitchen v0.1.0 (/path/to/kitchen)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.91s
Generated /path/to/kitchen/target/doc/kitchen/index.html

Cargo construit un site HTML dans target/doc/, et --open l’ouvre dans votre navigateur. Il ressemble exactement aux pages de docs.rs : une barre latérale, un champ de recherche et une page par fonction et par type. Il documente aussi chaque crate dont vous dépendez, ce qui est bien pratique quand vous êtes hors ligne.

Par défaut, seuls les éléments publics (marqués pub) apparaissent, car la documentation s’adresse aux personnes qui utilisent votre bibliothèque. Ajoutez --document-private-items pour tout inclure.

Voici le début de src/lib.rs dans la bibliothèque kitchen. La fonction a une section # Examples avec un bloc de code à l’intérieur du commentaire de documentation :

//! Petits outils pour la cuisine.
//!
//! `kitchen` convertit les températures du four, partage les aliments en portions
//! et lit des quantités comme `"250g"`.
/// Convertit une température de degrés Celsius en degrés Fahrenheit.
///
/// # Examples
///
/// ```
/// let f = kitchen::celsius_to_fahrenheit(180.0);
/// assert_eq!(f, 356.0);
/// ```
pub fn celsius_to_fahrenheit(celsius: f64) -> f64 {
celsius * 9.0 / 5.0 + 32.0
}

Lancez maintenant cargo test :

Doc-tests kitchen
running 1 test
test src/lib.rs - celsius_to_fahrenheit (line 10) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cargo a trouvé le bloc de code à la ligne 10, l’a transformé en tout petit programme, l’a compilé et l’a exécuté. Le assert_eq! a vérifié le résultat. Quelques points à noter :

  • L’exemple appelle kitchen::celsius_to_fahrenheit, avec le nom de la crate. Un doc test est compilé comme s’il s’agissait du programme de quelqu’un d’autre qui utilise votre bibliothèque : il ne voit donc que les éléments pub, et vous avez besoin du chemin complet ou d’une ligne use kitchen::...;.
  • Vous n’écrivez pas de fn main. Rustdoc enveloppe votre exemple dans une fonction main à votre place.
  • Utilisez assert! et assert_eq! (vus sur la page Les tests en Rust) pour montrer et vérifier le résultat en même temps.

Et si l’exemple est faux ? Remplacez 356.0 par 350.0 et relancez les tests :

running 1 test
test src/lib.rs - celsius_to_fahrenheit (line 10) ... FAILED
failures:
---- src/lib.rs - celsius_to_fahrenheit (line 10) stdout ----
Test executable failed (exit status: 101).
stderr:
assertion `left == right` failed
left: 356.0
right: 350.0
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

C’est tout l’intérêt : un exemple faux est repéré tout de suite, au lieu de dérouter un lecteur des mois plus tard. Le message assertion `left == right` failed signifie que les deux valeurs comparées sont différentes : à gauche (left) la valeur calculée, à droite (right) celle que vous attendiez. Pour lancer uniquement les doc tests, utilisez cargo test --doc.

Parfois, un exemple a besoin de lignes qui distrairaient le lecteur. Dans un doc test, une ligne qui commence par # (un dièse puis une espace) est compilée mais pas affichée dans la documentation générée.

L’usage le plus courant : vous voulez montrer l’opérateur ?, mais ? ne fonctionne que dans une fonction qui renvoie un Result. Vous cachez donc votre propre main :

/// Lit une quantité comme `"250g"` et renvoie le nombre de grammes.
///
/// # Examples
///
/// ```
/// use kitchen::parse_grams;
///
/// # fn main() -> Result<(), String> {
/// let flour = parse_grams("250g")?;
/// assert_eq!(flour, 250);
///
/// assert!(parse_grams("beaucoup").is_err());
/// # Ok(())
/// # }
/// ```
pub fn parse_grams(text: &str) -> Result<u32, String> {
let number = text
.strip_suffix('g')
.ok_or(format!("'{text}' devrait se terminer par g"))?;
number
.parse()
.map_err(|_| format!("'{number}' n'est pas un nombre entier"))
}

Dans le navigateur, le lecteur ne voit que les quatre lignes intéressantes : le use, l’appel avec ? et les deux assertions. Le test, lui, compile tout.

Tous les exemples ne peuvent pas simplement s’exécuter. Vous pouvez placer un mot après les backticks d’ouverture pour dire à rustdoc quoi faire :

  • ```no_run : compiler l’exemple, mais ne pas l’exécuter. Idéal pour du code qui lit des fichiers, ouvre des connexions réseau ou tourne en boucle à l’infini.
  • ```should_panic : l’exécuter et s’attendre à une panic. Le test échoue s’il ne panique pas.
  • ```ignore : ne pas le compiler du tout. À utiliser pour des ébauches et du pseudo-code. Essayez d’abord no_run, car un exemple ignoré peut devenir obsolète sans que personne ne s’en aperçoive.
  • ```text : ce n’est pas du code Rust, juste du texte (par exemple, une sortie d’exemple).
/// Partage `total` grammes en `portions` parts égales.
///
/// # Examples
///
/// ```
/// assert_eq!(kitchen::split_evenly(600, 3), 200);
/// ```
///
/// Zéro portion n'a aucun sens, donc ceci panique :
///
/// ```should_panic
/// kitchen::split_evenly(600, 0);
/// ```
pub fn split_evenly(total: u32, portions: u32) -> u32 {
if portions == 0 {
panic!("portions doit être supérieur à zéro");
}
total / portions
}
/// Charge une liste de courses depuis un fichier, un article par ligne.
///
/// ```no_run
/// let items = kitchen::load_list("courses.txt").unwrap();
/// println!("{} articles à acheter", items.len());
/// ```
pub fn load_list(path: &str) -> std::io::Result<Vec<String>> {
let text = std::fs::read_to_string(path)?;
Ok(text.lines().map(String::from).collect())
}

Avec tous ces exemples dans kitchen, plus une ébauche ignore dans le commentaire //! de la crate, cargo test affiche :

running 7 tests
test src/lib.rs - (line 8) ... ignored
test src/lib.rs - load_list (line 87) - compile ... ok
test src/lib.rs - celsius_to_fahrenheit (line 16) ... ok
test src/lib.rs - Recipe (line 104) ... ok
test src/lib.rs - parse_grams (line 59) ... ok
test src/lib.rs - split_evenly (line 34) ... ok
test src/lib.rs - split_evenly (line 40) - should panic ... ok
test result: ok. 6 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.11s

Le test no_run indique - compile : il a seulement été compilé. L’ordre des lignes peut changer d’une exécution à l’autre, car les tests tournent en parallèle.

Dans un commentaire de documentation, écrivez un nom entre backticks et entre crochets, comme [`Vec`], et rustdoc le transforme en lien vers la page de cet élément. On appelle ça un lien intra-doc (intra-doc link). Cela fonctionne pour vos propres éléments comme pour tout ce qui se trouve dans la bibliothèque standard :

//! Un tout petit programme de recettes.
/// Une recette : un nom et une liste d'ingrédients avec leur poids en grammes.
///
/// Créez-en une avec [`Recipe::new`] et remplissez-la avec [`Recipe::add`].
/// Les ingrédients sont gardés dans un [`Vec`], dans l'ordre où vous les ajoutez.
struct Recipe {
/// Le nom affiché en haut de la recette.
name: String,
/// Chaque ingrédient et son poids en grammes.
ingredients: Vec<(String, u32)>,
}
impl Recipe {
/// Crée une recette vide avec le nom donné.
fn new(name: &str) -> Recipe {
Recipe { name: name.to_string(), ingredients: Vec::new() }
}
/// Ajoute un ingrédient à la fin de la liste.
fn add(&mut self, ingredient: &str, grams: u32) {
self.ingredients.push((ingredient.to_string(), grams));
}
/// Additionne le poids de tous les ingrédients.
fn total_grams(&self) -> u32 {
self.ingredients.iter().map(|(_, grams)| grams).sum()
}
}
fn main() {
let mut pancakes = Recipe::new("Crêpes");
pancakes.add("farine", 200);
pancakes.add("lait", 300);
pancakes.add("œuf", 50);
println!("{}", pancakes.name);
for (ingredient, grams) in &pancakes.ingredients {
println!("- {ingredient} : {grams} g");
}
println!("Total : {} g", pancakes.total_grams());
}

Sortie :

Crêpes
- farine : 200 g
- lait : 300 g
- œuf : 50 g
Total : 550 g
▶ Essayez par vous-même

Les champs et les méthodes peuvent aussi avoir leurs propres commentaires ///, et chacun apparaît sur la page de la struct. Si un lien pointe vers quelque chose qui n’existe pas, cargo doc vous prévient. Ici, Recipe::push était une faute de frappe pour Recipe::add :

warning: unresolved link to `Recipe::push`
--> src/lib.rs:100:63
|
100 | /// Créez-en une avec [`Recipe::new`] et remplissez-la avec [`Recipe::push`].
| ^^^^^^^^^^^^ the struct `Recipe` has no field or associated item named `push`
|
= note: `#[warn(rustdoc::broken_intra_doc_links)]` on by default

L’avertissement dit : « lien vers Recipe::push impossible à résoudre ; la struct Recipe n’a ni champ ni élément associé nommé push ».

Pour une bibliothèque que d’autres personnes vont utiliser, vous pouvez demander au compilateur de vous signaler chaque élément public sans documentation. Placez ceci tout en haut de src/lib.rs :

#![warn(missing_docs)]

Désormais, un commentaire de documentation oublié provoque un avertissement :

warning: missing documentation for a function
--> src/lib.rs:138:1
|
138 | pub fn grams_to_ounces(grams: f64) -> f64 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
note: the lint level is defined here
--> src/lib.rs:1:9
|
1 | #![warn(missing_docs)]
| ^^^^^^^^^^^^

Le message signifie « documentation manquante pour une fonction », et la note indique la ligne qui a activé cette vérification. Le #! (avec un point d’exclamation) signifie que l’attribut s’applique à toute la crate. Il réclame aussi un commentaire //! pour la crate elle-même. Les éléments privés ne sont jamais signalés : ce n’est donc pas une corvée pour vos fonctions utilitaires internes.

docs.rs : un hébergement gratuit pour votre documentation

Section intitulée « docs.rs : un hébergement gratuit pour votre documentation »

Quand vous publiez une crate sur crates.io (voir Cargo et les crates en Rust), docs.rs construit sa documentation automatiquement et l’héberge gratuitement, pour chaque version. Vous n’avez rien à faire. C’est pour cela que presque toutes les crates Rust ont une documentation qui se ressemble et dans laquelle on cherche facilement.

Deux astuces de plus :

  • Lancez rustup doc --std pour ouvrir la documentation de la bibliothèque standard hors ligne dans votre navigateur.
  • Sur n’importe quelle page de docs.rs ou de la std, cliquez sur le lien Source pour lire le code d’une fonction. C’est une excellente façon d’apprendre.

Oublier le nom de la crate dans un doc test. Un doc test se trouve en dehors de votre crate, donc ceci échoue :

error[E0425]: cannot find function `celsius_to_fahrenheit` in this scope
--> src/lib.rs:17:9
|
17 | let f = celsius_to_fahrenheit(180.0);
| ^^^^^^^^^^^^^^^^^^^^^ not found in this scope

Le compilateur ne trouve pas la fonction « dans cette portée ». La solution : kitchen::celsius_to_fahrenheit(180.0), ou une ligne use kitchen::celsius_to_fahrenheit; au début de l’exemple (cachée avec # si vous voulez).

Un commentaire de documentation qui ne documente rien. /// doit se trouver juste avant un élément. À l’intérieur du corps d’une fonction, utilisez // :

error[E0585]: found a documentation comment that doesn't document anything
--> src/main.rs:3:5
|
3 | /// Partager la nourriture en trois portions.
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: doc comments must come before what they document, if a comment was intended use `//`

L’aide explique que les commentaires de documentation doivent précéder ce qu’ils documentent, et que si vous vouliez un simple commentaire, il faut utiliser //.

S’attendre à ce que les doc tests d’un binaire s’exécutent. Les doc tests s’exécutent pour les crates de bibliothèque (src/lib.rs). Les exemples des commentaires de documentation de src/main.rs ne sont pas testés. Si votre programme grandit, déplacez la logique dans src/lib.rs et gardez main.rs court.

Écrire une documentation qui répète le code. « Additionne deux nombres » au-dessus de fn add(a, b) n’aide personne. Expliquez ce que le lecteur ne peut pas voir : les unités (des grammes ? des secondes ?), les cas limites et les cas d’échec.

  • /// documente l’élément suivant, et //! documente la crate ou le module dans lequel il se trouve. Les deux utilisent le Markdown.
  • Utilisez les sections habituelles : # Examples, # Panics, # Errors et # Safety.
  • cargo doc --open génère un site HTML pour votre crate et ses dépendances.
  • Les blocs de code des commentaires de documentation sont des doc tests : cargo test les compile et les exécute. Cachez les lignes de préparation avec # , et utilisez no_run, should_panic ou ignore si besoin.
  • [`Nom`] crée un lien intra-doc, et #![warn(missing_docs)] vous signale les éléments publics non documentés.
  • Les crates publiées sur crates.io voient leur documentation hébergée automatiquement sur docs.rs.

Petit quiz

1. Quel commentaire documente toute la crate quand il est placé en haut de src/lib.rs ?

2. Que fait cargo test d'un bloc de code placé dans un commentaire /// d'une bibliothèque ?

3. Dans un doc test, que fait une ligne qui commence par # (un dièse puis une espace) ?

4. Un exemple ouvre une connexion réseau. Vous voulez qu'il soit compilé mais pas exécuté. Quelle annotation utilisez-vous ?

Score : 0 / 4