//! Verb conjugation from the Spanish Wiktionary.
//!
//! The DLE publishes its own tables, but dle.rae.es answers non-browser
//! clients with a Cloudflare challenge, so the conjugation comes from here:
//! an API meant for programmatic use, with CC BY-SA content, which also
//! includes voseo («vos hablás») that the RAE tables leave out.
//!
//! The table is a regular 32-row grid:
//!
//! ```text
//!
Modo indicativo ← a mood starts
//! yo tú … ← pronouns (already in every cell)
//! Presente yo hablo … ← a tense
//! ```
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::client::{percent_encode, Http, CLIENT_UA};
use crate::html::{balanced, find_open, strip_tags};
use crate::model::{ConjGroup, Conjugation, Tense};
const API: &str = "https://es.wiktionary.org/w/api.php";
#[derive(Deserialize)]
struct Response {
parse: Option,
}
#[derive(Deserialize)]
struct Parse {
title: String,
text: String,
}
/// Downloads and builds the conjugation of a verb. `Ok(None)` if the page
/// does not exist or has no conjugation table (because it is not a verb).
pub fn conjugation(http: &Http, verb: &str) -> Result> {
let url = format!(
"{API}?action=parse&page={}&prop=text&format=json&formatversion=2",
percent_encode(verb)
);
let (status, body) = http.get(&url, CLIENT_UA)?;
if status != 200 {
anyhow::bail!("es.wiktionary.org respondió {status}");
}
let res: Response =
serde_json::from_str(&body).context("el Wikcionario devolvió un JSON inesperado")?;
// Without `parse` the page does not exist; the error comes in another field.
let Some(parse) = res.parse else {
return Ok(None);
};
// Only the Spanish section: Wiktionary puts every language that shares
// the spelling on one page, and several have their own table. «casa»,
// for one, is a verb in Romanian.
let section = spanish_section(&parse.text).unwrap_or(&parse.text);
Ok(parse_table(section).map(|groups| Conjugation {
title: format!("Conjugación de «{}»", parse.title),
groups,
}))
}
/// Cuts out the «Español» section, from its heading to the next language's.
fn spanish_section(html: &str) -> Option<&str> {
let mut start: Option = None;
let mut i = 0;
while let Some(at) = find_open(html, i, "h2", None) {
let el = balanced(html, at, "h2");
let title = strip_tags(el);
i = at + el.len();
match start {
Some(s) => return Some(&html[s..at]),
None if title == "Español" => start = Some(at),
None => {}
}
}
start.map(|s| &html[s..])
}
/// Finds the conjugation table and turns it into moods and tenses.
fn parse_table(html: &str) -> Option> {
let table = find_conjugation_table(html)?;
let mut groups: Vec = Vec::new();
let mut i = 0;
while let Some(at) = find_open(table, i, "tr", None) {
let tr = balanced(table, at, "tr");
i = at + tr.len();
let (headers, data) = (cells(tr, "th"), cells(tr, "td"));
// A row with a single wide header starts a mood.
if data.is_empty() && headers.len() == 1 {
let title = headers[0].clone();
if is_divider(&title) {
continue;
}
groups.push(ConjGroup {
title,
tenses: vec![],
});
continue;
}
// The pronoun row adds nothing: every cell already repeats them.
if data.is_empty() {
continue;
}
let Some(group) = groups.last_mut() else {
continue;
};
let Some((name, forms)) = headers.first().map(|n| (n.clone(), data)) else {
continue;
};
let forms: Vec = forms.into_iter().map(shorten_pronouns).collect();
if !name.is_empty() && !forms.is_empty() {
group.tenses.push(Tense { name, forms });
}
}
groups.retain(|g| !g.tenses.is_empty());
(!groups.is_empty()).then_some(groups)
}
/// The page has several tables (pronunciation, translations…); the
/// conjugation one is the one that lists the non-personal forms.
fn find_conjugation_table(html: &str) -> Option<&str> {
let mut i = 0;
while let Some(at) = find_open(html, i, "table", None) {
let table = balanced(html, at, "table");
if table.contains("Formas no personales") || table.contains("Modo indicativo") {
return Some(table);
}
i = at + table.len();
}
None
}
/// Rows that only separate blocks or explain the symbols.
fn is_divider(title: &str) -> bool {
title.is_empty() || title.starts_with("Leyenda") || title == "Formas personales"
}
/// Shortens long pronouns so the forms fit in the terminal.
fn shorten_pronouns(form: String) -> String {
const PAIRS: &[(&str, &str)] = &[
("que él, que ella, que usted", "que él/ella/ud."),
("que ustedes, que ellos", "que uds./ellos"),
("él, ella, usted", "él/ella/ud."),
("ustedes, ellos", "uds./ellos"),
];
let mut out = form;
for (long, short) in PAIRS {
if out.starts_with(long) {
out = format!("{short}{}", &out[long.len()..]);
break;
}
}
out
}
fn cells(tr: &str, tag: &str) -> Vec {
let mut out = Vec::new();
let mut i = 0;
while let Some(at) = find_open(tr, i, tag, None) {
let el = balanced(tr, at, tag);
out.push(strip_tags(el));
i = at + el.len();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const TABLE: &str = r#"
Formas no personales (verboides)
Infinitivo hablar haber hablado
Formas personales
Modo indicativo
yo tú vos
Presente yo hablo tú hablas vos hablás
Leyenda: † arcaico
"#;
#[test]
fn builds_groups_and_tenses() {
let g = parse_table(TABLE).expect("should find the table");
assert_eq!(
g.len(),
2,
"moods: {:?}",
g.iter().map(|x| &x.title).collect::>()
);
assert_eq!(g[0].title, "Formas no personales (verboides)");
assert_eq!(g[0].tenses[0].name, "Infinitivo");
assert_eq!(g[0].tenses[0].forms, ["hablar", "haber hablado"]);
assert_eq!(g[1].title, "Modo indicativo");
assert_eq!(
g[1].tenses[0].forms,
["yo hablo", "tú hablas", "vos hablás"]
);
}
#[test]
fn skips_dividers_pronoun_row_and_legend() {
let g = parse_table(TABLE).unwrap();
let titles: Vec<&str> = g.iter().map(|x| x.title.as_str()).collect();
assert!(!titles.contains(&"Formas personales"));
assert!(!titles.iter().any(|t| t.starts_with("Leyenda")));
// The pronoun row must not become a tense.
assert!(g[1].tenses.iter().all(|t| !t.name.is_empty()));
assert_eq!(g[1].tenses.len(), 1);
}
#[test]
fn takes_only_the_spanish_section() {
// A page with the conjugation in another language, like «casa», a
// noun in Spanish and a verb in Romanian.
let page =
format!("Español sustantivo
Rumano {TABLE}Véneto ");
let es = spanish_section(&page).unwrap();
assert!(es.contains("sustantivo"));
assert!(
!es.contains("Modo indicativo"),
"another language leaked in"
);
assert!(parse_table(es).is_none());
}
#[test]
fn keeps_the_spanish_table_when_it_is_the_one() {
let page = format!("Español {TABLE}Rumano otra cosa
");
let es = spanish_section(&page).unwrap();
assert!(parse_table(es).is_some());
assert!(!es.contains("otra cosa"));
}
#[test]
fn spanish_section_runs_to_the_end_when_it_is_the_only_language() {
let page = format!("Español {TABLE}");
assert!(parse_table(spanish_section(&page).unwrap()).is_some());
}
#[test]
fn returns_none_when_the_page_has_no_spanish_section() {
assert!(spanish_section("Rumano x
").is_none());
}
#[test]
fn returns_none_when_there_is_no_conjugation_table() {
assert!(parse_table("").is_none());
}
#[test]
fn shortens_long_pronouns() {
assert_eq!(
shorten_pronouns("él, ella, usted habla".into()),
"él/ella/ud. habla"
);
assert_eq!(
shorten_pronouns("que ustedes, que ellos hablen".into()),
"que uds./ellos hablen"
);
assert_eq!(shorten_pronouns("yo hablo".into()), "yo hablo");
}
}