#[derive(Debug, Clone, Default)]
pub struct MessageArgs<'a> {
pub count: Option<u64>,
pub value: Option<&'a str>,
pub gender: Option<&'a str>,
pub names: Option<&'a str>,
pub start: Option<&'a str>,
pub end: Option<&'a str>,
pub url: Option<&'a str>,
pub date: Option<&'a str>,
pub year: Option<&'a str>,
pub month: Option<&'a str>,
pub day: Option<&'a str>,
pub main_list: Option<&'a str>,
}
pub trait MessageEvaluator: Send + Sync {
fn evaluate(&self, message: &str, args: &MessageArgs<'_>) -> Option<String>;
}
#[derive(Debug, Clone)]
pub struct Mf2MessageEvaluator;
impl MessageEvaluator for Mf2MessageEvaluator {
fn evaluate(&self, message: &str, args: &MessageArgs<'_>) -> Option<String> {
let trimmed = message.trim();
if trimmed.starts_with(".match") {
evaluate_mf2_matcher(trimmed, args)
} else {
substitute_mf2_vars(trimmed, args)
}
}
}
fn substitute_mf2_vars(pattern: &str, args: &MessageArgs<'_>) -> Option<String> {
if !pattern.contains('{') {
return Some(pattern.to_string());
}
let mut result = String::new();
let mut cursor = 0usize;
while let Some(offset) = pattern.get(cursor..).and_then(|s| s.find('{')) {
let open = cursor + offset;
#[allow(
clippy::string_slice,
reason = "cursor and open are valid char boundaries"
)]
result.push_str(&pattern[cursor..open]);
let close = find_matching_brace(pattern, open)?;
let inner = pattern.get(open + 1..close)?.trim();
if !inner.starts_with('$') {
return None;
}
#[allow(clippy::string_slice, reason = "inner starts with '$' (1-byte ASCII)")]
let var_name = &inner[1..];
let var_value = resolve_var(var_name, args)?;
result.push_str(var_value);
cursor = close + 1;
}
#[allow(clippy::string_slice, reason = "cursor is a valid char boundary")]
result.push_str(&pattern[cursor..]);
Some(result)
}
fn resolve_var<'a>(var_name: &str, args: &'a MessageArgs<'a>) -> Option<&'a str> {
match var_name {
"value" => args.value,
"gender" => args.gender,
"names" => args.names,
"start" => args.start,
"end" => args.end,
"url" => args.url,
"date" => args.date,
"year" => args.year,
"month" => args.month,
"day" => args.day,
"main_list" => args.main_list,
_ => None,
}
}
fn evaluate_mf2_matcher(message: &str, args: &MessageArgs<'_>) -> Option<String> {
let trimmed = message.trim();
if !trimmed.starts_with(".match") {
return None;
}
let (selectors, variants_start) = parse_mf2_selectors(trimmed)?;
let match_keys = selectors
.iter()
.map(|(var_name, function)| determine_match_key(var_name, *function, args))
.collect::<Option<Vec<_>>>()?;
let matched_pattern = find_mf2_variant(variants_start, &match_keys)?;
substitute_mf2_vars(&matched_pattern, args)
}
fn parse_mf2_selectors(message: &str) -> Option<(Vec<(&str, Option<&str>)>, &str)> {
#[allow(clippy::string_slice, reason = "'.match' is 1-byte ASCII")]
let mut rest = message[".match".len()..].trim_start();
let mut selectors = Vec::new();
while rest.starts_with('{') {
let close_brace = find_matching_brace(rest, 0)?;
let selector_text = rest.get(1..close_brace)?.trim();
selectors.push(parse_mf2_selector(selector_text)?);
rest = rest.get(close_brace + 1..)?.trim_start();
}
if selectors.is_empty() {
return None;
}
Some((selectors, rest))
}
fn parse_mf2_selector(selector: &str) -> Option<(&str, Option<&str>)> {
let parts: Vec<&str> = selector.split_whitespace().collect();
let var_name = parts.first()?.strip_prefix('$')?;
let function = parts
.get(1)
.and_then(|func_part| func_part.strip_prefix(':'));
Some((var_name, function))
}
fn determine_match_key(
var_name: &str,
function: Option<&str>,
args: &MessageArgs<'_>,
) -> Option<String> {
match function {
Some("plural") => {
if var_name != "count" {
return None;
}
let count = args.count?;
if count == 1 {
Some("one".to_string())
} else {
Some("*".to_string())
}
}
Some("select") | None => {
let value = match var_name {
"count" => args.count.map(|c| c.to_string()),
"value" => args.value.map(|s| s.to_string()),
"gender" => args.gender.map(|s| s.to_string()),
"names" => args.names.map(|s| s.to_string()),
"start" => args.start.map(|s| s.to_string()),
"end" => args.end.map(|s| s.to_string()),
"url" => args.url.map(|s| s.to_string()),
"date" => args.date.map(|s| s.to_string()),
"year" => args.year.map(|s| s.to_string()),
"month" => args.month.map(|s| s.to_string()),
"day" => args.day.map(|s| s.to_string()),
"main_list" => args.main_list.map(|s| s.to_string()),
_ => None,
}?;
Some(value)
}
_ => None,
}
}
fn find_mf2_variant(variants_text: &str, match_keys: &[String]) -> Option<String> {
let mut best_match: Option<(usize, String)> = None;
let mut rest = variants_text;
loop {
let trimmed = rest.trim_start();
if trimmed.is_empty() {
break;
}
if !trimmed.starts_with("when") {
break;
}
#[allow(clippy::string_slice, reason = "'when' is 1-byte ASCII")]
let after_when = trimmed["when".len()..].trim_start();
let brace_pos = after_when.find('{')?;
#[allow(clippy::string_slice, reason = "brace_pos is found via find('{')")]
let key_str = after_when[..brace_pos].trim();
let variant_keys: Vec<&str> = key_str.split_whitespace().collect();
let open_brace_index = rest.len() - after_when.len() + brace_pos;
let close_brace_index = find_matching_brace(rest, open_brace_index)?;
let pattern = rest
.get(open_brace_index + 1..close_brace_index)?
.to_string();
if let Some(score) = variant_match_score(&variant_keys, match_keys)
&& match best_match.as_ref() {
Some((best_score, _)) => score > *best_score,
None => true,
}
{
best_match = Some((score, pattern));
}
rest = rest.get(close_brace_index + 1..)?;
}
best_match.map(|(_, pattern)| pattern)
}
fn variant_match_score(variant_keys: &[&str], match_keys: &[String]) -> Option<usize> {
if variant_keys.len() != match_keys.len() {
return None;
}
let mut score = 0usize;
for (variant_key, match_key) in variant_keys.iter().zip(match_keys) {
if *variant_key == "*" {
continue;
}
if *variant_key != match_key {
return None;
}
score += 1;
}
Some(score)
}
fn find_matching_brace(input: &str, open_index: usize) -> Option<usize> {
let mut depth = 0usize;
for (index, ch) in input
.char_indices()
.skip_while(|(index, _)| *index < open_index)
{
match ch {
'{' => depth += 1,
'}' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(index);
}
}
_ => {}
}
}
None
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
#[test]
fn test_static_message() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs::default();
let result = evaluator.evaluate("and", &args);
assert_eq!(result, Some("and".to_string()));
}
#[test]
fn test_simple_variable() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
value: Some("Smith"),
..Default::default()
};
let result = evaluator.evaluate("retrieved from {$url}", &args);
assert_eq!(result, None);
let args = MessageArgs {
url: Some("https://example.com"),
..Default::default()
};
let result = evaluator.evaluate("retrieved from {$url}", &args);
assert_eq!(
result,
Some("retrieved from https://example.com".to_string())
);
}
#[test]
fn test_plural_one() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
count: Some(1),
..Default::default()
};
let message = ".match {$count :plural}\nwhen one {p.}\nwhen * {pp.}";
assert_eq!(evaluator.evaluate(message, &args), Some("p.".to_string()));
}
#[test]
fn test_plural_other() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
count: Some(5),
..Default::default()
};
let message = ".match {$count :plural}\nwhen one {p.}\nwhen * {pp.}";
assert_eq!(evaluator.evaluate(message, &args), Some("pp.".to_string()));
}
#[test]
fn test_select() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
gender: Some("masc"),
..Default::default()
};
let message = ".match {$gender :select}\nwhen masc {él}\nwhen fem {ella}\nwhen * {elle}";
assert_eq!(evaluator.evaluate(message, &args), Some("él".to_string()));
}
#[test]
fn test_select_fallback_wildcard() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
gender: Some("neuter"),
..Default::default()
};
let message = ".match {$gender :select}\nwhen masc {él}\nwhen fem {ella}\nwhen * {elle}";
assert_eq!(evaluator.evaluate(message, &args), Some("elle".to_string()));
}
#[test]
fn test_mixed_text_and_variable() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
url: Some("https://example.com"),
..Default::default()
};
let result = evaluator.evaluate("retrieved from {$url}", &args);
assert_eq!(
result,
Some("retrieved from https://example.com".to_string())
);
}
#[test]
fn test_date_component_substitution() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
year: Some("2023"),
month: Some("urtarrila"),
day: Some("12"),
..Default::default()
};
assert_eq!(
evaluator.evaluate("{$year}ko {$month}ren {$day}a", &args),
Some("2023ko urtarrilaren 12a".to_string())
);
assert_eq!(
evaluator.evaluate("{$month} {$day}", &args),
Some("urtarrila 12".to_string())
);
}
#[test]
fn test_date_component_missing_returns_none() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
year: Some("2023"),
month: Some("urtarrila"),
..Default::default()
};
assert_eq!(
evaluator.evaluate("{$year}ko {$month}ren {$day}a", &args),
None
);
}
#[test]
fn test_missing_variable_plural() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs::default();
let message = ".match {$count :plural}\nwhen one {p.}\nwhen * {pp.}";
let result = evaluator.evaluate(message, &args);
assert_eq!(result, None);
}
#[test]
fn test_multi_selector_exact_match() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
count: Some(1),
gender: Some("feminine"),
..Default::default()
};
let message = ".match {$gender :select} {$count :plural}\nwhen feminine one {editora}\nwhen feminine * {editoras}\nwhen * * {equipo editorial}";
assert_eq!(
evaluator.evaluate(message, &args),
Some("editora".to_string())
);
}
#[test]
fn test_multi_selector_partial_wildcard_match() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
count: Some(3),
gender: Some("feminine"),
..Default::default()
};
let message = ".match {$gender :select} {$count :plural}\nwhen feminine one {editora}\nwhen feminine * {editoras}\nwhen * * {equipo editorial}";
assert_eq!(
evaluator.evaluate(message, &args),
Some("editoras".to_string())
);
}
#[test]
fn test_multi_selector_full_wildcard_match() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
count: Some(2),
gender: Some("common"),
..Default::default()
};
let message = ".match {$gender :select} {$count :plural}\nwhen feminine one {editora}\nwhen feminine * {editoras}\nwhen * * {equipo editorial}";
assert_eq!(
evaluator.evaluate(message, &args),
Some("equipo editorial".to_string())
);
}
#[test]
fn test_multi_selector_missing_gender() {
let evaluator = Mf2MessageEvaluator;
let args = MessageArgs {
count: Some(1),
..Default::default()
};
let message = ".match {$gender :select} {$count :plural}\nwhen feminine one {editora}\nwhen * * {equipo editorial}";
assert_eq!(evaluator.evaluate(message, &args), None);
}
}