use std::collections::BTreeSet;
use super::fold_search_term;
const JOINERS: &[char] = &['-', '_', '/', '.', ':'];
const EMPHASIS_RUN: usize = 3;
pub(crate) fn dropped_identifiers(text: &str, rendering: &str) -> Vec<String> {
let normalized = |text: &str| {
super::date_tokens::canonical_dates(
fidelity_tokens(text)
.into_iter()
.map(fold_search_term)
.collect(),
)
};
let literal_ids = identifiers_in(&fidelity_tokens(text));
let source = normalized(text);
let carried = normalized(rendering)
.into_iter()
.flat_map(carried_forms)
.collect::<BTreeSet<_>>();
source
.into_iter()
.filter(|token| {
literal_ids.contains(token)
|| token.bytes().any(|b| b.is_ascii_digit()) && is_identifier(token, false)
})
.filter(|identifier| {
!carried.contains(identifier)
&& (!identifier.starts_with("--")
|| !carried.iter().any(|candidate| {
super::date_tokens::carries_partial_date(identifier, candidate)
}))
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn fidelity_tokens(text: &str) -> Vec<&str> {
text.split_whitespace().map(fidelity_token).collect()
}
fn fidelity_token(token: &str) -> &str {
let token = trim_edge_punctuation(token);
["'s", "’s", "'S", "’S"]
.into_iter()
.find_map(|suffix| token.strip_suffix(suffix))
.filter(|base| is_identifier(base, false))
.unwrap_or(token)
}
pub fn identifiers(text: &str) -> BTreeSet<String> {
identifiers_in(
&text
.split_whitespace()
.map(trim_edge_punctuation)
.collect::<Vec<_>>(),
)
}
fn identifiers_in(tokens: &[&str]) -> BTreeSet<String> {
tokens
.iter()
.zip(emphasis_flags(tokens))
.filter(|(token, emphasis)| is_identifier(token, *emphasis))
.map(|(token, _)| fold_search_term(token))
.collect()
}
fn emphasis_flags(tokens: &[&str]) -> Vec<bool> {
let shouted = tokens
.iter()
.map(|token| is_shouted(token))
.collect::<Vec<_>>();
let mut flags = vec![false; tokens.len()];
let mut start = 0;
while start < shouted.len() {
if !shouted[start] {
start += 1;
continue;
}
let end = shouted[start..]
.iter()
.position(|shouted| !shouted)
.map_or(shouted.len(), |offset| start + offset);
if end - start >= EMPHASIS_RUN {
flags[start..end].fill(true);
}
start = end;
}
flags
}
fn is_shouted(token: &str) -> bool {
token.chars().count() >= 2
&& token
.chars()
.all(|character| character.is_alphabetic() && character.is_uppercase())
}
fn is_identifier(token: &str, emphasis: bool) -> bool {
if token.chars().count() < 2 {
return false;
}
let has_digit = token.chars().any(|character| character.is_ascii_digit());
let is_tagged = token.starts_with('#') || token.starts_with('@');
let is_acronym = !emphasis
&& token
.chars()
.all(|character| character.is_ascii_uppercase())
&& !is_shouted_function_word(token);
has_digit || is_tagged || is_acronym || is_compound(token)
}
fn is_shouted_function_word(token: &str) -> bool {
super::LanguageVocabulary::shipped().is_function_word(&fold_search_term(token))
}
fn is_compound(token: &str) -> bool {
let inner = token.trim_matches(JOINERS);
let Some(runs) = compound_runs(inner) else {
return false;
};
!is_slashed_word_pair(inner, &runs)
}
fn compound_runs(inner: &str) -> Option<Vec<&str>> {
if !inner.contains(JOINERS) {
return None;
}
let runs = inner.split(JOINERS).collect::<Vec<_>>();
(runs.len() >= 2
&& runs.iter().all(|run| {
run.chars().count() >= 2 && run.chars().all(|character| character.is_alphanumeric())
}))
.then_some(runs)
}
fn is_slashed_word_pair(inner: &str, runs: &[&str]) -> bool {
runs.len() == 2
&& inner.contains('/')
&& runs.iter().all(|run| {
run.chars().all(char::is_alphabetic)
&& !run.chars().all(|character| character.is_ascii_uppercase())
})
}
fn carried_forms(token: String) -> Vec<String> {
let mut forms = Vec::with_capacity(3);
if let Some(number) = unit_adjective_number(&token) {
forms.push(number.to_string());
}
if let Some(spelling) = other_decimal_separator(&token) {
forms.push(spelling);
}
forms.push(token);
forms
}
const THOUSANDS_GROUP: usize = 3;
fn other_decimal_separator(token: &str) -> Option<String> {
let number = token.strip_prefix(['-', '+', '−']).unwrap_or(token);
let sign = &token[..token.len() - number.len()];
let (whole, fraction, separator) = match number.split_once(',') {
Some((whole, fraction)) => (whole, fraction, '.'),
None => {
let (whole, fraction) = number.split_once('.')?;
(whole, fraction, ',')
}
};
let digits = |run: &str| !run.is_empty() && run.bytes().all(|byte| byte.is_ascii_digit());
if !digits(whole) || !digits(fraction) || fraction.len() == THOUSANDS_GROUP {
return None;
}
Some(format!("{sign}{whole}{separator}{fraction}"))
}
fn unit_adjective_number(token: &str) -> Option<&str> {
let (number, unit) = token.split_once('-')?;
let counted = number.len() >= 2 && number.bytes().all(|byte| byte.is_ascii_digit());
let unit_word = unit.chars().count() >= 2 && unit.chars().all(char::is_alphabetic);
(counted && unit_word).then_some(number)
}
fn trim_edge_punctuation(token: &str) -> &str {
const EDGE_PUNCTUATION: &[char] = &[
'.', ',', ';', ':', '!', '?', '¡', '¿', '(', ')', '[', ']', '{', '}', '"', '\'', '«', '»',
'“', '”', '‘', '’', '`',
];
token.trim_matches(EDGE_PUNCTUATION)
}
pub fn surface_tokens(text: &str) -> BTreeSet<String> {
text.split_whitespace()
.map(trim_edge_punctuation)
.filter(|token| !token.is_empty())
.map(fold_search_term)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifiers_are_the_tokens_a_translation_copies() {
let found = identifiers(
"Se adoptó Valkey 7.2 en la rama feat/valkey-store (#469, ADR-018); ver kmp-mcp.",
);
assert_eq!(
found.into_iter().collect::<Vec<_>>(),
["#469", "7.2", "adr-018", "feat/valkey-store", "kmp-mcp"]
);
}
#[test]
fn an_abbreviation_or_a_single_character_is_not_one() {
assert!(
identifiers("Los adaptadores (p.ej. el de Valkey) se registran al arrancar, a las 19.")
.contains("19")
);
assert!(!identifiers("Se apagó a las 9.").contains("9"));
assert!(
!identifiers("Los adaptadores, p.ej. el de Valkey, se registran.")
.iter()
.any(|id| id.contains("ej"))
);
assert!(identifiers("Un solo carácter: x, y o z.").is_empty());
}
#[test]
fn surface_tokens_drop_clause_punctuation_and_fold() {
let tokens = surface_tokens("The valve (#469) froze, ADR-018 says.");
assert!(tokens.contains("#469"));
assert!(tokens.contains("adr-018"));
assert!(tokens.contains("valve"));
}
}