Salta ai contenuti

Documentazione e doc test in Rust

Ogni pagina di docs.rs e ogni pagina della documentazione della libreria standard l’ha scritta un programmatore, dentro il codice sorgente, usando dei commenti speciali. Rust trasforma quei commenti in un sito web con un solo comando. Puoi fare esattamente lo stesso con il tuo codice.

C’è anche un secondo motivo per interessartene. Gli esempi di codice nella tua documentazione sono veri test. Quando esegui cargo test, Rust li compila e li esegue. Se modifichi la funzione e ti dimentichi di aggiornare l’esempio, il test fallisce. La tua documentazione non può diventare obsoleta di nascosto. È una cosa rara nei linguaggi di programmazione, ed è uno degli aspetti più belli di Rust.

Hai già visto i commenti normali nella pagina Commenti. I commenti di documentazione (doc comment) sono simili, ma hanno tre caratteri:

  • /// documenta l’elemento sotto di sé: una funzione, una struct, un enum, un campo o un modulo.
  • //! documenta l’elemento dentro cui si trova. In cima a src/lib.rs o src/main.rs descrive l’intero crate.
//! Un piccolissimo programma di ricette.
/// Divide `total` grammi in `portions` porzioni uguali.
fn split_evenly(total: u32, portions: u32) -> u32 {
total / portions
}

La prima riga di un commento di documentazione dovrebbe essere una breve frase di riepilogo. Gli strumenti mostrano quella riga negli elenchi e nei risultati di ricerca, quindi falla valere.

I commenti di documentazione non cambiano quello che fa il programma. Ecco una funzione documentata, usata da main:

/// Divide `total` grammi in `portions` porzioni uguali.
///
/// I grammi avanzati vengono ignorati: 10 grammi divisi per 3 danno 3.
///
/// # Panics
///
/// Va in panic se `portions` vale zero.
fn split_evenly(total: u32, portions: u32) -> u32 {
if portions == 0 {
panic!("portions deve essere maggiore di zero");
}
total / portions
}
fn main() {
println!("{}", split_evenly(600, 3));
println!("{}", split_evenly(10, 3));
}

Output:

200
3
▶ Provalo tu

Nella maggior parte degli editor, se passi il mouse sopra split_evenly dentro main, ora vedi questo testo in un riquadro. Già solo questo vale la fatica.

I commenti di documentazione si scrivono in Markdown, lo stesso formato usato su GitHub e in molte app di chat. Puoi usare `codice`, **grassetto**, *corsivo*, elenchi, link e titoli:

/// Restituisce la temperatura del forno per un piatto, in **gradi Celsius**.
///
/// Piatti supportati:
///
/// - `"bread"`: 220 gradi
/// - `"cake"`: 180 gradi
/// - qualsiasi altro piatto: 200 gradi
///
/// Controlla *sempre* il tuo forno, perché ogni forno è diverso.
/// Vedi [Grado Celsius su Wikipedia](https://it.wikipedia.org/wiki/Grado_Celsius).
pub fn oven_temperature(dish: &str) -> u32 {
match dish {
"bread" => 220,
"cake" => 180,
_ => 200,
}
}

Lascia una riga /// vuota tra un paragrafo e l’altro, proprio come lasceresti una riga vuota in un normale file Markdown. Racchiudi tra backtick i nomi di funzioni, tipi e valori, così compaiono come codice.

I programmatori Rust usano sempre gli stessi titoli, così chi legge sa dove guardare. Uno l’hai già visto. Ecco i più comuni (per convenzione si scrivono in inglese):

  • # Examples: come si usa l’elemento. Quasi ogni elemento pubblico dovrebbe averne uno.
  • # Panics: quando la funzione può andare in panic, così chi la chiama può evitarlo.
  • # Errors: quando una funzione che restituisce un Result restituisce un Err, e perché.
  • # Safety: per una unsafe fn, le regole che chi la chiama deve rispettare (vedi Rust unsafe).

Ecco # Errors e # Safety in un file di libreria, src/lib.rs:

