use super::Language;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArticleKind {
Word,
Elided,
Joined,
}
impl ArticleKind {
fn of(article: &str) -> Self {
match article.chars().next_back() {
Some('\'') => Self::Elided,
Some('-') => Self::Joined,
_ => Self::Word,
}
}
}
fn is_filing(c: char) -> bool {
c.is_alphabetic() || c.is_ascii_digit()
}
fn is_apostrophe(c: char) -> bool {
matches!(
c,
'\'' | '\u{2019}' | '\u{2018}' | '\u{2BC}' | '\u{60}' | '\u{B4}'
)
}
fn is_hyphen(c: char) -> bool {
matches!(c, '-' | '\u{2010}' | '\u{2011}')
}
fn char_matches(c: char, a: char) -> bool {
match a {
'\'' => is_apostrophe(c),
'-' => is_hyphen(c),
_ => {
let mut lower = c.to_lowercase();
lower.next() == Some(a) && lower.next().is_none()
}
}
}
fn match_article<'t>(s: &'t str, article: &str) -> Option<&'t str> {
let mut chars = s.chars();
for a in article.chars() {
if !char_matches(chars.next()?, a) {
return None;
}
}
let rest = chars.as_str();
let next = rest.chars().next();
let boundary = match ArticleKind::of(article) {
ArticleKind::Word => next.is_some_and(char::is_whitespace),
ArticleKind::Elided => true,
ArticleKind::Joined => next.is_some_and(|c| !c.is_whitespace()),
};
boundary.then_some(rest)
}
fn from_first_filing(s: &str) -> Option<&str> {
let rest = s.trim_start_matches(|c: char| !is_filing(c));
(!rest.is_empty()).then_some(rest)
}
fn strip_irish_prefix<'t>(article: &str, rest: &'t str) -> &'t str {
let (prefix, marks_bare_prefix): (char, fn(char) -> bool) = match article {
"an" => ('t', |c| c.is_uppercase() || c == 's'),
"na" => ('h', |c| {
c.is_uppercase()
|| matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'á' | 'é' | 'í' | 'ó' | 'ú')
}),
_ => return rest,
};
let mut chars = rest.chars();
let Some(first) = chars.next() else {
return rest;
};
if first.to_ascii_lowercase() != prefix {
return rest;
}
let after = chars.as_str();
let mut following = after.chars();
match following.next() {
Some(c) if is_hyphen(c) => from_first_filing(following.as_str()).unwrap_or(rest),
Some(c) if first == prefix && marks_bare_prefix(c) => after,
Some(_) | None => rest,
}
}
pub(super) fn strip(language: Language, title: &str) -> Option<&str> {
let articles = language.articles();
let mut candidate = title;
let mut chars = title.chars();
while let Some(c) = chars.next() {
for &article in articles {
if let Some(rest) = match_article(candidate, article) {
let filing = from_first_filing(rest)?;
return Some(if language == Language::Irish {
strip_irish_prefix(article, filing)
} else {
filing
});
}
}
if is_filing(c) {
break;
}
candidate = chars.as_str();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use Language as L;
fn title_case(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut done = false;
for c in s.chars() {
if !done && c.is_alphabetic() {
out.extend(c.to_uppercase());
done = true;
} else {
out.push(c);
}
}
out
}
fn cases(article: &str) -> [String; 3] {
[
article.to_owned(),
title_case(article),
article.to_uppercase(),
]
}
#[track_caller]
fn strips(language: Language, input: &str, want: &str) {
let got = language.strip_initial_article(input);
assert_eq!(got, Some(want), "{language} {input:?}");
if let Some(out) = got {
let range = input.as_bytes().as_ptr_range();
let out_range = out.as_bytes().as_ptr_range();
assert!(
range.start <= out_range.start && out_range.end <= range.end,
"{out:?} is not a subslice of {input:?}"
);
}
}
#[track_caller]
fn keeps(language: Language, input: &str) {
assert_eq!(
language.strip_initial_article(input),
None,
"{language} {input:?}"
);
}
#[test]
fn every_article_strips_per_kind() {
for &language in L::ALL {
for &article in language.articles() {
for art in cases(article) {
match ArticleKind::of(article) {
ArticleKind::Word => {
strips(language, &format!("{art} word"), "word");
keeps(language, &format!("{art}word"));
}
ArticleKind::Elided => {
strips(language, &format!("{art}word"), "word");
strips(language, &format!("{art} word"), "word");
}
ArticleKind::Joined => {
strips(language, &format!("{art}word"), "word");
keeps(language, &format!("{art} word"));
}
}
}
}
}
}
#[test]
fn excluded_forms_do_not_strip() {
let excluded: &[(Language, &[&str])] = &[
(L::English, &["ye", "d'", "de"]),
(L::Catalan, &["en", "uns", "unes"]),
(L::Romanian, &["unui", "unei", "a", "al", "ai", "ale"]),
(
L::German,
&[
"des", "dem", "den", "einer", "eines", "einem", "einen", "'s",
],
),
(
L::Icelandic,
&[
"hina", "hinum", "hinni", "hinu", "hins", "hinnar", "hinna", "'r",
],
),
(L::Occitan, &["ih'"]),
(L::Spanish, &["unos", "unas"]),
(L::Portuguese, &["uns", "umas", "à", "ao"]),
(L::Galician, &["uns", "unhas"]),
(L::Luxembourgish, &["dem", "der", "engem", "enger"]),
];
for &(language, forms) in excluded {
for form in forms {
for art in cases(form) {
keeps(language, &format!("{art} word"));
keeps(language, &format!("{art}word"));
}
}
}
}
#[test]
fn boundaries() {
keeps(L::English, "Theory");
keeps(L::English, "Anthem");
keeps(L::Spanish, "Elegía");
keeps(L::English, "A, B, C");
keeps(L::Spanish, "¿El…?");
for apostrophe in ['\'', '\u{2019}', '\u{2018}', '\u{2BC}', '\u{60}', '\u{B4}'] {
strips(L::French, &format!("L{apostrophe}amour"), "amour");
strips(L::French, &format!("L{apostrophe} amour"), "amour");
}
keeps(L::French, "L\"amour");
for hyphen in ['-', '\u{2010}', '\u{2011}'] {
strips(L::Maltese, &format!("il{hyphen}Kantilena"), "Kantilena");
}
keeps(L::Maltese, "il\u{2013}Kantilena"); keeps(L::Maltese, "il- Kantilena");
strips(L::Maltese, "IL-KANTILENA", "KANTILENA");
strips(L::Maltese, "il-\"Kantilena\"", "Kantilena\"");
strips(L::Maltese, "il-1", "1");
strips(L::Maltese, "ix-Xemx", "Xemx");
strips(L::Maltese, "l-Ilma", "Ilma");
keeps(L::Maltese, "il-");
for space in [' ', '\t', '\u{A0}', '\u{202F}', '\u{3000}'] {
strips(L::English, &format!("The{space}Bear"), "Bear");
}
}
#[test]
fn leading_and_following_punctuation() {
strips(
L::French,
"«\u{202F}Le Petit Prince\u{202F}»",
"Petit Prince\u{202F}»",
);
strips(L::Spanish, "¿El otoño?", "otoño?");
strips(L::English, "\"The Bear\"", "Bear\"");
strips(
L::English,
"[The part of Pennsylvania]",
"part of Pennsylvania]",
);
strips(L::English, " The Bear", "Bear");
strips(L::English, "* The Bear", "Bear");
strips(L::English, "½ The Bear", "Bear");
strips(L::Dutch, "'t Hooft", "Hooft");
strips(L::Dutch, "‘t Hooft", "Hooft");
strips(L::Dutch, "\"'t Hooft\"", "Hooft\"");
strips(L::Afrikaans, "«'n Mens»", "Mens»");
strips(L::Dutch, "‘De Avonden’", "Avonden’");
strips(L::English, "The \"Bear\"", "Bear\"");
strips(L::English, "The #1", "1");
strips(L::English, "The (2)", "2)");
strips(L::English, "The ½ Bear", "Bear");
keeps(L::English, "The ½");
keeps(L::English, "Bear the cat");
keeps(L::English, "Theory of the bear");
keeps(L::French, "Bonjour la vie");
keeps(L::English, "\"Bear\"");
keeps(L::English, "... as others see us");
}
#[test]
fn edge_cases() {
for title in ["The", "The ", "The !", "", " "] {
keeps(L::English, title);
}
keeps(L::French, "Les");
keeps(L::French, "L'");
keeps(L::French, "L' ");
strips(L::English, "The A Team", "A Team");
strips(L::English, "The The", "The");
strips(L::English, "A to Z drug facts", "to Z drug facts");
keeps(L::Spanish, "E\u{301}l libro");
keeps(L::Spanish, "La\u{301} casa");
strips(L::English, "The Cafe\u{301}", "Cafe\u{301}");
keeps(L::Italian, "İl libro");
strips(L::Luxembourgish, "Déi Geschicht", "Geschicht");
strips(L::Icelandic, "HIÐ ÍSLENZKA", "ÍSLENZKA");
strips(L::Maltese, "IŻ-ŻMIEN", "ŻMIEN");
keeps(L::Luxembourgish, "Dei Geschicht");
keeps(L::Portuguese, "À espera");
strips(L::English, "\u{FF11} The Bear", "Bear");
keeps(L::English, "1 The Bear");
keeps(L::English, "The\u{200B} Bear");
strips(L::English, "\u{200B}The Bear", "Bear");
let long = format!("The {}", "a".repeat(100_000));
assert_eq!(
L::English.strip_initial_article(&long).map(str::len),
Some(100_000)
);
let punctuation = "!".repeat(100_000);
keeps(L::English, &punctuation);
}
#[test]
fn irish() {
strips(L::Irish, "an t-uisce", "uisce");
strips(L::Irish, "An T-Uisce", "Uisce");
strips(L::Irish, "AN T-UISCE", "UISCE");
strips(L::Irish, "an t\u{2011}uisce", "uisce");
strips(L::Irish, "An tUasal", "Uasal");
strips(L::Irish, "an tsráid", "sráid");
strips(L::Irish, "an tSeapáin", "Seapáin");
strips(L::Irish, "an \"tUasal\"", "Uasal\"");
strips(L::Irish, "na hoíche", "oíche");
strips(L::Irish, "na h-oíche", "oíche");
strips(L::Irish, "na hÉireann", "Éireann");
strips(L::Irish, "na hÉ", "É");
strips(L::Irish, "na háite", "áite");
strips(L::Irish, "na heaglaise", "eaglaise");
strips(L::Irish, "na hiníne", "iníne");
strips(L::Irish, "na hóige", "óige");
strips(L::Irish, "na huaire", "uaire");
strips(L::Irish, "na hé", "é");
strips(L::Irish, "na hí", "í");
strips(L::Irish, "na húlla", "úlla");
strips(L::Irish, "an teach", "teach");
strips(L::Irish, "an tuairim", "tuairim");
strips(L::Irish, "na leabhair", "leabhair");
strips(L::Irish, "AN TÁIN", "TÁIN");
strips(L::Irish, "NA HOÍCHE", "HOÍCHE");
strips(L::Irish, "An tsunami", "sunami");
strips(L::Irish, "na hataí", "ataí");
strips(L::Irish, "an t-", "t-");
strips(L::Irish, "an t-!", "t-!");
strips(L::Irish, "na h", "h");
let with_an: Vec<Language> = L::ALL
.iter()
.copied()
.filter(|&language| language != L::Irish && language.articles().contains(&"an"))
.collect();
assert!(!with_an.is_empty());
for language in with_an {
strips(language, "An tUasal", "tUasal");
}
}
#[test]
fn languages_without_articles_strip_nothing() {
for language in [L::Latin, L::Albanian] {
for title in [
"Le Bear",
"The Bear",
"De bello Gallico",
"'t Hooft",
"Një natë",
"il-Kantilena",
] {
keeps(language, title);
}
}
}
#[test]
fn one_match_per_position() {
for &language in L::ALL {
for &short in language.articles() {
for &long in language.articles() {
if let Some(tail) = long.strip_prefix(short).filter(|t| !t.is_empty()) {
assert_eq!(
ArticleKind::of(short),
ArticleKind::Word,
"{language} {short}/{long}"
);
assert!(
!tail.starts_with(char::is_whitespace),
"{language} {short}/{long}"
);
}
}
}
}
}
}