Ir al contenido

Proyecto: Contador de palabras en Rust

En este proyecto vas a construir una herramienta pequeña pero real: un contador de palabras. Le das un texto y te dice qué palabras aparecen más veces. Quienes escriben usan herramientas así para descubrir qué palabras repiten demasiado. Los buscadores usan la misma idea para entender de qué trata una página.

Lo construiremos en pasos pequeños. Cada paso es un programa completo que funciona, así que puedes detenerte cuando quieras, experimentar con él y luego seguir. Por el camino vas a usar cadenas de texto, HashMaps, ordenación, salida con formato y argumentos de línea de comandos.

Al final, tu programa mostrará algo así:

PALABRA VECES
----------------
the 6
cat 3
happy 2

Una aclaración antes de empezar: los textos de ejemplo de esta página están en inglés. Así los conteos y las tablas son exactamente los mismos que en la versión original del tutorial. Tu contador funciona igual de bien con texto en español: to_lowercase y is_alphanumeric entienden letras como á o ñ.

Lo primero es cortar el texto en palabras. split_whitespace() divide una cadena por los espacios, tabuladores y saltos de línea, y se salta los trozos vacíos que quedan entre ellos:

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!("{} palabras", words.len());
}

Salida:

["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]
12 palabras
▶ Pruébalo tú mismo

Funciona, pero mira con atención. "The" y "the" cuentan como palabras distintas, y "mat." todavía lleva su punto. Si contáramos ahora, los resultados estarían mal. Arreglemos eso primero.

Normalizar una palabra significa llevarla a una forma estándar. Haremos dos cosas:

  1. Quitar los signos de puntuación del principio y del final, con trim_matches.
  2. Pasarla a minúsculas, con 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} -> (omitida)", word);
} else {
println!("{:>6} -> {}", word, clean);
}
}
}

Salida:

The -> the
cat -> cat
sat -> sat
on -> on
the -> the
mat. -> mat
Don't -> don't
stop -> stop
-- -> (omitida)
said -> said
the -> the
DOG! -> dog
▶ Pruébalo tú mismo

trim_matches recibe una closure (función anónima) que decide qué caracteres quitar. Aquí quita todo lo que no sea una letra o un dígito, pero solo en los extremos. Por eso el apóstrofo de don't se queda. Una “palabra” hecha solo de puntuación, como --, se convierte en una cadena vacía, y la omitimos.

Ahora viene la idea principal. Un HashMap relaciona cada palabra con las veces que la hemos visto. La API entry hace que contar sea muy corto:

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!("{} palabras distintas", counts.len());
}

Salida:

the: 6
cat: 3
happy: 2
11 palabras distintas
▶ Pruébalo tú mismo

Leamos despacio *counts.entry(clean).or_insert(0) += 1;:

  • counts.entry(clean) busca el lugar del mapa que corresponde a esta palabra.
  • .or_insert(0) pone un 0 ahí si la palabra es nueva. En los dos casos, te devuelve una referencia mutable al contador.
  • * sigue esa referencia hasta el número en sí, y += 1 le suma uno.

La \ al final de la primera línea del texto solo sirve para continuar la cadena en la línea siguiente. Y counts["the"] busca una clave directamente. Si la clave no existe, provoca un panic, pero aquí no pasa nada porque sabemos que esas palabras están en el texto. Para claves que podrían faltar, usa .get().

¿Por qué no mostramos el mapa completo con un bucle? Porque un HashMap no tiene un orden fijo. Si lo recorres, el orden puede cambiar cada vez que ejecutas el programa. Para tener un orden estable y útil, hay que ordenar.

Paso 4: Ordena por cantidad y luego por palabra

Sección titulada «Paso 4: Ordena por cantidad y luego por palabra»

Pasamos los pares del mapa a un Vec y lo ordenamos. Queremos primero la cantidad más alta. Cuando dos palabras tienen la misma cantidad, las queremos en orden alfabético, para que la salida sea siempre igual:

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}");
}
}

Salida:

the: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ Pruébalo tú mismo

La línea de ordenación es la parte más difícil del proyecto, así que vamos a desarmarla:

  • sort_by nos da dos elementos, a y b, y nos pregunta “¿cuál va primero?”.
  • b.1.cmp(&a.1) compara las cantidades (.1 es la segunda parte de la tupla). Escribimos b antes que a a propósito: eso invierte el orden, y así las cantidades grandes van primero.
  • .then(a.0.cmp(&b.0)) solo se usa cuando las cantidades son iguales. Compara las palabras en orden normal, de a a b, así que los empates quedan en orden alfabético.

Un detalle si cuentas texto en español: este orden “alfabético” compara el código de cada carácter, así que las palabras que empiezan con á o ñ quedan después de la z.

En la página Ordenar y buscar hay más ejemplos de sort_by y sus parientes.

Paso 5: Muestra una tabla con las primeras N palabras

Sección titulada «Paso 5: Muestra una tabla con las primeras N palabras»

Una lista larga cuesta leerla. Mostremos solo las primeras palabras, en columnas ordenadas. Los especificadores de formato se encargan de alinear:

  • {:<10} significa “alinear a la izquierda en un espacio de 10 caracteres”.
  • {:>5} significa “alinear a la derecha en un espacio de 5 caracteres”. Los números suelen verse mejor alineados a la derecha.

Los encabezados PALABRA (7 letras) y VECES (5 letras) caben en esos anchos, así que la tabla queda alineada con la línea de 16 guiones.

También vamos a pasar el código a funciones, para que main se lea como una historia corta:

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}", "PALABRA", "VECES");
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);
}

Salida:

PALABRA VECES
----------------
the 6
cat 3
happy 2
is 2
on 2
▶ Pruébalo tú mismo

