From a946ba81f7a6089713337644053b00b825824a98 Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 19:00:45 -0300 Subject: drae: the RAE Spanish dictionary in the terminal --- src/cache.rs | 244 ++++++++++++++++++++++++++++++ src/cli.rs | 141 +++++++++++++++++ src/client.rs | 85 +++++++++++ src/commands.rs | 311 ++++++++++++++++++++++++++++++++++++++ src/config.rs | 134 +++++++++++++++++ src/dle.rs | 309 ++++++++++++++++++++++++++++++++++++++ src/html.rs | 235 +++++++++++++++++++++++++++++ src/main.rs | 96 ++++++++++++ src/model.rs | 150 +++++++++++++++++++ src/output.rs | 404 +++++++++++++++++++++++++++++++++++++++++++++++++ src/raeapi.rs | 441 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/suggest.rs | 221 +++++++++++++++++++++++++++ src/wiktionary.rs | 270 +++++++++++++++++++++++++++++++++ 13 files changed, 3041 insertions(+) create mode 100644 src/cache.rs create mode 100644 src/cli.rs create mode 100644 src/client.rs create mode 100644 src/commands.rs create mode 100644 src/config.rs create mode 100644 src/dle.rs create mode 100644 src/html.rs create mode 100644 src/main.rs create mode 100644 src/model.rs create mode 100644 src/output.rs create mode 100644 src/raeapi.rs create mode 100644 src/suggest.rs create mode 100644 src/wiktionary.rs (limited to 'src') 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 { + 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> { + let json: Option = 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> { + 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::>>()?; + Ok(rows) + } + + /// Searches for a text inside the definitions already downloaded. + pub fn search(&self, needle: &str, limit: usize) -> Result> { + 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::>>()?; + Ok(rows) + } + + pub fn count(&self) -> Result { + Ok(self + .conn + .query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?) + } + + pub fn clear(&self) -> Result { + 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 `) + #[arg(value_name = "PALABRA")] + pub word: Option, + + #[command(subcommand)] + pub cmd: Option, + + /// Archivo de configuración (por defecto ~/.config/drae/config.toml) + #[arg(long, global = true, value_name = "ARCHIVO")] + pub config: Option, + + /// 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, + + /// 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, +} + +#[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, + }, + + /// 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, + 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 { + 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> { + 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> { + 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 { + 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(&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, + antonyms: Vec, + } + 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, + /// Seconds to wait for each request. + pub timeout: Option, + /// Cache database. + pub cache: Option, + /// Hunspell `.dic` used for suggestions. + #[serde(rename = "diccionario")] + pub dictionary: Option, + /// Fixed output width; defaults to the terminal width. + #[serde(rename = "ancho")] + pub width: Option, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub source: String, + pub timeout: u64, + pub cache: PathBuf, + pub dictionary: Option, + pub width: Option, +} + +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 { + 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 +//!
+//!

hablar

+//!
Del lat. fabulāri.
← etymology +//!
  1. …
  2. …
← senses +//!

ni hablar

    …
← set phrases +//! ``` +//! +//! Inside each sense, synonyms live in a nested list, so cutting uses +//! [`html::balanced`] instead of looking for the next ``. + +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á en el Diccionario") +} + +/// Extracts the entry from a DLE page. `None` if the word is not there. +pub fn parse(html: &str) -> Option { + if is_not_found(html) { + return None; + } + let art_start = find_open(html, 0, "article", Some("o-main__article"))?; + // The page never closes `
`, 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(" &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_region, i, "div"))) + .filter(|s| !s.is_empty()); + + let mut senses = Vec::new(); + let mut locutions = Vec::new(); + let mut cursor = 0usize; + let mut first = true; + while let Some(ol_at) = find_open(defs_region, cursor, "ol", Some(CLASS_DEFS)) { + let ol = balanced(defs_region, ol_at, "ol"); + let parsed = parse_senses(ol); + if first { + senses = parsed; + first = false; + } else if let Some(phrase) = heading_before(defs_region, cursor, ol_at) { + locutions.push(Locution { + phrase, + senses: parsed, + }); + } + cursor = ol_at + ol.len(); + } + + Some(Entry { + word, + etymology, + senses, + locutions, + // The conjugation is fetched separately, from Wiktionary. + conjugation: None, + source: "dle".into(), + }) +} + +/// The `

` that titles a set phrase, searched between the previous block and this one. +fn heading_before(h: &str, from: usize, until: usize) -> Option { + let region = &h[from..until]; + let mut last = None; + let mut i = 0; + while let Some(at) = find_open(region, i, "h3", None) { + last = Some(strip_tags(balanced(region, at, "h3"))); + i = at + 3; + } + last.filter(|s| !s.is_empty()) +} + +fn text_of(h: &str, tag: &str, class: Option<&str>) -> Option { + let needle = class.map(|c| format!("class=\"{c}\"")); + let at = find_open(h, 0, tag, needle.as_deref())?; + let s = strip_tags(balanced(h, at, tag)); + (!s.is_empty()).then_some(s) +} + +fn parse_senses(ol: &str) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while let Some(at) = find_open(ol, i, "li", None) { + let li = balanced(ol, at, "li"); + // The dictionary marks the article senses with `class="j"` and the + // set-phrase senses with `class="m"`. Skipping the whole element + // avoids walking into the synonym list, which is also made of `
  • `. + let class = attr(open_tag(ol, at), "class").unwrap_or_default(); + if class.starts_with('j') || class.starts_with('m') { + if let Some(sense) = parse_sense(li) { + out.push(sense); + } + } + i = at + li.len(); + } + out +} + +fn parse_sense(li: &str) -> Option { + // The footer holds synonyms and antonyms; the body, the definition. + let footer_at = find_open(li, 0, "div", Some("c-definitions__item-footer")); + let body = &li[..footer_at.unwrap_or(li.len())]; + + let number = element_text(body, "span", "n_acep") + .map(|s| s.trim().trim_end_matches('.').to_string()) + .filter(|s| !s.is_empty()); + + let examples = all_element_texts(body, "span", "h"); + let grammar = abbreviations(body); + + // The text is the body minus the number and the examples. + let mut text_src = body.to_string(); + for (tag, class) in [("span", "n_acep"), ("span", "h")] { + text_src = remove_elements(&text_src, tag, class); + } + let text = collapse_ws(&strip_tags(&text_src)); + if text.is_empty() && examples.is_empty() { + return None; + } + + let (mut synonyms, mut antonyms) = (Vec::new(), Vec::new()); + if let Some(f) = footer_at { + let footer = balanced(li, f, "div"); + for (label, words) in word_lists(footer) { + if label.starts_with("Ant") { + antonyms.extend(words); + } else { + synonyms.extend(words); + } + } + } + + Some(Sense { + number, + grammar, + text, + examples, + synonyms, + antonyms, + }) +} + +/// The `Sin.:` / `Ant.:` lists in a sense footer, with their label. +fn word_lists(footer: &str) -> Vec<(String, Vec)> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(at) = find_open(footer, i, "div", Some(r#"class="c-word-list""#)) { + let block = balanced(footer, at, "div"); + // The label is told apart by the `title` of its ``, which says + // "Sinónimos o afines" or "Antónimos u opuestos". + let label = find_open(block, 0, "abbr", Some("sin-header-inline")) + .and_then(|a| attr(open_tag(block, a), "title")) + .or_else(|| element_text(block, "div", "c-word-list__label")) + .unwrap_or_default(); + let words: Vec = all_element_texts(block, "span", "sin") + .into_iter() + .map(|w| collapse_ws(w.trim_end_matches([',', '.']))) + .filter(|w| !w.is_empty()) + .collect(); + if !words.is_empty() { + out.push((label, words)); + } + i = at + block.len(); + } + out +} + +/// The abbreviations of a sense with their expanded form (`f.` → +/// «nombre femenino»), which the DLE keeps in the `title` attribute. +fn abbreviations(h: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while let Some(at) = find_open(h, i, "abbr", None) { + let el = balanced(h, at, "abbr"); + let short = strip_tags(el); + if let Some(long) = attr(open_tag(h, at), "title") { + if !short.is_empty() && !out.iter().any(|a| a.short == short) { + out.push(Abbr { short, long }); + } + } + i = at + el.len(); + } + out +} + +/// Text of the first ``. +fn element_text(h: &str, tag: &str, class: &str) -> Option { + let needle = format!("class=\"{class}\""); + let at = find_open(h, 0, tag, Some(&needle))?; + Some(strip_tags(balanced(h, at, tag))) +} + +/// Text of every ``, in order. +fn all_element_texts(h: &str, tag: &str, class: &str) -> Vec { + let needle = format!("class=\"{class}\""); + let mut out = Vec::new(); + let mut i = 0; + while let Some(at) = find_open(h, i, tag, Some(&needle)) { + let el = balanced(h, at, tag); + let t = strip_tags(el); + if !t.is_empty() { + out.push(t); + } + i = at + el.len(); + } + out +} + +/// Removes the given elements entirely, content included. +fn remove_elements(h: &str, tag: &str, class: &str) -> String { + let needle = format!("class=\"{class}\""); + let mut out = String::with_capacity(h.len()); + let mut i = 0; + while let Some(at) = find_open(h, i, tag, Some(&needle)) { + out.push_str(&h[i..at]); + // Leave a space so neighbouring words do not run together. + out.push(' '); + i = at + balanced(h, at, tag).len(); + } + out.push_str(&h[i..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real dle.rae.es page, trimmed to four senses and one set phrase. + /// The live path almost never answers (Cloudflare challenges anything + /// that is not a browser), so this fixture is what keeps the reader + /// verified. + const CASA: &str = include_str!("../tests/fixtures/dle-casa.html"); + + #[test] + fn reads_headword_and_etymology() { + let e = parse(CASA).expect("should parse the page"); + assert_eq!(e.word, "casa"); + assert_eq!(e.etymology.as_deref(), Some("Del lat. casa 'choza'.")); + assert_eq!(e.source, "dle"); + } + + #[test] + fn reads_senses_with_marks_examples_and_synonyms() { + let e = parse(CASA).unwrap(); + assert_eq!(e.senses.len(), 4, "senses: {}", e.senses.len()); + + let first = &e.senses[0]; + assert_eq!(first.number.as_deref(), Some("1")); + // The text keeps the grammar mark and the final dot, as the + // dictionary prints it. + assert_eq!(first.text, "f. Edificio para habitar."); + assert_eq!(first.examples, ["Una casa de ocho plantas."]); + assert!(first.synonyms.contains(&"vivienda".to_string())); + assert!(first.synonyms.contains(&"casalicio".to_string())); + // The example must not leak into the definition. + assert!(!first.text.contains("ocho plantas")); + // Nor the synonyms. + assert!(!first.text.contains("vivienda")); + } + + #[test] + fn expands_abbreviations_from_the_title_attribute() { + let e = parse(CASA).unwrap(); + let g = &e.senses[0].grammar; + assert!( + g.iter() + .any(|a| a.short == "f." && a.long == "nombre femenino"), + "abbreviations: {g:?}" + ); + } + + #[test] + fn reads_locutions() { + let e = parse(CASA).unwrap(); + assert_eq!(e.locutions.len(), 1, "set phrases: {:?}", e.locutions); + assert!(!e.locutions[0].phrase.is_empty()); + assert!(!e.locutions[0].senses.is_empty()); + } + + #[test] + fn detects_the_not_found_page() { + assert!(is_not_found( + "

    La palabra «asdfgh» no está en el Diccionario

    " + )); + assert!(!is_not_found(CASA)); + assert!(parse("

    no está en el Diccionario

    ").is_none()); + } +} diff --git a/src/html.rs b/src/html.rs new file mode 100644 index 0000000..1522610 --- /dev/null +++ b/src/html.rs @@ -0,0 +1,235 @@ +//! Minimal HTML utilities: entity decoding, tag stripping and element +//! extraction with depth counting. +//! +//! The DLE markup is regular, so a full parser would be overkill. But each +//! sense (`
  • `) contains its own synonym list +//! (`
    • …
    `), so cutting "up to the +//! next `
  • `" returns a truncated fragment. Hence [`balanced`], which +//! counts openings and closings. + +/// Decodes HTML entities: common named ones and numeric ones (`é`, `é`). +pub fn decode_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'&' { + // Copy the whole UTF-8 character, not a lone byte. + let ch = s[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + continue; + } + match s[i..].find(';').filter(|&e| e <= 12) { + Some(end) => { + let ent = &s[i + 1..i + end]; + match named_or_numeric(ent) { + Some(ch) => { + out.push(ch); + i += end + 1; + } + None => { + out.push('&'); + i += 1; + } + } + } + None => { + out.push('&'); + i += 1; + } + } + } + out +} + +fn named_or_numeric(ent: &str) -> Option { + if let Some(num) = ent.strip_prefix('#') { + let code = match num.strip_prefix('x').or_else(|| num.strip_prefix('X')) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => num.parse::().ok()?, + }; + return char::from_u32(code); + } + Some(match ent { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" | "#39" => '\'', + "nbsp" => ' ', + "iexcl" => '¡', + "iquest" => '¿', + "laquo" => '«', + "raquo" => '»', + "ldquo" => '“', + "rdquo" => '”', + "hellip" => '…', + "mdash" => '—', + "ndash" => '–', + "deg" => '°', + _ => return None, + }) +} + +/// Removes tags, decodes entities and normalizes whitespace. +pub fn strip_tags(s: &str) -> String { + let mut raw = String::with_capacity(s.len()); + let mut depth = 0usize; + for ch in s.chars() { + match ch { + '<' => depth += 1, + '>' => depth = depth.saturating_sub(1), + _ if depth == 0 => raw.push(ch), + _ => {} + } + } + collapse_ws(&decode_entities(&raw)) +} + +/// Collapses repeated whitespace and trims, without breaking the thin spaces +/// the DLE uses before punctuation. +pub fn collapse_ws(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut space = false; + for ch in s.chars() { + if ch.is_whitespace() { + space = !out.is_empty(); + } else { + if space { + out.push(' '); + } + space = false; + out.push(ch); + } + } + out +} + +/// Index of the next `) -> Option { + let pat = format!("<{tag}"); + let mut i = from; + while let Some(rel) = h[i..].find(&pat) { + let start = i + rel; + let after = start + pat.len(); + // Keep `' || c == '/'); + if ok { + let end = start + h[start..].find('>')? + 1; + match needle { + Some(n) if !h[start..end].contains(n) => i = end, + _ => return Some(start), + } + } else { + i = after; + } + } + None +} + +/// Returns the whole element starting at `start`, counting nested tags with +/// the same name. Includes the closing tag. +pub fn balanced<'a>(h: &'a str, start: usize, tag: &str) -> &'a str { + let open = format!("<{tag}"); + let close = format!(" { + depth += 1; + i = o + open.len(); + } + (_, Some(c)) => { + if depth == 0 { + break; + } + depth -= 1; + let end = h[c..].find('>').map(|e| c + e + 1).unwrap_or(h.len()); + if depth == 0 { + return &h[start..end]; + } + i = end; + } + (Some(o), None) => i = o + open.len(), + (None, None) => break, + } + } + &h[start..] +} + +/// Reads an attribute value inside an opening tag. +pub fn attr(open_tag: &str, name: &str) -> Option { + let pat = format!("{name}=\""); + let i = open_tag.find(&pat)? + pat.len(); + let j = open_tag[i..].find('"')? + i; + Some(decode_entities(&open_tag[i..j])) +} + +/// The opening tag that starts at `start` (`
  • `). +pub fn open_tag(h: &str, start: usize) -> &str { + match h[start..].find('>') { + Some(e) => &h[start..start + e + 1], + None => &h[start..], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_named_and_numeric_entities() { + assert_eq!(decode_entities("café"), "café"); + assert_eq!(decode_entities("año"), "año"); + assert_eq!(decode_entities("«hablar»"), "«hablar»"); + assert_eq!(decode_entities("A & B"), "A & B"); + } + + #[test] + fn leaves_lone_ampersands_alone() { + assert_eq!(decode_entities("tal & cual"), "tal & cual"); + assert_eq!(decode_entities("&noesunaentidad;"), "&noesunaentidad;"); + } + + #[test] + fn strips_tags_and_collapses_whitespace() { + let h = r#"
    1. f. Edificio.
    "#; + assert_eq!(strip_tags(h), "1. f. Edificio."); + } + + #[test] + fn balanced_handles_nested_same_tag() { + // Exactly the DLE case: the synonym list lives inside the sense, so + // there is an inner `
  • `. + let h = r#"
  • def
    • sinónimo
  • otra
  • "#; + let start = find_open(h, 0, "li", Some(r#"class="j""#)).unwrap(); + let el = balanced(h, start, "li"); + assert!(el.ends_with("")); + assert!(el.contains("sinónimo")); + assert!(!el.contains("otra"), "ran into the next sense: {el}"); + } + + #[test] + fn find_open_does_not_match_tag_prefixes() { + let h = r#"
  • ok
  • "#; + let start = find_open(h, 0, "li", None).unwrap(); + assert!(h[start..].starts_with(r#"
  • "#; + assert_eq!(attr(t, "title").as_deref(), Some("nombre femenino")); + assert_eq!(attr(t, "class").as_deref(), Some("d")); + assert_eq!(attr(t, "id"), None); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4830824 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,96 @@ +mod cache; +mod cli; +mod client; +mod commands; +mod config; +mod dle; +mod html; +mod model; +mod output; +mod raeapi; +mod suggest; +mod wiktionary; + +use anyhow::Result; +use clap::Parser; + +use cache::Cache; +use cli::{CacheCmd, Cli, Cmd, Color, Source}; +use client::Http; +use commands::Ctx; +use output::{ColorChoice, Style}; + +fn main() { + if let Err(e) = run() { + eprintln!("drae: {e:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let args = Cli::parse(); + let cfg = config::load(args.config.as_deref())?; + + let style = Style::new( + match args.color { + Color::Auto => ColorChoice::Auto, + Color::Always => ColorChoice::Always, + Color::Never => ColorChoice::Never, + }, + args.width.or(cfg.width), + ); + + let source = args.source.unwrap_or(match cfg.source.as_str() { + "dle" => Source::Dle, + "rae-api" => Source::RaeApi, + _ => Source::Auto, + }); + + // A cache that cannot be opened must not block lookups: carry on + // without it and warn on stderr. + let cache = if args.no_cache { + None + } else { + match Cache::open(&cfg.cache) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("drae: aviso, sin caché ({e})"); + None + } + } + }; + + let ctx = Ctx { + http: Http::new(cfg.timeout), + cfg, + cache, + style, + json: args.json, + expand_abbr: args.expand_abbr, + refresh: args.refresh, + source, + }; + + match (args.word, args.cmd) { + (Some(word), _) => commands::define(&ctx, &word), + (None, Some(cmd)) => match cmd { + Cmd::Define { word } => commands::define(&ctx, &word), + Cmd::Synonyms { word } => commands::synonyms(&ctx, &word), + Cmd::Conjugate { verb, mood } => commands::conjugate(&ctx, &verb, mood.as_deref()), + Cmd::WordOfTheDay => commands::featured(&ctx, true), + Cmd::Random => commands::featured(&ctx, false), + Cmd::Search { text, max } => commands::search(&ctx, &text, max), + Cmd::Cache { action } => match action { + CacheCmd::Info => commands::cache_info(&ctx), + CacheCmd::List { max } => commands::cache_list(&ctx, max), + CacheCmd::Clear => commands::cache_clear(&ctx), + }, + }, + (None, None) => { + use clap::CommandFactory; + Cli::command().print_help()?; + println!(); + Ok(()) + } + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..c6255e0 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,150 @@ +//! The data model shared by both sources (DLE and rae-api) and both output +//! formats (terminal and JSON). + +use serde::{Deserialize, Serialize}; + +/// A dictionary entry: everything known about a word. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entry { + /// Headword as the dictionary writes it (may carry a superscript). + pub word: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub etymology: Option, + pub senses: Vec, + /// Set phrases and expressions: «ni hablar», «vino de coco»… + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub locutions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub conjugation: Option, + /// Where it came from: `dle` or `rae-api`. + pub source: String, +} + +impl Entry { + /// Synonyms of all senses, without repeats and in order of appearance. + pub fn all_synonyms(&self) -> Vec { + dedup(self.senses.iter().flat_map(|s| s.synonyms.iter())) + } + + pub fn all_antonyms(&self) -> Vec { + dedup(self.senses.iter().flat_map(|s| s.antonyms.iter())) + } + + pub fn has_synonyms(&self) -> bool { + self.senses.iter().any(|s| !s.synonyms.is_empty()) + } + + pub fn has_antonyms(&self) -> bool { + self.senses.iter().any(|s| !s.antonyms.is_empty()) + } +} + +fn dedup<'a>(it: impl Iterator) -> Vec { + let mut out: Vec = Vec::new(); + for w in it { + if !out.iter().any(|e| e == w) { + out.push(w.clone()); + } + } + out +} + +/// A numbered sense. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Sense { + /// Sense number, without the dot: `"1"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub number: Option, + /// Grammar and usage marks (`f.`, `intr.`, `p. us.`) with their expanded + /// meaning, which the DLE keeps in the `title` attribute. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub grammar: Vec, + /// The definition text, without the number or the marks. + pub text: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub examples: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub synonyms: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub antonyms: Vec, +} + +/// A dictionary abbreviation together with its expanded form. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Abbr { + /// As printed: `f.` + pub short: String, + /// As the dictionary explains it: `nombre femenino` + pub long: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Locution { + pub phrase: String, + pub senses: Vec, +} + +/// The full conjugation of a verb, grouped the way the DLE groups it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Conjugation { + /// «Conjugación de "hablar"» + pub title: String, + pub groups: Vec, +} + +/// A verb mood: «Modo indicativo», «Formas no personales»… +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConjGroup { + pub title: String, + pub tenses: Vec, +} + +/// A verb tense with its forms, each one with its pronoun +/// («yo hablo», «vos hablás»). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tense { + pub name: String, + pub forms: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sense(syn: &[&str], ant: &[&str]) -> Sense { + Sense { + synonyms: syn.iter().map(|s| s.to_string()).collect(), + antonyms: ant.iter().map(|s| s.to_string()).collect(), + ..Default::default() + } + } + + fn entry(senses: Vec) -> Entry { + Entry { + word: "casa".into(), + etymology: None, + senses, + locutions: vec![], + conjugation: None, + source: "dle".into(), + } + } + + #[test] + fn collects_synonyms_across_senses_without_repeating() { + let e = entry(vec![ + sense(&["vivienda", "hogar"], &[]), + sense(&["hogar", "morada"], &[]), + ]); + assert_eq!(e.all_synonyms(), ["vivienda", "hogar", "morada"]); + assert!(e.has_synonyms()); + assert!(!e.has_antonyms()); + } + + #[test] + fn reports_no_synonyms_for_an_entry_without_them() { + let e = entry(vec![sense(&[], &[])]); + assert!(e.all_synonyms().is_empty()); + assert!(!e.has_synonyms()); + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..26c65d3 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,404 @@ +//! Terminal and JSON presentation. +//! +//! Color turns itself off when the output is not a terminal (a pipe, a file) +//! or when `NO_COLOR` is set, following . + +use std::io::IsTerminal; + +use crate::model::{Conjugation, Entry, Locution, Sense}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ColorChoice { + Auto, + Always, + Never, +} + +#[derive(Clone, Copy)] +pub struct Style { + color: bool, + pub width: usize, +} + +impl Style { + pub fn new(choice: ColorChoice, width: Option) -> Self { + let color = match choice { + ColorChoice::Always => true, + ColorChoice::Never => false, + ColorChoice::Auto => { + std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() + } + }; + Style { + color, + width: width.unwrap_or_else(detect_width).clamp(40, 100), + } + } + + fn paint(&self, code: &str, s: &str) -> String { + if self.color && !s.is_empty() { + format!("\x1b[{code}m{s}\x1b[0m") + } else { + s.to_string() + } + } + + pub fn bold(&self, s: &str) -> String { + self.paint("1", s) + } + pub fn dim(&self, s: &str) -> String { + self.paint("2", s) + } + pub fn italic(&self, s: &str) -> String { + self.paint("3", s) + } + pub fn cyan(&self, s: &str) -> String { + self.paint("36", s) + } + pub fn green(&self, s: &str) -> String { + self.paint("32", s) + } + pub fn red(&self, s: &str) -> String { + self.paint("31", s) + } + pub fn yellow(&self, s: &str) -> String { + self.paint("33", s) + } + pub fn heading(&self, s: &str) -> String { + self.paint("1;36", s) + } +} + +fn detect_width() -> usize { + if let Ok(cols) = std::env::var("COLUMNS") { + if let Ok(n) = cols.parse::() { + return n; + } + } + terminal_size::terminal_size() + .map(|(terminal_size::Width(w), _)| w as usize) + .unwrap_or(80) +} + +/// Wraps a paragraph to the given width. `first` indents the first line and +/// `rest` the following ones, so the text stays aligned under the sense +/// number instead of going back to the margin. +pub fn wrap(text: &str, width: usize, first: &str, rest: &str) -> String { + let usable = width.saturating_sub(rest.chars().count()).max(20); + let mut out = String::new(); + let mut line = String::new(); + let mut line_len = 0usize; + let mut started = false; + + for word in text.split_whitespace() { + let wlen = word.chars().count(); + if line_len > 0 && line_len + 1 + wlen > usable { + out.push_str(if started { rest } else { first }); + out.push_str(&line); + out.push('\n'); + started = true; + line.clear(); + line_len = 0; + } + if line_len > 0 { + line.push(' '); + line_len += 1; + } + line.push_str(word); + line_len += wlen; + } + if line_len > 0 { + out.push_str(if started { rest } else { first }); + out.push_str(&line); + } + out +} + +/// The full entry, as shown in the terminal. +pub fn entry(e: &Entry, st: &Style, show_abbr: bool) -> String { + let mut out = String::new(); + out.push_str(&st.heading(&e.word)); + out.push('\n'); + + if let Some(ety) = &e.etymology { + out.push_str(&st.dim(&wrap(ety, st.width, "", ""))); + out.push('\n'); + } + out.push('\n'); + + for s in &e.senses { + out.push_str(&sense(s, st, show_abbr)); + out.push('\n'); + } + + if !e.locutions.is_empty() { + out.push('\n'); + out.push_str(&st.heading("Locuciones y expresiones")); + out.push('\n'); + for l in &e.locutions { + out.push_str(&locution(l, st, show_abbr)); + } + } + + if let Some(c) = &e.conjugation { + out.push('\n'); + out.push_str(&conjugation(c, st)); + } + out +} + +/// A sense: number, definition, examples and synonyms. +pub fn sense(s: &Sense, st: &Style, show_abbr: bool) -> String { + let num = s.number.clone().unwrap_or_default(); + let label = if num.is_empty() { + " ".to_string() + } else { + format!("{num}.") + }; + let pad = " ".repeat(label.chars().count().max(2) + 1); + let first = format!("{} ", st.yellow(&label)); + + let mut out = wrap(&superscript_homographs(&s.text), st.width, &first, &pad); + out.push('\n'); + + for ex in &s.examples { + out.push_str(&st.italic(&st.dim(&wrap(ex, st.width, &pad, &pad)))); + out.push('\n'); + } + if !s.synonyms.is_empty() { + let joined = superscript_homographs(&s.synonyms.join(", ")); + let line = format!("{} {joined}", st.green("Sin.:")); + out.push_str(&wrap(&line, st.width, &pad, &pad)); + out.push('\n'); + } + if !s.antonyms.is_empty() { + let joined = superscript_homographs(&s.antonyms.join(", ")); + let line = format!("{} {joined}", st.red("Ant.:")); + out.push_str(&wrap(&line, st.width, &pad, &pad)); + out.push('\n'); + } + if show_abbr && !s.grammar.is_empty() { + let expanded: Vec = s + .grammar + .iter() + .map(|a| format!("{} = {}", a.short, a.long)) + .collect(); + let line = expanded.join("; "); + out.push_str(&st.dim(&wrap(&line, st.width, &pad, &pad))); + out.push('\n'); + } + out +} + +fn locution(l: &Locution, st: &Style, show_abbr: bool) -> String { + let mut out = format!("\n{}\n", st.bold(&l.phrase)); + for s in &l.senses { + out.push_str(&sense(s, st, show_abbr)); + } + out +} + +/// The conjugation, one mood per block and one tense per paragraph. +/// +/// A seven-person grid per tense does not fit an 80-column terminal once +/// compound tenses show up («yo habría hablado»), so the forms flow separated +/// by «·». Each form already carries its pronoun, so no column header is needed. +pub fn conjugation(c: &Conjugation, st: &Style) -> String { + let mut out = format!("{}\n", st.heading(&c.title)); + for g in &c.groups { + out.push_str(&format!("\n{}\n", st.bold(&g.title))); + for t in &g.tenses { + out.push_str(&format!(" {}\n", st.yellow(&t.name))); + let line = t.forms.join(" · "); + out.push_str(&wrap(&line, st.width, " ", " ")); +