/// Legge una quantità come `"250g"` e restituisce il numero di grammi.
///
/// # Errors
///
/// Restituisce un errore se il testo non finisce con `g`
/// o se la parte prima della `g` non è un numero intero.
pub fn parse_grams(text: &str) -> Result<u32, String> {
let number = text
.strip_suffix('g')
.ok_or(format!("'{text}' dovrebbe finire con g"))?;
number
.parse()
.map_err(|_| format!("'{number}' non è un numero intero"))
}
/// Restituisce il primo ingrediente senza controllare che la lista non sia vuota.
///
/// # Safety
///
/// Chi chiama la funzione deve assicurarsi che `ingredients` non sia vuota.
/// Chiamarla su una slice vuota è comportamento indefinito (undefined behavior).
pub unsafe fn first_unchecked(ingredients: &[String]) -> &String {
unsafe { ingredients.get_unchecked(0) }
}

Nota che i titoli usano un solo #. Dentro un commento di documentazione, # è un titolo Markdown, non un attributo di Rust.

Per il resto di questa pagina lavoreremo su una piccola libreria. Creala con cargo new --lib kitchen. Poi, dentro il progetto, esegui:

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 genera un sito HTML in target/doc/ e --open lo apre nel browser. Ha esattamente l’aspetto delle pagine di docs.rs: una barra laterale, una casella di ricerca e una pagina per ogni funzione e ogni tipo. Documenta anche tutti i crate da cui dipendi, il che è comodo quando sei offline.

Di default compaiono solo gli elementi pubblici (marcati pub), perché la documentazione è per chi usa la tua libreria. Aggiungi --document-private-items per includere tutto.

Ecco l’inizio di src/lib.rs nella libreria kitchen. La funzione ha una sezione # Examples con un blocco di codice dentro il commento di documentazione:

//! Piccoli aiuti per la cucina.
//!
//! `kitchen` converte le temperature del forno, divide il cibo in porzioni
//! e legge quantità come `"250g"`.
/// Converte una temperatura da gradi Celsius a gradi 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
}

Ora esegui 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 ha trovato il blocco di codice alla riga 10 (line 10), l’ha trasformato in un piccolo programma, l’ha compilato e l’ha eseguito. assert_eq! ha controllato il risultato. Alcune cose da notare:

  • L’esempio chiama kitchen::celsius_to_fahrenheit, con il nome del crate. Un doc test viene compilato come se fosse il programma di qualcun altro che usa la tua libreria, quindi vede solo gli elementi pub, e ti serve il percorso completo oppure una riga use kitchen::...;.
  • Non scrivi fn main. Rustdoc avvolge il tuo esempio in un main al posto tuo.
  • Usa assert! e assert_eq! (dalla pagina Test) per mostrare e controllare il risultato nello stesso momento.

E se l’esempio è sbagliato? Cambia 356.0 in 350.0 ed esegui di nuovo i test:

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

È proprio questo il punto. Un esempio sbagliato viene scoperto subito, invece di confondere un lettore mesi dopo. Per eseguire solo i doc test, usa cargo test --doc.

A volte un esempio ha bisogno di righe che distrarrebbero chi legge. Una riga di un doc test che inizia con # (cancelletto e spazio) viene compilata ma non mostrata nella documentazione generata.

L’uso più comune: vuoi mostrare l’operatore ?, ma ? funziona solo dentro una funzione che restituisce un Result. Allora nascondi il tuo main:

/// Legge una quantità come `"250g"` e restituisce il numero di grammi.
///
/// # Examples
///
/// ```
/// use kitchen::parse_grams;
///
/// # fn main() -> Result<(), String> {
/// let flour = parse_grams("250g")?;
/// assert_eq!(flour, 250);
///
/// assert!(parse_grams("a lot").is_err());
/// # Ok(())
/// # }
/// ```
pub fn parse_grams(text: &str) -> Result<u32, String> {
let number = text
.strip_suffix('g')
.ok_or(format!("'{text}' dovrebbe finire con g"))?;
number
.parse()
.map_err(|_| format!("'{number}' non è un numero intero"))
}

Nel browser, chi legge vede solo le quattro righe interessanti: lo use, la chiamata con ? e i due assert. Il test compila comunque tutto.

