1use std::collections::{HashMap, HashSet};
11use std::fmt;
12
13use fluent_syntax::{ast, parser};
14
15use crate::error::L10nError;
16use crate::ftl_refs::{Ref, RefKind, RefsIncompat, check_refs, find_refs, find_refs_and_selectors};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct MessageContract {
26 pub message: &'static str,
28 pub attribute: Option<&'static str>,
30 pub vars: &'static [&'static str],
32 pub bool_vars: &'static [&'static str],
34 pub elements: &'static [ElementContract],
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct ElementContract {
41 pub name: &'static str,
43 pub is_term: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum ContractViolation {
55 MissingMessage {
57 message: String,
59 },
60 MissingValue {
63 message: String,
65 },
66 MissingAttribute {
68 message: String,
70 attribute: String,
72 },
73 UnknownVariable {
76 message: String,
78 attribute: Option<String>,
80 variable: String,
82 },
83 ElementMismatch {
87 message: String,
89 expected: Vec<String>,
91 found: Vec<String>,
93 },
94 BoolSelectorMismatch {
97 message: String,
99 attribute: Option<String>,
101 variable: String,
103 found: Vec<String>,
105 },
106 BoolReferenceOutsideSelector {
109 message: String,
111 attribute: Option<String>,
113 variable: String,
115 },
116 UnknownTerm {
119 term: String,
121 referenced_from: String,
124 },
125}
126
127impl fmt::Display for ContractViolation {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Self::MissingMessage { message } => {
131 write!(f, "message '{message}' is missing")
132 }
133 Self::MissingValue { message } => {
134 write!(f, "message '{message}' has no value of its own")
135 }
136 Self::MissingAttribute { message, attribute } => {
137 write!(f, "message '{message}' has no attribute '{attribute}'")
138 }
139 Self::UnknownVariable {
140 message,
141 attribute,
142 variable,
143 } => {
144 match attribute {
145 Some(a) => write!(f, "attribute '{a}' of message '{message}'")?,
146 None => write!(f, "message '{message}'")?,
147 }
148 write!(f, " references unknown variable '${variable}'")
149 }
150 Self::ElementMismatch {
151 message,
152 expected,
153 found,
154 } => write!(
155 f,
156 "message '{message}' has element markers [{}] but the contract expects [{}]",
157 found.join(", "),
158 expected.join(", "),
159 ),
160 Self::BoolSelectorMismatch {
161 message,
162 attribute,
163 variable,
164 found,
165 } => {
166 match attribute {
167 Some(a) => write!(f, "attribute '{a}' of message '{message}'")?,
168 None => write!(f, "message '{message}'")?,
169 }
170 write!(
171 f,
172 " has Boolean selector ${variable} with keys [{}]; expected [true, false]",
173 found.join(", "),
174 )
175 }
176 Self::BoolReferenceOutsideSelector {
177 message,
178 attribute,
179 variable,
180 } => {
181 match attribute {
182 Some(a) => write!(f, "attribute '{a}' of message '{message}'")?,
183 None => write!(f, "message '{message}'")?,
184 }
185 write!(
186 f,
187 " references Boolean variable '${variable}' outside a [true]/[false] selector",
188 )
189 }
190 Self::UnknownTerm {
191 term,
192 referenced_from,
193 } => write!(
194 f,
195 "'{referenced_from}' references term '-{term}', which is not defined",
196 ),
197 }
198 }
199}
200
201pub fn validate_ftl(bytes: &[u8], contracts: &[MessageContract]) -> Result<(), L10nError> {
221 let ftl = String::from_utf8(bytes.to_vec()).map_err(L10nError::InvalidUtf8)?;
222 let resource = match parser::parse(ftl.as_str()) {
223 Ok(resource) => resource,
224 Err((_, errors)) => {
225 return Err(L10nError::ResourceParse(
226 errors.iter().map(|e| format!("{e:?}")).collect(),
227 ));
228 }
229 };
230
231 let mut messages: HashMap<&str, &ast::Message<&str>> = HashMap::new();
232 let mut terms: HashMap<&str, &ast::Term<&str>> = HashMap::new();
233 for entry in &resource.body {
234 match entry {
235 ast::Entry::Message(m) => {
236 messages.entry(m.id.name).or_insert(m);
237 }
238 ast::Entry::Term(t) => {
239 terms.entry(t.id.name).or_insert(t);
240 }
241 _ => {}
242 }
243 }
244
245 let mut violations = Vec::new();
246 let mut walked_terms: HashSet<String> = HashSet::new();
249
250 for contract in contracts {
251 let Some(message) = messages.get(contract.message) else {
252 violations.push(ContractViolation::MissingMessage {
253 message: contract.message.to_string(),
254 });
255 continue;
256 };
257
258 let pattern = if let Some(attribute) = contract.attribute {
259 match message.attributes.iter().find(|a| a.id.name == attribute) {
260 Some(attr) => &attr.value,
261 None => {
262 violations.push(ContractViolation::MissingAttribute {
263 message: contract.message.to_string(),
264 attribute: attribute.to_string(),
265 });
266 continue;
267 }
268 }
269 } else {
270 match message.value.as_ref() {
271 Some(value) => value,
272 None => {
273 violations.push(ContractViolation::MissingValue {
274 message: contract.message.to_string(),
275 });
276 continue;
277 }
278 }
279 };
280
281 let (refs, selectors) = find_refs_and_selectors(pattern);
282 let elements: Vec<(&str, RefKind)> = contract
283 .elements
284 .iter()
285 .map(|e| {
286 let kind = if e.is_term {
287 RefKind::Term
288 } else {
289 RefKind::Variable
290 };
291 (e.name, kind)
292 })
293 .collect();
294 if let Err(incompatibilities) = check_refs(
295 contract.vars,
296 contract.bool_vars,
297 &elements,
298 &refs,
299 &selectors,
300 ) {
301 violations.extend(
302 incompatibilities
303 .into_iter()
304 .map(|incompat| match incompat {
305 RefsIncompat::UnknownVariable { variable } => {
306 ContractViolation::UnknownVariable {
307 message: contract.message.to_string(),
308 attribute: contract.attribute.map(str::to_string),
309 variable,
310 }
311 }
312 RefsIncompat::ElementMismatch { expected, found } => {
313 ContractViolation::ElementMismatch {
314 message: contract.message.to_string(),
315 expected: render_elements(&expected),
316 found: render_elements(&found),
317 }
318 }
319 RefsIncompat::BoolSelectorMismatch { variable, found } => {
320 ContractViolation::BoolSelectorMismatch {
321 message: contract.message.to_string(),
322 attribute: contract.attribute.map(str::to_string),
323 variable,
324 found,
325 }
326 }
327 RefsIncompat::BoolReferenceOutsideSelector { variable } => {
328 ContractViolation::BoolReferenceOutsideSelector {
329 message: contract.message.to_string(),
330 attribute: contract.attribute.map(str::to_string),
331 variable,
332 }
333 }
334 }),
335 );
336 }
337
338 let origin = match contract.attribute {
339 Some(a) => format!("{}.{a}", contract.message),
340 None => contract.message.to_string(),
341 };
342 check_term_refs(&refs, &origin, &terms, &mut walked_terms, &mut violations);
343 }
344
345 if violations.is_empty() {
346 Ok(())
347 } else {
348 Err(L10nError::Validation { violations })
349 }
350}
351
352fn check_term_refs(
357 refs: &[Ref],
358 origin: &str,
359 terms: &HashMap<&str, &ast::Term<&str>>,
360 walked: &mut HashSet<String>,
361 violations: &mut Vec<ContractViolation>,
362) {
363 for r in refs {
364 if r.kind != RefKind::Term {
365 continue;
366 }
367 match terms.get(r.name.as_str()) {
368 None => violations.push(ContractViolation::UnknownTerm {
369 term: r.name.clone(),
370 referenced_from: origin.to_string(),
371 }),
372 Some(term) => {
373 if walked.insert(r.name.clone()) {
374 let term_refs = find_refs(&term.value);
375 let term_origin = format!("-{}", r.name);
376 check_term_refs(&term_refs, &term_origin, terms, walked, violations);
377 }
378 }
379 }
380 }
381}
382
383fn render_elements(elements: &[(String, RefKind)]) -> Vec<String> {
385 elements
386 .iter()
387 .map(|(name, kind)| match kind {
388 RefKind::Variable => format!("${name}"),
389 RefKind::Term => format!("-{name}"),
390 })
391 .collect()
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 const CONTRACTS: &[MessageContract] = &[
399 MessageContract {
400 message: "hello",
401 attribute: None,
402 vars: &["name"],
403 bool_vars: &[],
404 elements: &[],
405 },
406 MessageContract {
407 message: "login",
408 attribute: Some("placeholder"),
409 vars: &[],
410 bool_vars: &[],
411 elements: &[],
412 },
413 MessageContract {
414 message: "notice",
415 attribute: None,
416 vars: &["count"],
417 bool_vars: &[],
418 elements: &[
419 ElementContract {
420 name: "icon",
421 is_term: false,
422 },
423 ElementContract {
424 name: "privacy-link",
425 is_term: true,
426 },
427 ],
428 },
429 ];
430
431 const VALID: &str = r#"
432hello = Hej { $name }
433login = Logga in
434 .placeholder = Ange din e-post
435notice = { $icon } Du har { $count } olästa, se { -privacy-link }.
436-privacy-link = vår integritetspolicy
437"#;
438
439 fn violations(ftl: &str) -> Vec<ContractViolation> {
440 match validate_ftl(ftl.as_bytes(), CONTRACTS) {
441 Err(L10nError::Validation { violations }) => violations,
442 other => panic!("expected Validation error, got {other:?}"),
443 }
444 }
445
446 #[test]
447 fn a_complete_translation_passes() {
448 validate_ftl(VALID.as_bytes(), CONTRACTS).unwrap();
449 }
450
451 #[test]
452 fn fewer_variables_than_the_contract_is_allowed() {
453 let ftl = VALID.replace("Hej { $name }", "Hej!");
456 validate_ftl(ftl.as_bytes(), CONTRACTS).unwrap();
457 }
458
459 #[test]
460 fn a_missing_message_is_a_violation() {
461 let ftl = VALID.replace("hello", "helo");
462 assert_eq!(
463 violations(&ftl),
464 vec![ContractViolation::MissingMessage {
465 message: "hello".to_string()
466 }]
467 );
468 }
469
470 #[test]
471 fn a_missing_attribute_is_a_violation() {
472 let ftl = VALID.replace(".placeholder", ".placeholdr");
473 assert_eq!(
474 violations(&ftl),
475 vec![ContractViolation::MissingAttribute {
476 message: "login".to_string(),
477 attribute: "placeholder".to_string(),
478 }]
479 );
480 }
481
482 #[test]
483 fn a_message_without_a_value_is_a_violation() {
484 let ftl = VALID.replace(
485 "hello = Hej { $name }",
486 "hello =\n .other = Hej { $name }",
487 );
488 assert_eq!(
489 violations(&ftl),
490 vec![ContractViolation::MissingValue {
491 message: "hello".to_string()
492 }]
493 );
494 }
495
496 #[test]
497 fn an_unknown_variable_is_a_violation() {
498 let ftl = VALID.replace("{ $name }", "{ $nom }");
501 assert_eq!(
502 violations(&ftl),
503 vec![ContractViolation::UnknownVariable {
504 message: "hello".to_string(),
505 attribute: None,
506 variable: "nom".to_string(),
507 }]
508 );
509 }
510
511 #[test]
512 fn an_unknown_variable_in_a_selector_is_a_violation() {
513 let ftl = VALID.replace(
514 "hello = Hej { $name }",
515 "hello = { $other ->\n [one] En\n *[other] Hej\n}",
516 );
517 assert_eq!(
518 violations(&ftl),
519 vec![ContractViolation::UnknownVariable {
520 message: "hello".to_string(),
521 attribute: None,
522 variable: "other".to_string(),
523 }]
524 );
525 }
526
527 #[test]
528 fn boolean_selector_keys_are_validated() {
529 const BOOL_CONTRACT: &[MessageContract] = &[MessageContract {
530 message: "feature",
531 attribute: None,
532 vars: &["enabled"],
533 bool_vars: &["enabled"],
534 elements: &[],
535 }];
536 let valid = "feature = { $enabled ->\n [true] On\n *[false] Off\n}\n";
537 validate_ftl(valid.as_bytes(), BOOL_CONTRACT).unwrap();
538
539 let invalid = "feature = { $enabled ->\n [yes] On\n *[no] Off\n}\n";
540 match validate_ftl(invalid.as_bytes(), BOOL_CONTRACT) {
541 Err(L10nError::Validation { violations }) => assert_eq!(
542 violations,
543 vec![ContractViolation::BoolSelectorMismatch {
544 message: "feature".to_string(),
545 attribute: None,
546 variable: "enabled".to_string(),
547 found: vec!["yes".to_string(), "no".to_string()],
548 }]
549 ),
550 other => panic!("expected Validation error, got {other:?}"),
551 }
552 }
553
554 #[test]
555 fn boolean_variable_must_not_be_interpolated_directly() {
556 const BOOL_CONTRACT: &[MessageContract] = &[MessageContract {
557 message: "feature",
558 attribute: None,
559 vars: &["enabled"],
560 bool_vars: &["enabled"],
561 elements: &[],
562 }];
563 let invalid = "feature = Feature: { $enabled }\n";
564
565 match validate_ftl(invalid.as_bytes(), BOOL_CONTRACT) {
566 Err(L10nError::Validation { violations }) => assert_eq!(
567 violations,
568 vec![ContractViolation::BoolReferenceOutsideSelector {
569 message: "feature".to_string(),
570 attribute: None,
571 variable: "enabled".to_string(),
572 }]
573 ),
574 other => panic!("expected Validation error, got {other:?}"),
575 }
576 }
577
578 #[test]
579 fn independent_reference_violations_are_collected() {
580 const BOOL_CONTRACT: &[MessageContract] = &[MessageContract {
581 message: "feature",
582 attribute: None,
583 vars: &["enabled"],
584 bool_vars: &["enabled"],
585 elements: &[],
586 }];
587 let invalid = "feature = { $enabled ->\n [yes] { $extra }\n *[no] Off\n}\n";
588
589 match validate_ftl(invalid.as_bytes(), BOOL_CONTRACT) {
590 Err(L10nError::Validation { violations }) => assert_eq!(
591 violations,
592 vec![
593 ContractViolation::UnknownVariable {
594 message: "feature".to_string(),
595 attribute: None,
596 variable: "extra".to_string(),
597 },
598 ContractViolation::BoolSelectorMismatch {
599 message: "feature".to_string(),
600 attribute: None,
601 variable: "enabled".to_string(),
602 found: vec!["yes".to_string(), "no".to_string()],
603 },
604 ]
605 ),
606 other => panic!("expected Validation error, got {other:?}"),
607 }
608 }
609
610 #[test]
611 fn a_reordered_element_sequence_is_a_violation() {
612 let ftl = VALID.replace(
613 "{ $icon } Du har { $count } olästa, se { -privacy-link }.",
614 "Se { -privacy-link } { $icon } för { $count } olästa.",
615 );
616 assert_eq!(
617 violations(&ftl),
618 vec![ContractViolation::ElementMismatch {
619 message: "notice".to_string(),
620 expected: vec!["$icon".to_string(), "-privacy-link".to_string()],
621 found: vec!["-privacy-link".to_string(), "$icon".to_string()],
622 }]
623 );
624 }
625
626 #[test]
627 fn a_dropped_element_is_a_violation() {
628 let ftl = VALID.replace("{ $icon } ", "");
629 assert_eq!(
630 violations(&ftl),
631 vec![ContractViolation::ElementMismatch {
632 message: "notice".to_string(),
633 expected: vec!["$icon".to_string(), "-privacy-link".to_string()],
634 found: vec!["-privacy-link".to_string()],
635 }]
636 );
637 }
638
639 #[test]
640 fn an_undefined_term_is_a_violation() {
641 let ftl = VALID.replace("-privacy-link = vår integritetspolicy", "");
642 assert_eq!(
643 violations(&ftl),
644 vec![ContractViolation::UnknownTerm {
645 term: "privacy-link".to_string(),
646 referenced_from: "notice".to_string(),
647 }]
648 );
649 }
650
651 #[test]
652 fn an_undefined_term_behind_another_term_is_a_violation() {
653 let ftl = VALID.replace(
654 "-privacy-link = vår integritetspolicy",
655 "-privacy-link = vår { -policy }",
656 );
657 assert_eq!(
658 violations(&ftl),
659 vec![ContractViolation::UnknownTerm {
660 term: "policy".to_string(),
661 referenced_from: "-privacy-link".to_string(),
662 }]
663 );
664 }
665
666 #[test]
667 fn all_violations_are_collected() {
668 let ftl = "notice = Bara { $typo } kvar\n";
669 let found = violations(ftl);
670 assert_eq!(found.len(), 4, "got: {found:?}");
673 assert!(found.iter().any(
674 |v| matches!(v, ContractViolation::MissingMessage { message } if message == "hello")
675 ));
676 assert!(found.iter().any(
677 |v| matches!(v, ContractViolation::ElementMismatch { message, .. } if message == "notice")
678 ));
679 assert!(found.iter().any(
680 |v| matches!(v, ContractViolation::UnknownVariable { variable, .. } if variable == "typo")
681 ));
682 }
683
684 #[test]
685 fn a_parse_error_is_a_resource_parse_error() {
686 let err = validate_ftl(b"hello = { $unclosed\n", CONTRACTS).unwrap_err();
687 assert!(matches!(err, L10nError::ResourceParse(_)), "got: {err:?}");
688 }
689
690 #[test]
691 fn invalid_utf8_is_an_utf8_error() {
692 let err = validate_ftl(&[0xff, 0xfe], CONTRACTS).unwrap_err();
693 assert!(matches!(err, L10nError::InvalidUtf8(_)), "got: {err:?}");
694 }
695
696 #[test]
697 fn violations_render_readably() {
698 let ftl = VALID.replace("{ $name }", "{ $nom }");
699 let err = validate_ftl(ftl.as_bytes(), CONTRACTS).unwrap_err();
700 let text = err.to_string();
701 assert!(
702 text.contains("message 'hello' references unknown variable '$nom'"),
703 "got: {text}"
704 );
705 }
706}