.take(top_n) se detiene después de top_n elementos. Si hay menos palabras, simplemente termina antes, así que nunca tienes que preocuparte por pasarte del final.

Fíjate en que print_table recibe &[(String, usize)], un slice (porción). Eso significa que solo toma prestada la lista, y funciona con cualquier Vec de pares.

Paso 6: La versión final, que lee un archivo

Sección titulada «Paso 6: La versión final, que lee un archivo»

Un contador de palabras de verdad debería leer un archivo. Tomaremos la ruta del archivo de la línea de comandos, y un segundo argumento opcional con cuántas filas mostrar. Si algo sale mal, mostramos un mensaje amable con eprintln! (que escribe en la salida de errores) y terminamos con el código 1.

Crea el proyecto y un archivo de texto para probar:

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

Pon esto en src/main.rs. Esta versión es para tu propia máquina, porque el Playground no puede leer tus archivos:

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}", "PALABRA", "VECES");
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] es el nombre del programa, args[1] es la ruta del archivo
let Some(path) = args.get(1) else {
eprintln!("Uso: word_counter <archivo> [top_n]");
process::exit(1);
};
// el segundo argumento es opcional; usa 10 si falta o si no es un número
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!("No se pudo leer {path}: {e}");
process::exit(1);
}
};
let pairs = sorted_counts(count_words(&text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} palabras, {} distintas\n", total, pairs.len());
print_table(&pairs, top_n);
}

Ejecútalo. Recuerda que los argumentos para tu programa van después de --:

Terminal window
cargo run -- story.txt 3
12 palabras, 7 distintas
PALABRA VECES
----------------
the 4
on 2
sat 2

Y si olvidas el nombre del archivo, recibes el mensaje de uso en lugar de un cierre inesperado:

Terminal window
cargo run
Uso: word_counter <archivo> [top_n]

let Some(path) = args.get(1) else { ... }; se llama let-else. Significa “si este patrón coincide, sigue adelante; si no, ejecuta el bloque else, que tiene que detenerse (aquí, terminando el programa)”. La página de Archivos en Rust explica read_to_string y sus errores con más detalle. Si el archivo no existe, {e} muestra algo como No such file or directory (os error 2) (“no existe el archivo o directorio”). Ese mensaje lo genera el sistema, por eso suele salir en inglés.

Aquí tienes el mismo programa con el texto incluido en el código, para que puedas ejecutarlo en el Playground. Solo cambia main:

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}", "PALABRA", "VECES");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
// imagina que esto viene de un archivo
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!("{} palabras, {} distintas\n", total, pairs.len());
print_table(&pairs, top_n);
}

Salida:

20 palabras, 11 distintas
PALABRA VECES
----------------
is 5
rust 3
fun 2
it 2
▶ Pruébalo tú mismo

Olvidar el * al contar. or_insert te da una referencia al número, no el número en sí. Sin el *, Rust se niega a sumarle:

*counts.entry(word).or_insert(0) += 1; // correcto
counts.entry(word).or_insert(0) += 1; // error
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;
| +

El compilador te dice que += no se puede usar sobre una referencia mutable (&mut {integer}), y hasta te muestra la solución: agregar el * para llegar al número.

Mostrar el HashMap directamente. println!("{:?}", counts) funciona, pero el orden es aleatorio y puede cambiar entre ejecuciones. Cuando el orden importa, pasa siempre los datos a un Vec y ordénalo primero.

Ordenar solo por cantidad. Si ordenas solo con b.1.cmp(&a.1), las palabras con la misma cantidad pueden salir en cualquier orden. Agrega el desempate con .then(...) para que la salida sea siempre la misma.

Ya tienes una herramienta que funciona. Aquí van algunas formas de hacerla tuya, de la más fácil a la más difícil:

  • Ignora las palabras vacías. Palabras como “the”, “a”, “is” y “and” (o, en español, “el”, “la”, “de” y “y”) están en todas partes y no dicen mucho. Ponlas en un HashSet y sáltate cualquier palabra que esté en él.
  • Muestra porcentajes. Junto a cada cantidad, muestra qué parte del total representa, como the 6 28.6%. Usa {:.1} para mostrar un decimal.
  • Lee de stdin si no te dan un archivo. Usa std::io::read_to_string(std::io::stdin()) para poder pasarle texto con una tubería: cat story.txt | cargo run.
  • Cuenta varios archivos. Recorre cada ruta de args[1..] y suma todas sus palabras en un mismo mapa.
  • Usa clap para los argumentos. Ejecuta cargo add clap --features derive y agrega opciones de verdad como --top 5 y --ignore-case, además de un mensaje --help gratis. Mira un ejemplo en la página de Argumentos de línea de comandos.
  • Escribe tests. Pasa las funciones a src/lib.rs y agrega algunos tests (pruebas) para normalize y count_words.
  • split_whitespace() corta el texto en palabras; trim_matches y to_lowercase las normalizan.
  • *map.entry(key).or_insert(0) += 1 es la forma habitual de contar cosas con un HashMap.
  • Un HashMap no tiene orden. Pasa los datos a un Vec y usa sort_by con un desempate para obtener una salida estable.
  • {:<10} y {:>5} alinean el texto en columnas; .take(n) limita cuántas filas muestras.
  • Lee la ruta del archivo con env::args(), maneja los errores con un mensaje amable y termina con process::exit(1).

Comprueba lo aprendido

1. ¿Qué devuelve "¡Hola!".trim_matches(|c: char| !c.is_alphanumeric())?

2. ¿Por qué pasamos el HashMap a un Vec antes de mostrarlo?

3. En pairs.sort_by(|a, b| b.1.cmp(&a.1)), ¿por qué se escribe b antes que a?

4. ¿Qué hace {:>5} en println!?

Puntuación: 0 / 4