Non tutti gli esempi possono semplicemente essere eseguiti. Puoi scrivere una parola dopo i backtick di apertura per dire a rustdoc che cosa fare:

  • ```no_run: compila l’esempio, ma non eseguirlo. Va bene per codice che legge file, apre connessioni di rete o gira all’infinito.
  • ```should_panic: eseguilo e aspettati un panic. Il test fallisce se il panic non arriva.
  • ```ignore: non compilarlo affatto. Usalo per bozze e pseudocodice. Prova prima no_run, perché un esempio ignorato può diventare obsoleto senza che nessuno se ne accorga.
  • ```text: non è codice Rust, solo testo (per esempio, un output di esempio).
/// Divide `total` grammi in `portions` porzioni uguali.
///
/// # Examples
///
/// ```
/// assert_eq!(kitchen::split_evenly(600, 3), 200);
/// ```
///
/// Zero porzioni non ha senso, quindi questo va in panic:
///
/// ```should_panic
/// kitchen::split_evenly(600, 0);
/// ```
pub fn split_evenly(total: u32, portions: u32) -> u32 {
if portions == 0 {
panic!("portions deve essere maggiore di zero");
}
total / portions
}
/// Carica una lista della spesa da un file, un elemento per riga.
///
/// ```no_run
/// let items = kitchen::load_list("shopping.txt").unwrap();
/// println!("{} cose da comprare", 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())
}

Con tutti questi esempi in kitchen, più una bozza ignore nel commento //! del crate, cargo test riporta:

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

Il test no_run dice - compile: è stato solo compilato. L’ordine delle righe può cambiare da un’esecuzione all’altra, perché i test girano in parallelo.

Dentro un commento di documentazione, scrivi un nome tra backtick e parentesi quadre, come [`Vec`], e rustdoc lo trasforma in un link alla pagina di quell’elemento. Si chiama intra-doc link (link interno alla documentazione). Funziona per i tuoi elementi e per qualsiasi cosa nella libreria standard:

//! Un piccolissimo programma di ricette.
/// Una ricetta: un nome e una lista di ingredienti con il loro peso in grammi.
///
/// Creane una con [`Recipe::new`] e riempila con [`Recipe::add`].
/// Gli ingredienti stanno in un [`Vec`], nell'ordine in cui li aggiungi.
struct Recipe {
/// Il nome mostrato in cima alla ricetta.
name: String,
/// Ogni ingrediente con il suo peso in grammi.
ingredients: Vec<(String, u32)>,
}
impl Recipe {
/// Crea una ricetta vuota con il nome indicato.
fn new(name: &str) -> Recipe {
Recipe { name: name.to_string(), ingredients: Vec::new() }
}
/// Aggiunge un ingrediente in fondo alla lista.
fn add(&mut self, ingredient: &str, grams: u32) {
self.ingredients.push((ingredient.to_string(), grams));
}
/// Somma il peso di tutti gli ingredienti.
fn total_grams(&self) -> u32 {
self.ingredients.iter().map(|(_, grams)| grams).sum()
}
}
fn main() {
let mut pancakes = Recipe::new("Pancake");
pancakes.add("farina", 200);
pancakes.add("latte", 300);
pancakes.add("uovo", 50);
println!("{}", pancakes.name);
for (ingredient, grams) in &pancakes.ingredients {
println!("- {ingredient}: {grams} g");
}
println!("Totale: {} g", pancakes.total_grams());
}

Output:

Pancake
- farina: 200 g
- latte: 300 g
- uovo: 50 g
Totale: 550 g
▶ Provalo tu

Anche i campi e i metodi possono avere i loro commenti ///, e ognuno compare nella pagina della struct. Se un link punta a qualcosa che non esiste, cargo doc ti avvisa. Qui Recipe::push era un errore di battitura al posto di Recipe::add:

warning: unresolved link to `Recipe::push`
--> src/lib.rs:100:53
|
100 | /// Creane una con [`Recipe::new`] e riempila con [`Recipe::push`].
| ^^^^^^^^^^^^ the struct `Recipe` has no field or associated item named `push`
|
= note: `#[warn(rustdoc::broken_intra_doc_links)]` on by default

L’avviso dice che il link a Recipe::push non si può risolvere, perché la struct Recipe non ha né un campo né un elemento associato chiamato push.

