aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 19:00:45 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 19:00:45 -0300
commita946ba81f7a6089713337644053b00b825824a98 (patch)
treedf7c98401b3a879a0283e940afa3fecc9453d9d4 /src
downloaddrae-cli-main.tar.gz
drae-cli-main.zip
drae: the RAE Spanish dictionary in the terminalHEADmain
Diffstat (limited to 'src')
-rw-r--r--src/cache.rs244
-rw-r--r--src/cli.rs141
-rw-r--r--src/client.rs85
-rw-r--r--src/commands.rs311
-rw-r--r--src/config.rs134
-rw-r--r--src/dle.rs309
-rw-r--r--src/html.rs235
-rw-r--r--src/main.rs96
-rw-r--r--src/model.rs150
-rw-r--r--src/output.rs404
-rw-r--r--src/raeapi.rs441
-rw-r--r--src/suggest.rs221
-rw-r--r--src/wiktionary.rs270
13 files changed, 3041 insertions, 0 deletions
diff --git a/src/cache.rs b/src/cache.rs
new file mode 100644
index 0000000..45d66a4
--- /dev/null
+++ b/src/cache.rs
@@ -0,0 +1,244 @@
+//! Local SQLite cache: anything looked up once is read back without network.
+//!
+//! Lives in `~/.cache/drae/entries.db` (or `$XDG_CACHE_HOME`). It stores the
+//! already-normalized entry as JSON, so it survives changes to the DLE markup
+//! and lets the user search inside definitions already downloaded.
+
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use anyhow::{Context, Result};
+use rusqlite::{params, Connection, OptionalExtension};
+
+use crate::model::Entry;
+
+pub struct Cache {
+ conn: Connection,
+}
+
+impl Cache {
+ pub fn open(path: &Path) -> Result<Self> {
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)
+ .with_context(|| format!("no se pudo crear {}", dir.display()))?;
+ }
+ let conn = Connection::open(path)
+ .with_context(|| format!("no se pudo abrir la caché en {}", path.display()))?;
+ conn.execute_batch(
+ "PRAGMA journal_mode = WAL;
+ CREATE TABLE IF NOT EXISTS entries (
+ key TEXT PRIMARY KEY,
+ word TEXT NOT NULL,
+ json TEXT NOT NULL,
+ source TEXT NOT NULL,
+ fetched_at INTEGER NOT NULL
+ );",
+ )
+ .context("no se pudo preparar la caché")?;
+ Ok(Cache { conn })
+ }
+
+ /// Default path: `$XDG_CACHE_HOME/drae/entries.db`.
+ pub fn default_path() -> PathBuf {
+ let base = std::env::var_os("XDG_CACHE_HOME")
+ .map(PathBuf::from)
+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
+ .unwrap_or_else(|| PathBuf::from(".cache"));
+ base.join("drae").join("entries.db")
+ }
+
+ pub fn get(&self, word: &str) -> Result<Option<Entry>> {
+ let json: Option<String> = self
+ .conn
+ .query_row(
+ "SELECT json FROM entries WHERE key = ?1",
+ params![key(word)],
+ |r| r.get(0),
+ )
+ .optional()?;
+ // An entry written by an older version of the model is silently
+ // ignored and downloaded again.
+ Ok(json.and_then(|j| serde_json::from_str(&j).ok()))
+ }
+
+ pub fn put(&self, word: &str, entry: &Entry) -> Result<()> {
+ self.conn.execute(
+ "INSERT INTO entries (key, word, json, source, fetched_at)
+ VALUES (?1, ?2, ?3, ?4, ?5)
+ ON CONFLICT(key) DO UPDATE SET
+ json = excluded.json,
+ source = excluded.source,
+ fetched_at = excluded.fetched_at",
+ params![
+ key(word),
+ entry.word,
+ serde_json::to_string(entry)?,
+ entry.source,
+ now(),
+ ],
+ )?;
+ Ok(())
+ }
+
+ /// Stored words, most recent first.
+ pub fn list(&self, limit: usize) -> Result<Vec<(String, String, i64)>> {
+ let mut stmt = self.conn.prepare(
+ "SELECT word, source, fetched_at FROM entries
+ ORDER BY fetched_at DESC LIMIT ?1",
+ )?;
+ let rows = stmt
+ .query_map(params![limit as i64], |r| {
+ Ok((r.get(0)?, r.get(1)?, r.get(2)?))
+ })?
+ .collect::<rusqlite::Result<Vec<_>>>()?;
+ Ok(rows)
+ }
+
+ /// Searches for a text inside the definitions already downloaded.
+ pub fn search(&self, needle: &str, limit: usize) -> Result<Vec<(String, String)>> {
+ let mut stmt = self.conn.prepare(
+ "SELECT word, json FROM entries
+ WHERE json LIKE ?1 ESCAPE '\\'
+ ORDER BY word LIMIT ?2",
+ )?;
+ let pattern = format!("%{}%", escape_like(needle));
+ let rows = stmt
+ .query_map(params![pattern, limit as i64], |r| {
+ Ok((r.get(0)?, r.get(1)?))
+ })?
+ .collect::<rusqlite::Result<Vec<_>>>()?;
+ Ok(rows)
+ }
+
+ pub fn count(&self) -> Result<i64> {
+ Ok(self
+ .conn
+ .query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?)
+ }
+
+ pub fn clear(&self) -> Result<usize> {
+ let n = self.conn.execute("DELETE FROM entries", [])?;
+ self.conn.execute_batch("VACUUM;")?;
+ Ok(n)
+ }
+}
+
+/// Lookup key: lowercase, so «Casa» and «casa» share an entry.
+fn key(word: &str) -> String {
+ word.trim().to_lowercase()
+}
+
+fn escape_like(s: &str) -> String {
+ s.replace('\\', "\\\\")
+ .replace('%', "\\%")
+ .replace('_', "\\_")
+}
+
+/// Formats a Unix timestamp as `YYYY-MM-DD` without a date library, using
+/// Howard Hinnant's civil-from-days algorithm.
+pub fn format_date(ts: i64) -> String {
+ let days = ts.div_euclid(86_400);
+ let z = days + 719_468;
+ let era = z.div_euclid(146_097);
+ let doe = z.rem_euclid(146_097);
+ let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
+ let y = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = if m <= 2 { y + 1 } else { y };
+ format!("{y:04}-{m:02}-{d:02}")
+}
+
+fn now() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs() as i64)
+ .unwrap_or(0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::model::Sense;
+
+ fn entry(word: &str, text: &str) -> Entry {
+ Entry {
+ word: word.into(),
+ etymology: None,
+ senses: vec![Sense {
+ text: text.into(),
+ ..Default::default()
+ }],
+ locutions: vec![],
+ conjugation: None,
+ source: "dle".into(),
+ }
+ }
+
+ fn cache() -> Cache {
+ Cache::open(Path::new(":memory:")).unwrap()
+ }
+
+ #[test]
+ fn stores_and_reads_back_an_entry() {
+ let c = cache();
+ c.put("casa", &entry("casa", "Edificio para habitar."))
+ .unwrap();
+ let got = c.get("casa").unwrap().expect("should be in the cache");
+ assert_eq!(got.word, "casa");
+ assert_eq!(c.count().unwrap(), 1);
+ }
+
+ #[test]
+ fn lookup_is_case_insensitive() {
+ let c = cache();
+ c.put("Casa", &entry("casa", "Edificio.")).unwrap();
+ assert!(c.get("casa").unwrap().is_some());
+ assert!(c.get("CASA").unwrap().is_some());
+ }
+
+ #[test]
+ fn writing_the_same_word_twice_replaces_it() {
+ let c = cache();
+ c.put("casa", &entry("casa", "vieja")).unwrap();
+ c.put("casa", &entry("casa", "nueva")).unwrap();
+ assert_eq!(c.count().unwrap(), 1);
+ assert_eq!(c.get("casa").unwrap().unwrap().senses[0].text, "nueva");
+ }
+
+ #[test]
+ fn searches_inside_stored_definitions() {
+ let c = cache();
+ c.put("casa", &entry("casa", "Edificio para habitar."))
+ .unwrap();
+ c.put("perro", &entry("perro", "Mamífero doméstico."))
+ .unwrap();
+ let hits = c.search("Edificio", 10).unwrap();
+ assert_eq!(hits.len(), 1);
+ assert_eq!(hits[0].0, "casa");
+ }
+
+ #[test]
+ fn like_wildcards_in_the_query_are_literal() {
+ let c = cache();
+ c.put("casa", &entry("casa", "Edificio.")).unwrap();
+ // Unescaped, "%" would match everything.
+ assert!(c.search("%", 10).unwrap().is_empty());
+ }
+
+ #[test]
+ fn formats_a_unix_timestamp_as_a_date() {
+ assert_eq!(format_date(0), "1970-01-01");
+ assert_eq!(format_date(1_757_000_000), "2025-09-04");
+ }
+
+ #[test]
+ fn clear_empties_the_cache() {
+ let c = cache();
+ c.put("casa", &entry("casa", "Edificio.")).unwrap();
+ c.clear().unwrap();
+ assert_eq!(c.count().unwrap(), 0);
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..9f23838
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,141 @@
+//! Command-line definition.
+//!
+//! The tool is a Spanish dictionary for Spanish speakers, so every command,
+//! flag and help text the user sees is in Spanish. Rust names are in English
+//! and mapped explicitly (`name = "..."`, `long = "..."`).
+
+use std::path::PathBuf;
+
+use clap::{Parser, Subcommand, ValueEnum};
+
+#[derive(Parser)]
+#[command(
+ name = "drae",
+ version,
+ about = "Diccionario de la lengua española en la terminal",
+ long_about = "drae — consulta el Diccionario de la lengua española (RAE) sin salir de la terminal.\n\
+ Sin orden, define la palabra que se le pase: `drae casa`.\n\
+ Lo consultado queda en caché, así que la segunda vez responde sin red.",
+ args_conflicts_with_subcommands = true
+)]
+pub struct Cli {
+ /// La palabra a definir (equivale a `drae definir <palabra>`)
+ #[arg(value_name = "PALABRA")]
+ pub word: Option<String>,
+
+ #[command(subcommand)]
+ pub cmd: Option<Cmd>,
+
+ /// Archivo de configuración (por defecto ~/.config/drae/config.toml)
+ #[arg(long, global = true, value_name = "ARCHIVO")]
+ pub config: Option<PathBuf>,
+
+ /// Imprime JSON en lugar del texto formateado
+ #[arg(long, global = true)]
+ pub json: bool,
+
+ /// Cuándo colorear la salida
+ #[arg(long, global = true, value_enum, default_value_t = Color::Auto)]
+ pub color: Color,
+
+ /// Ancho de salida en columnas (por defecto, el de la terminal)
+ #[arg(long = "ancho", global = true, value_name = "N")]
+ pub width: Option<usize>,
+
+ /// Despliega el significado de las abreviaturas (f. = nombre femenino)
+ #[arg(long = "abreviaturas", short = 'a', global = true)]
+ pub expand_abbr: bool,
+
+ /// Ignora la caché y vuelve a descargar
+ #[arg(long = "actualizar", short = 'A', global = true)]
+ pub refresh: bool,
+
+ /// No leer ni escribir la caché
+ #[arg(long = "sin-cache", global = true)]
+ pub no_cache: bool,
+
+ /// De dónde leer: auto (rae-api con respaldo), dle o rae-api
+ #[arg(long = "fuente", global = true, value_enum)]
+ pub source: Option<Source>,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum Color {
+ Auto,
+ Always,
+ Never,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum Source {
+ Auto,
+ Dle,
+ #[value(name = "rae-api")]
+ RaeApi,
+}
+
+#[derive(Subcommand)]
+pub enum Cmd {
+ /// Define una palabra. Úsalo cuando la palabra coincida con el nombre de
+ /// una orden: `drae definir sin`
+ #[command(name = "definir", alias = "def")]
+ Define {
+ #[arg(value_name = "PALABRA")]
+ word: String,
+ },
+
+ /// Sinónimos y antónimos de una palabra
+ #[command(name = "sinonimos", alias = "sin")]
+ Synonyms {
+ #[arg(value_name = "PALABRA")]
+ word: String,
+ },
+
+ /// Conjugación completa de un verbo
+ #[command(name = "conjugar", alias = "conj")]
+ Conjugate {
+ #[arg(value_name = "VERBO")]
+ verb: String,
+ /// Solo este modo: indicativo, subjuntivo, imperativo…
+ #[arg(long = "modo", value_name = "MODO")]
+ mood: Option<String>,
+ },
+
+ /// La palabra del día que publica la RAE
+ #[command(name = "dia")]
+ WordOfTheDay,
+
+ /// Una palabra al azar del diccionario
+ #[command(name = "azar")]
+ Random,
+
+ /// Busca un texto dentro de las definiciones ya guardadas en caché
+ #[command(name = "buscar")]
+ Search {
+ #[arg(value_name = "TEXTO")]
+ text: String,
+ #[arg(long, default_value_t = 20)]
+ max: usize,
+ },
+
+ /// Gestiona la caché local
+ Cache {
+ #[command(subcommand)]
+ action: CacheCmd,
+ },
+}
+
+#[derive(Subcommand)]
+pub enum CacheCmd {
+ /// Cuántas entradas hay y dónde viven
+ Info,
+ /// Las últimas palabras consultadas
+ #[command(name = "listar")]
+ List {
+ #[arg(long, default_value_t = 30)]
+ max: usize,
+ },
+ /// Borra todo lo guardado
+ #[command(name = "limpiar")]
+ Clear,
+}
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..37205bf
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,85 @@
+//! HTTP client.
+//!
+//! dle.rae.es answers 403 to short or missing user agents, so requests to it
+//! identify as a desktop browser.
+
+use std::time::Duration;
+
+use anyhow::{Context, Result};
+use ureq::Agent;
+
+/// Browser user agent, for dle.rae.es, which rejects clients that do not
+/// identify as one.
+pub const BROWSER_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
+ (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
+
+/// Honest identification for APIs that want to know who is calling, as the
+/// Wikimedia policy asks.
+pub const CLIENT_UA: &str = concat!(
+ "drae-cli/",
+ env!("CARGO_PKG_VERSION"),
+ " (terminal dictionary)"
+);
+
+pub struct Http {
+ agent: Agent,
+}
+
+impl Http {
+ pub fn new(timeout_secs: u64) -> Self {
+ let config = Agent::config_builder()
+ .timeout_global(Some(Duration::from_secs(timeout_secs)))
+ // A 404 is an answer we want to read, not a network error.
+ .http_status_as_error(false)
+ .build();
+ Http {
+ agent: config.new_agent(),
+ }
+ }
+
+ /// Returns the status code and the body.
+ pub fn get(&self, url: &str, user_agent: &str) -> Result<(u16, String)> {
+ let mut res = self
+ .agent
+ .get(url)
+ .header("User-Agent", user_agent)
+ .header("Accept-Language", "es-ES,es;q=0.9")
+ .header("Accept", "text/html,application/json;q=0.9,*/*;q=0.8")
+ .call()
+ .with_context(|| format!("no se pudo conectar con {url}"))?;
+ let status = res.status().as_u16();
+ let body = res
+ .body_mut()
+ .read_to_string()
+ .with_context(|| format!("respuesta ilegible de {url}"))?;
+ Ok((status, body))
+ }
+}
+
+/// Encodes a word for a URL path. The RAE needs `%C3%B1` for ñ; other safe
+/// characters pass through unchanged.
+pub fn percent_encode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for b in s.as_bytes() {
+ match b {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ out.push(*b as char)
+ }
+ _ => out.push_str(&format!("%{b:02X}")),
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn encodes_spanish_characters() {
+ assert_eq!(percent_encode("año"), "a%C3%B1o");
+ assert_eq!(percent_encode("café"), "caf%C3%A9");
+ assert_eq!(percent_encode("perro"), "perro");
+ assert_eq!(percent_encode("dar de sí"), "dar%20de%20s%C3%AD");
+ }
+}
diff --git a/src/commands.rs b/src/commands.rs
new file mode 100644
index 0000000..d82970e
--- /dev/null
+++ b/src/commands.rs
@@ -0,0 +1,311 @@
+//! The commands, and the path a lookup follows:
+//! cache → rae-api.com → dle.rae.es → offline suggestions.
+
+use anyhow::{bail, Context, Result};
+
+use crate::cache::Cache;
+use crate::cli::Source;
+use crate::client::{percent_encode, Http, BROWSER_UA};
+use crate::config::Config;
+use crate::model::Entry;
+use crate::output::{self, Style};
+use crate::{dle, raeapi, suggest, wiktionary};
+
+const DLE_BASE: &str = "https://dle.rae.es";
+
+pub struct Ctx {
+ pub cfg: Config,
+ pub http: Http,
+ pub cache: Option<Cache>,
+ pub style: Style,
+ pub json: bool,
+ pub expand_abbr: bool,
+ pub refresh: bool,
+ pub source: Source,
+}
+
+impl Ctx {
+ /// Looks a word up. If it does not exist, returns a ready-to-print error
+ /// with the closest suggestions from the local dictionary.
+ pub fn lookup(&self, word: &str) -> Result<Entry> {
+ let word = word.trim();
+ if word.is_empty() {
+ bail!("hay que indicar una palabra");
+ }
+
+ if !self.refresh {
+ if let Some(c) = &self.cache {
+ if let Some(hit) = c.get(word)? {
+ return Ok(hit);
+ }
+ }
+ }
+
+ let entry = self.fetch(word)?;
+ match entry {
+ Some(e) => {
+ if let Some(c) = &self.cache {
+ // A failed write must not stop the word from being shown.
+ let _ = c.put(word, &e);
+ }
+ Ok(e)
+ }
+ None => bail!("{}", self.not_found_message(word)),
+ }
+ }
+
+ /// Downloads the entry from the chosen source. `Ok(None)` = no such word.
+ ///
+ /// The default order is rae-api first: it serves the DLE content as JSON
+ /// and accepts automated clients. dle.rae.es is the fallback because it
+ /// answers with a Cloudflare challenge to anything but a browser, so most
+ /// of the time it does not respond.
+ fn fetch(&self, word: &str) -> Result<Option<Entry>> {
+ match self.source {
+ Source::Dle => self.fetch_dle(word),
+ Source::RaeApi => raeapi::lookup(&self.http, word),
+ Source::Auto => match raeapi::lookup(&self.http, word) {
+ Ok(Some(e)) => Ok(Some(e)),
+ Ok(None) => Ok(None),
+ // The API failed: try the site, in case this client gets through.
+ Err(first) => self.fetch_dle(word).map_err(|_| first),
+ },
+ }
+ }
+
+ fn fetch_dle(&self, word: &str) -> Result<Option<Entry>> {
+ let url = format!("{DLE_BASE}/{}", percent_encode(word));
+ let (status, body) = self.http.get(&url, BROWSER_UA)?;
+ if status == 403 {
+ bail!(
+ "dle.rae.es rechazó la petición con un desafío de Cloudflare, \
+ que solo resuelve un navegador"
+ );
+ }
+ if status != 200 {
+ bail!("dle.rae.es respondió {status}");
+ }
+ if dle::is_not_found(&body) {
+ return Ok(None);
+ }
+ match dle::parse(&body) {
+ Some(e) if !e.senses.is_empty() => Ok(Some(e)),
+ // A valid page we cannot read: let the caller decide.
+ _ => bail!("no se pudo interpretar la página del DLE para «{word}»"),
+ }
+ }
+
+ fn not_found_message(&self, word: &str) -> String {
+ let mut msg = format!("«{word}» no está en el Diccionario");
+ for s in self.suggestions(word) {
+ msg.push_str(&format!("\n ¿quiso decir {}?", self.style.cyan(&s)));
+ }
+ msg
+ }
+
+ /// Similar words, from the system hunspell dictionary.
+ fn suggestions(&self, word: &str) -> Vec<String> {
+ let path = self
+ .cfg
+ .dictionary
+ .clone()
+ .or_else(suggest::find_dictionary);
+ let Some(path) = path else {
+ return vec![];
+ };
+ let Ok(words) = suggest::load_words(&path) else {
+ return vec![];
+ };
+ suggest::closest(word, &words, 4)
+ }
+
+ fn print_json<T: serde::Serialize>(&self, v: &T) -> Result<()> {
+ println!("{}", serde_json::to_string_pretty(v)?);
+ Ok(())
+ }
+}
+
+pub fn define(ctx: &Ctx, word: &str) -> Result<()> {
+ let e = ctx.lookup(word)?;
+ if ctx.json {
+ return ctx.print_json(&e);
+ }
+ print!("{}", output::entry(&e, &ctx.style, ctx.expand_abbr));
+ Ok(())
+}
+
+pub fn synonyms(ctx: &Ctx, word: &str) -> Result<()> {
+ let e = ctx.lookup(word)?;
+ if ctx.json {
+ #[derive(serde::Serialize)]
+ struct Out<'a> {
+ word: &'a str,
+ synonyms: Vec<String>,
+ antonyms: Vec<String>,
+ }
+ return ctx.print_json(&Out {
+ word: &e.word,
+ synonyms: e.all_synonyms(),
+ antonyms: e.all_antonyms(),
+ });
+ }
+
+ if !e.has_synonyms() && !e.has_antonyms() {
+ println!(
+ "{}",
+ ctx.style
+ .dim(&format!("«{}» no tiene sinónimos registrados", e.word))
+ );
+ return Ok(());
+ }
+
+ println!("{}\n", ctx.style.heading(&e.word));
+ // Shown per sense: the synonyms of «casa» are not the same for
+ // «building» as for «lineage».
+ for s in &e.senses {
+ if s.synonyms.is_empty() && s.antonyms.is_empty() {
+ continue;
+ }
+ let num = s.number.clone().unwrap_or_default();
+ let head = format!(
+ "{}. {}",
+ num,
+ output::truncate(&s.text, ctx.style.width - 6)
+ );
+ println!("{}", ctx.style.dim(&head));
+ if !s.synonyms.is_empty() {
+ let joined = output::superscript_homographs(&s.synonyms.join(", "));
+ let line = format!("{} {joined}", ctx.style.green("Sin.:"));
+ println!(
+ "{}",
+ output::wrap(&line, ctx.style.width, " ", " ")
+ );
+ }
+ if !s.antonyms.is_empty() {
+ let joined = output::superscript_homographs(&s.antonyms.join(", "));
+ let line = format!("{} {joined}", ctx.style.red("Ant.:"));
+ println!(
+ "{}",
+ output::wrap(&line, ctx.style.width, " ", " ")
+ );
+ }
+ println!();
+ }
+ Ok(())
+}
+
+pub fn conjugate(ctx: &Ctx, verb: &str, mood: Option<&str>) -> Result<()> {
+ let Some(mut c) = wiktionary::conjugation(&ctx.http, verb)? else {
+ bail!("«{verb}» no tiene tabla de conjugación (¿no es un verbo?)");
+ };
+
+ if let Some(m) = mood {
+ let needle = suggest::fold(m);
+ c.groups
+ .retain(|g| suggest::fold(&g.title).contains(&needle));
+ if c.groups.is_empty() {
+ bail!("no hay ningún modo que se parezca a «{m}»");
+ }
+ }
+
+ if ctx.json {
+ return ctx.print_json(&c);
+ }
+ print!("{}", output::conjugation(&c, &ctx.style));
+ Ok(())
+}
+
+/// The word of the day and the random word come from the rae-api index and
+/// are then defined through the normal path.
+pub fn featured(ctx: &Ctx, daily: bool) -> Result<()> {
+ let word = if daily {
+ raeapi::word_of_the_day(&ctx.http).context("no se pudo obtener la palabra del día")?
+ } else {
+ raeapi::random_word(&ctx.http).context("no se pudo obtener una palabra al azar")?
+ };
+ define(ctx, &word)
+}
+
+pub fn search(ctx: &Ctx, text: &str, max: usize) -> Result<()> {
+ let Some(cache) = &ctx.cache else {
+ bail!("`buscar` mira dentro de la caché, y está desactivada con --sin-cache");
+ };
+ let hits = cache.search(text, max)?;
+ if hits.is_empty() {
+ println!(
+ "{}",
+ ctx.style.dim(&format!(
+ "nada con «{text}» entre las {} palabras guardadas",
+ cache.count()?
+ ))
+ );
+ return Ok(());
+ }
+
+ if ctx.json {
+ let words: Vec<&str> = hits.iter().map(|(w, _)| w.as_str()).collect();
+ return ctx.print_json(&words);
+ }
+
+ let needle = suggest::fold(text);
+ for (word, json) in &hits {
+ let entry: Entry = match serde_json::from_str(json) {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
+ // Show the sense where the searched text appears.
+ let sense = entry
+ .senses
+ .iter()
+ .find(|s| suggest::fold(&s.text).contains(&needle))
+ .or_else(|| entry.senses.first());
+ let gloss = sense
+ .map(|s| {
+ let text = output::superscript_homographs(&s.text);
+ output::truncate(&text, ctx.style.width.saturating_sub(word.len() + 4))
+ })
+ .unwrap_or_default();
+ println!("{} {}", ctx.style.cyan(word), ctx.style.dim(&gloss));
+ }
+ Ok(())
+}
+
+pub fn cache_info(ctx: &Ctx) -> Result<()> {
+ let Some(cache) = &ctx.cache else {
+ bail!("la caché está desactivada con --sin-cache");
+ };
+ println!("entradas: {}", cache.count()?);
+ println!("archivo: {}", ctx.cfg.cache.display());
+ let dict = ctx.cfg.dictionary.clone().or_else(suggest::find_dictionary);
+ println!(
+ "sugerencias: {}",
+ dict.map(|p| p.display().to_string())
+ .unwrap_or_else(|| "sin diccionario de hunspell instalado".into())
+ );
+ Ok(())
+}
+
+pub fn cache_list(ctx: &Ctx, max: usize) -> Result<()> {
+ let Some(cache) = &ctx.cache else {
+ bail!("la caché está desactivada con --sin-cache");
+ };
+ let rows = cache.list(max)?;
+ if rows.is_empty() {
+ println!("{}", ctx.style.dim("la caché está vacía"));
+ return Ok(());
+ }
+ for (word, source, ts) in rows {
+ let meta = format!("{} {}", crate::cache::format_date(ts), source);
+ println!("{:24} {}", word, ctx.style.dim(&meta));
+ }
+ Ok(())
+}
+
+pub fn cache_clear(ctx: &Ctx) -> Result<()> {
+ let Some(cache) = &ctx.cache else {
+ bail!("la caché está desactivada con --sin-cache");
+ };
+ let n = cache.clear()?;
+ println!("borradas {n} entradas");
+ Ok(())
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..4e81bbe
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,134 @@
+//! Optional configuration in `~/.config/drae/config.toml`.
+//!
+//! Everything has a sensible default: the file is only needed to change
+//! something. Command-line flags always win over it. The keys are in Spanish
+//! because the users are Spanish speakers.
+//!
+//! ```toml
+//! fuente = "auto" # auto | dle | rae-api
+//! timeout = 15
+//! # cache = "~/.cache/drae/entries.db"
+//! # diccionario = "/usr/share/hunspell/es_AR.dic"
+//! ```
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+
+use crate::cache::Cache;
+
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ConfigFile {
+ /// Where to read from: `auto` (rae-api with fallback), `dle` or `rae-api`.
+ #[serde(rename = "fuente")]
+ pub source: Option<String>,
+ /// Seconds to wait for each request.
+ pub timeout: Option<u64>,
+ /// Cache database.
+ pub cache: Option<String>,
+ /// Hunspell `.dic` used for suggestions.
+ #[serde(rename = "diccionario")]
+ pub dictionary: Option<String>,
+ /// Fixed output width; defaults to the terminal width.
+ #[serde(rename = "ancho")]
+ pub width: Option<usize>,
+}
+
+#[derive(Debug, Clone)]
+pub struct Config {
+ pub source: String,
+ pub timeout: u64,
+ pub cache: PathBuf,
+ pub dictionary: Option<PathBuf>,
+ pub width: Option<usize>,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Config {
+ source: "auto".into(),
+ timeout: 15,
+ cache: Cache::default_path(),
+ dictionary: None,
+ width: None,
+ }
+ }
+}
+
+pub fn default_path() -> PathBuf {
+ let base = std::env::var_os("XDG_CONFIG_HOME")
+ .map(PathBuf::from)
+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
+ .unwrap_or_else(|| PathBuf::from(".config"));
+ base.join("drae").join("config.toml")
+}
+
+/// Loads the file if it exists. A missing file is not an error.
+pub fn load(explicit: Option<&Path>) -> Result<Config> {
+ let path = explicit.map(PathBuf::from).unwrap_or_else(default_path);
+ let mut cfg = Config::default();
+ let Ok(raw) = std::fs::read_to_string(&path) else {
+ return Ok(cfg);
+ };
+ let file: ConfigFile = toml::from_str(&raw)
+ .with_context(|| format!("no se pudo leer la configuración {}", path.display()))?;
+
+ if let Some(s) = file.source {
+ cfg.source = s;
+ }
+ if let Some(t) = file.timeout {
+ cfg.timeout = t;
+ }
+ if let Some(c) = file.cache {
+ cfg.cache = expand_tilde(&c);
+ }
+ if let Some(d) = file.dictionary {
+ cfg.dictionary = Some(expand_tilde(&d));
+ }
+ cfg.width = file.width.or(cfg.width);
+ Ok(cfg)
+}
+
+fn expand_tilde(s: &str) -> PathBuf {
+ match s.strip_prefix("~/") {
+ Some(rest) => match std::env::var_os("HOME") {
+ Some(home) => PathBuf::from(home).join(rest),
+ None => PathBuf::from(s),
+ },
+ None => PathBuf::from(s),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn missing_file_yields_defaults() {
+ let cfg = load(Some(Path::new("/does/not/exist/config.toml"))).unwrap();
+ assert_eq!(cfg.source, "auto");
+ assert_eq!(cfg.timeout, 15);
+ }
+
+ #[test]
+ fn parses_a_config_file() {
+ let dir = std::env::temp_dir().join(format!("drae-cfg-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let p = dir.join("config.toml");
+ std::fs::write(&p, "fuente = \"dle\"\ntimeout = 30\nancho = 72\n").unwrap();
+ let cfg = load(Some(&p)).unwrap();
+ assert_eq!(cfg.source, "dle");
+ assert_eq!(cfg.timeout, 30);
+ assert_eq!(cfg.width, Some(72));
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn expands_a_leading_tilde() {
+ std::env::set_var("HOME", "/home/test");
+ assert_eq!(expand_tilde("~/x/y.db"), PathBuf::from("/home/test/x/y.db"));
+ assert_eq!(expand_tilde("/abs/x.db"), PathBuf::from("/abs/x.db"));
+ }
+}
diff --git a/src/dle.rs b/src/dle.rs
new file mode 100644
index 0000000..ed3d0e5
--- /dev/null
+++ b/src/dle.rs
@@ -0,0 +1,309 @@
+//! Reader for the Diccionario de la lengua española (dle.rae.es).
+//!
+//! Each word page is laid out like this:
+//!
+//! ```text
+//! <article>
+//! <h1 class="c-page-header__title">hablar</h1>
+//! <div class="n2 c-text-intro">Del lat. fabulāri.</div> ← etymology
+//! <ol class="c-definitions"> <li class="j"> … </li> … </ol> ← senses
+//! <h3>ni hablar</h3> <ol class="c-definitions"> … </ol> ← set phrases
+//! ```
+//!
+//! Inside each sense, synonyms live in a nested list, so cutting uses
+//! [`html::balanced`] instead of looking for the next `</li>`.
+
+use crate::html::{attr, balanced, collapse_ws, find_open, open_tag, strip_tags};
+use crate::model::{Abbr, Entry, Locution, Sense};
+
+const CLASS_DEFS: &str = r#"class="c-definitions""#;
+
+/// `true` if the DLE answered with its "word not found" page. It returns 200
+/// just like a hit, so the text has to be checked.
+pub fn is_not_found(html: &str) -> bool {
+ html.contains("no está en el Diccionario") || html.contains("no est&#xE1; en el Diccionario")
+}
+
+/// Extracts the entry from a DLE page. `None` if the word is not there.
+pub fn parse(html: &str) -> Option<Entry> {
+ if is_not_found(html) {
+ return None;
+ }
+ let art_start = find_open(html, 0, "article", Some("o-main__article"))?;
+ // The page never closes `<article>`, so the balanced cut would also take
+ // the footer. Cut where the page footer starts.
+ let article = balanced(html, art_start, "article");
+ let article = match article.find("<footer") {
+ Some(f) => &article[..f],
+ None => article,
+ };
+
+ let word = text_of(article, "h1", Some("c-page-header__title"))?;
+
+ // The conjugation and the final synonym lists are separate sections;
+ // the definitions are everything before them.
+ let conj_at = article.find(r#"id="conjugacion"#);
+ let defs_region = &article[..conj_at.unwrap_or(article.len())];
+
+ let etymology = find_open(defs_region, 0, "div", Some("c-text-intro"))
+ .map(|i| strip_tags(balanced(defs_regi