use std::collections::{HashMap, HashSet};
use std::fmt;
use fluent_syntax::{ast, parser};
use crate::error::L10nError;
use crate::ftl_refs::{Ref, RefKind, RefsIncompat, check_refs, find_refs};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MessageContract {
pub message: &'static str,
pub attribute: Option<&'static str>,
pub vars: &'static [&'static str],
pub elements: &'static [ElementContract],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ElementContract {
pub name: &'static str,
pub is_term: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ContractViolation {
MissingMessage {
message: String,
},
MissingValue {
message: String,
},
MissingAttribute {
message: String,
attribute: String,
},
UnknownVariable {
message: String,
attribute: Option<String>,
variable: String,
},
ElementMismatch {
message: String,
expected: Vec<String>,
found: Vec<String>,
},
UnknownTerm {
term: String,
referenced_from: String,
},
}
impl fmt::Display for ContractViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingMessage { message } => {
write!(f, "message '{message}' is missing")
}
Self::MissingValue { message } => {
write!(f, "message '{message}' has no value of its own")
}
Self::MissingAttribute { message, attribute } => {
write!(f, "message '{message}' has no attribute '{attribute}'")
}
Self::UnknownVariable {
message,
attribute,
variable,
} => {
match attribute {
Some(a) => write!(f, "attribute '{a}' of message '{message}'")?,
None => write!(f, "message '{message}'")?,
}
write!(f, " references unknown variable '${variable}'")
}
Self::ElementMismatch {
message,
expected,
found,
} => write!(
f,
"message '{message}' has element markers [{}] but the contract expects [{}]",
found.join(", "),
expected.join(", "),
),
Self::UnknownTerm {
term,
referenced_from,
} => write!(
f,
"'{referenced_from}' references term '-{term}', which is not defined",
),
}
}
}
pub fn validate_ftl(bytes: &[u8], contracts: &[MessageContract]) -> Result<(), L10nError> {
let ftl = String::from_utf8(bytes.to_vec()).map_err(L10nError::InvalidUtf8)?;
let resource = match parser::parse(ftl.as_str()) {
Ok(resource) => resource,
Err((_, errors)) => {
return Err(L10nError::ResourceParse(
errors.iter().map(|e| format!("{e:?}")).collect(),
));
}
};
let mut messages: HashMap<&str, &ast::Message<&str>> = HashMap::new();
let mut terms: HashMap<&str, &ast::Term<&str>> = HashMap::new();
for entry in &resource.body {
match entry {
ast::Entry::Message(m) => {
messages.entry(m.id.name).or_insert(m);
}
ast::Entry::Term(t) => {
terms.entry(t.id.name).or_insert(t);
}
_ => {}
}
}
let mut violations = Vec::new();
let mut walked_terms: HashSet<String> = HashSet::new();
for contract in contracts {
let Some(message) = messages.get(contract.message) else {
violations.push(ContractViolation::MissingMessage {
message: contract.message.to_string(),
});
continue;
};
let pattern = if let Some(attribute) = contract.attribute {
match message.attributes.iter().find(|a| a.id.name == attribute) {
Some(attr) => &attr.value,
None => {
violations.push(ContractViolation::MissingAttribute {
message: contract.message.to_string(),
attribute: attribute.to_string(),
});
continue;
}
}
} else {
match message.value.as_ref() {
Some(value) => value,
None => {
violations.push(ContractViolation::MissingValue {
message: contract.message.to_string(),
});
continue;
}
}
};
let refs = find_refs(pattern);
let elements: Vec<(&str, RefKind)> = contract
.elements
.iter()
.map(|e| {
let kind = if e.is_term {
RefKind::Term
} else {
RefKind::Variable
};
(e.name, kind)
})
.collect();
if let Err(incompat) = check_refs(contract.vars, &elements, &refs) {
violations.push(match incompat {
RefsIncompat::UnknownVariable { variable } => ContractViolation::UnknownVariable {
message: contract.message.to_string(),
attribute: contract.attribute.map(str::to_string),
variable,
},
RefsIncompat::ElementMismatch { expected, found } => {
ContractViolation::ElementMismatch {
message: contract.message.to_string(),
expected: render_elements(&expected),
found: render_elements(&found),
}
}
});
}
let origin = match contract.attribute {
Some(a) => format!("{}.{a}", contract.message),
None => contract.message.to_string(),
};
check_term_refs(&refs, &origin, &terms, &mut walked_terms, &mut violations);
}
if violations.is_empty() {
Ok(())
} else {
Err(L10nError::Validation { violations })
}
}
fn check_term_refs(
refs: &[Ref],
origin: &str,
terms: &HashMap<&str, &ast::Term<&str>>,
walked: &mut HashSet<String>,
violations: &mut Vec<ContractViolation>,
) {
for r in refs {
if r.kind != RefKind::Term {
continue;
}
match terms.get(r.name.as_str()) {
None => violations.push(ContractViolation::UnknownTerm {
term: r.name.clone(),
referenced_from: origin.to_string(),
}),
Some(term) => {
if walked.insert(r.name.clone()) {
let term_refs = find_refs(&term.value);
let term_origin = format!("-{}", r.name);
check_term_refs(&term_refs, &term_origin, terms, walked, violations);
}
}
}
}
}
fn render_elements(elements: &[(String, RefKind)]) -> Vec<String> {
elements
.iter()
.map(|(name, kind)| match kind {
RefKind::Variable => format!("${name}"),
RefKind::Term => format!("-{name}"),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const CONTRACTS: &[MessageContract] = &[
MessageContract {
message: "hello",
attribute: None,
vars: &["name"],
elements: &[],
},
MessageContract {
message: "login",
attribute: Some("placeholder"),
vars: &[],
elements: &[],
},
MessageContract {
message: "notice",
attribute: None,
vars: &["count"],
elements: &[
ElementContract {
name: "icon",
is_term: false,
},
ElementContract {
name: "privacy-link",
is_term: true,
},
],
},
];
const VALID: &str = r#"
hello = Hej { $name }
login = Logga in
.placeholder = Ange din e-post
notice = { $icon } Du har { $count } olästa, se { -privacy-link }.
-privacy-link = vår integritetspolicy
"#;
fn violations(ftl: &str) -> Vec<ContractViolation> {
match validate_ftl(ftl.as_bytes(), CONTRACTS) {
Err(L10nError::Validation { violations }) => violations,
other => panic!("expected Validation error, got {other:?}"),
}
}
#[test]
fn a_complete_translation_passes() {
validate_ftl(VALID.as_bytes(), CONTRACTS).unwrap();
}
#[test]
fn fewer_variables_than_the_contract_is_allowed() {
let ftl = VALID.replace("Hej { $name }", "Hej!");
validate_ftl(ftl.as_bytes(), CONTRACTS).unwrap();
}
#[test]
fn a_missing_message_is_a_violation() {
let ftl = VALID.replace("hello", "helo");
assert_eq!(
violations(&ftl),
vec![ContractViolation::MissingMessage {
message: "hello".to_string()
}]
);
}
#[test]
fn a_missing_attribute_is_a_violation() {
let ftl = VALID.replace(".placeholder", ".placeholdr");
assert_eq!(
violations(&ftl),
vec![ContractViolation::MissingAttribute {
message: "login".to_string(),
attribute: "placeholder".to_string(),
}]
);
}
#[test]
fn a_message_without_a_value_is_a_violation() {
let ftl = VALID.replace(
"hello = Hej { $name }",
"hello =\n .other = Hej { $name }",
);
assert_eq!(
violations(&ftl),
vec![ContractViolation::MissingValue {
message: "hello".to_string()
}]
);
}
#[test]
fn an_unknown_variable_is_a_violation() {
let ftl = VALID.replace("{ $name }", "{ $nom }");
assert_eq!(
violations(&ftl),
vec![ContractViolation::UnknownVariable {
message: "hello".to_string(),
attribute: None,
variable: "nom".to_string(),
}]
);
}
#[test]
fn an_unknown_variable_in_a_selector_is_a_violation() {
let ftl = VALID.replace(
"hello = Hej { $name }",
"hello = { $other ->\n [one] En\n *[other] Hej\n}",
);
assert_eq!(
violations(&ftl),
vec![ContractViolation::UnknownVariable {
message: "hello".to_string(),
attribute: None,
variable: "other".to_string(),
}]
);
}
#[test]
fn a_reordered_element_sequence_is_a_violation() {
let ftl = VALID.replace(
"{ $icon } Du har { $count } olästa, se { -privacy-link }.",
"Se { -privacy-link } { $icon } för { $count } olästa.",
);
assert_eq!(
violations(&ftl),
vec![ContractViolation::ElementMismatch {
message: "notice".to_string(),
expected: vec!["$icon".to_string(), "-privacy-link".to_string()],
found: vec!["-privacy-link".to_string(), "$icon".to_string()],
}]
);
}
#[test]
fn a_dropped_element_is_a_violation() {
let ftl = VALID.replace("{ $icon } ", "");
assert_eq!(
violations(&ftl),
vec![ContractViolation::ElementMismatch {
message: "notice".to_string(),
expected: vec!["$icon".to_string(), "-privacy-link".to_string()],
found: vec!["-privacy-link".to_string()],
}]
);
}
#[test]
fn an_undefined_term_is_a_violation() {
let ftl = VALID.replace("-privacy-link = vår integritetspolicy", "");
assert_eq!(
violations(&ftl),
vec![ContractViolation::UnknownTerm {
term: "privacy-link".to_string(),
referenced_from: "notice".to_string(),
}]
);
}
#[test]
fn an_undefined_term_behind_another_term_is_a_violation() {
let ftl = VALID.replace(
"-privacy-link = vår integritetspolicy",
"-privacy-link = vår { -policy }",
);
assert_eq!(
violations(&ftl),
vec![ContractViolation::UnknownTerm {
term: "policy".to_string(),
referenced_from: "-privacy-link".to_string(),
}]
);
}
#[test]
fn all_violations_are_collected() {
let ftl = "notice = Bara { $typo } kvar\n";
let found = violations(ftl);
assert_eq!(found.len(), 3, "got: {found:?}");
assert!(found.iter().any(
|v| matches!(v, ContractViolation::MissingMessage { message } if message == "hello")
));
assert!(found.iter().any(
|v| matches!(v, ContractViolation::ElementMismatch { message, .. } if message == "notice")
));
}
#[test]
fn a_parse_error_is_a_resource_parse_error() {
let err = validate_ftl(b"hello = { $unclosed\n", CONTRACTS).unwrap_err();
assert!(matches!(err, L10nError::ResourceParse(_)), "got: {err:?}");
}
#[test]
fn invalid_utf8_is_an_utf8_error() {
let err = validate_ftl(&[0xff, 0xfe], CONTRACTS).unwrap_err();
assert!(matches!(err, L10nError::InvalidUtf8(_)), "got: {err:?}");
}
#[test]
fn violations_render_readably() {
let ftl = VALID.replace("{ $name }", "{ $nom }");
let err = validate_ftl(ftl.as_bytes(), CONTRACTS).unwrap_err();
let text = err.to_string();
assert!(
text.contains("message 'hello' references unknown variable '$nom'"),
"got: {text}"
);
}
}