Rendere obbligatoria la documentazione con missing_docs

Sezione intitolata “Rendere obbligatoria la documentazione con missing_docs”

Per una libreria che useranno altre persone, puoi chiedere al compilatore di ricordarti ogni elemento pubblico senza documentazione. Metti questa riga in cima a src/lib.rs:

#![warn(missing_docs)]

Ora, se dimentichi un commento di documentazione, ricevi un avviso:

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)]
| ^^^^^^^^^^^^

L’avviso significa «manca la documentazione per una funzione». Il #! (con il punto esclamativo) indica che l’attributo vale per l’intero crate. Questo lint vuole anche un commento //! per il crate stesso. Gli elementi privati non vengono mai segnalati, quindi non è un peso per le funzioni di supporto interne.

docs.rs: hosting gratuito per la tua documentazione

Sezione intitolata “docs.rs: hosting gratuito per la tua documentazione”

Quando pubblichi un crate su crates.io (vedi Cargo e crate), docs.rs genera automaticamente la sua documentazione e la ospita gratis, per ogni versione. Non devi fare niente. Ecco perché quasi ogni crate Rust ha una documentazione dall’aspetto uguale e facile da consultare.

Altri due consigli:

  • Esegui rustup doc --std per aprire nel browser la documentazione della libreria standard offline.
  • In qualsiasi pagina di docs.rs o della libreria standard, clicca sul link Source per leggere il codice dietro una funzione. È un ottimo modo per imparare.

Dimenticare il nome del crate in un doc test. Un doc test sta fuori dal tuo crate, quindi questo fallisce:

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

Il compilatore non trova la funzione celsius_to_fahrenheit in questo scope. La soluzione è kitchen::celsius_to_fahrenheit(180.0), oppure una riga use kitchen::celsius_to_fahrenheit; all’inizio dell’esempio (nascosta con #, se vuoi). Trovi altri dettagli su questo errore nella pagina E0425.

Un commento di documentazione che non documenta niente. /// deve stare subito prima di un elemento. Dentro il corpo di una funzione, usa //:

error[E0585]: found a documentation comment that doesn't document anything
--> src/main.rs:3:5
|
3 | /// Dividi il cibo in tre porzioni.
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: doc comments must come before what they document, if a comment was intended use `//`

Il compilatore ha trovato un commento di documentazione che non documenta niente e ti suggerisce di usare // se volevi un commento normale.

Aspettarsi che i doc test di un eseguibile vengano eseguiti. I doc test girano per i crate di libreria (src/lib.rs). Gli esempi nei commenti di documentazione di src/main.rs non vengono testati. Se il tuo programma cresce, sposta la logica in src/lib.rs e tieni main.rs piccolo.

Scrivere documentazione che ripete il codice. «Somma due numeri» sopra fn add(a, b) non aiuta. Spiega quello che chi legge non può vedere: le unità di misura (grammi? secondi?), i casi limite e quando la funzione fallisce.

  • /// documenta l’elemento successivo, e //! documenta il crate o il modulo in cui si trova. Entrambi usano il Markdown.
  • Usa le sezioni standard: # Examples, # Panics, # Errors e # Safety.
  • cargo doc --open genera un sito HTML per il tuo crate e le sue dipendenze.
  • I blocchi di codice nei commenti di documentazione sono doc test: cargo test li compila e li esegue. Nascondi le righe di preparazione con # e usa no_run, should_panic o ignore quando serve.
  • [`Name`] crea un intra-doc link, e #![warn(missing_docs)] ti ricorda gli elementi pubblici senza documentazione.
  • I crate pubblicati su crates.io hanno la documentazione ospitata automaticamente su docs.rs.

Verifica veloce

1. Quale commento documenta l'intero crate quando è messo in cima a src/lib.rs?

2. Che cosa fa cargo test con un blocco di codice dentro un commento /// di una libreria?

3. In un doc test, che cosa fa una riga che inizia con # (cancelletto e spazio)?

4. Un esempio apre una connessione di rete. Vuoi che venga compilato ma non eseguito. Quale annotazione usi?

Punteggio: 0 / 4