Skip to main content

fluent_typed/
contract.rs

1//! Runtime validation of external `.ftl` translations.
2//!
3//! The build step compiles a [`MessageContract`] for every generated accessor
4//! into the generated file (the `MESSAGE_CONTRACTS` static). [`validate_ftl`]
5//! checks external `.ftl` bytes — a translation read from disk or downloaded
6//! at runtime — against those contracts, using the same compatibility rules
7//! the build applies to every locale. On success, every generated accessor is
8//! safe to call on a bundle loaded from those bytes.
9
10use 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/// The compiled contract of one generated message accessor: which message (or
19/// attribute) it resolves, which arguments it fills, and — for structured
20/// messages — the exact `(Element)` marker sequence it splits on.
21///
22/// Instances are emitted by the build step into the generated file's
23/// `MESSAGE_CONTRACTS` static; there is no reason to construct one by hand.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct MessageContract {
26    /// The Fluent message id.
27    pub message: &'static str,
28    /// The attribute of the message, for an attribute accessor.
29    pub attribute: Option<&'static str>,
30    /// The argument names the accessor fills (`$variable` names).
31    pub vars: &'static [&'static str],
32    /// Variables encoded from Rust `bool` as Fluent `"true"` / `"false"`.
33    pub bool_vars: &'static [&'static str],
34    /// The `(Element)` markers, in pattern order. Empty for plain messages.
35    pub elements: &'static [ElementContract],
36}
37
38/// One `(Element)` marker of a structured message contract.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct ElementContract {
41    /// The variable or term name (without the `$` / `-` sigil).
42    pub name: &'static str,
43    /// `true` for a term element (`-name`), `false` for a variable element.
44    pub is_term: bool,
45}
46
47/// One way an external `.ftl` failed to satisfy a [`MessageContract`].
48///
49/// Carried by [`L10nError::Validation`]. Each variant describes a mistake
50/// that would otherwise surface later as a panic or formatting error in a
51/// generated accessor.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum ContractViolation {
55    /// A contract message is missing from the `.ftl` entirely.
56    MissingMessage {
57        /// The message id.
58        message: String,
59    },
60    /// The message exists but has no value of its own, and the contract
61    /// expects one (the message defines only attributes).
62    MissingValue {
63        /// The message id.
64        message: String,
65    },
66    /// The message exists but lacks a required attribute.
67    MissingAttribute {
68        /// The message id.
69        message: String,
70        /// The missing attribute.
71        attribute: String,
72    },
73    /// The pattern references a variable that is not a contract argument.
74    /// The generated accessor would leave it unfilled, which fails to format.
75    UnknownVariable {
76        /// The message id.
77        message: String,
78        /// The attribute, for an attribute accessor.
79        attribute: Option<String>,
80        /// The unknown `$variable` name.
81        variable: String,
82    },
83    /// The `(Element)` marker sequence does not match the contract, so the
84    /// structured-message split would not produce the expected slots.
85    /// The entries are rendered as `$variable` / `-term`.
86    ElementMismatch {
87        /// The message id.
88        message: String,
89        /// The contract's marker sequence.
90        expected: Vec<String>,
91        /// The sequence found in the `.ftl`.
92        found: Vec<String>,
93    },
94    /// A Boolean variable's selector does not contain exactly `[true]` and
95    /// `[false]` branches.
96    BoolSelectorMismatch {
97        /// The message id.
98        message: String,
99        /// The attribute, for an attribute accessor.
100        attribute: Option<String>,
101        /// The Boolean variable driving the selector.
102        variable: String,
103        /// Variant keys found in the translation.
104        found: Vec<String>,
105    },
106    /// A Boolean variable is referenced outside its selector, which would
107    /// render its encoded `true` / `false` string directly.
108    BoolReferenceOutsideSelector {
109        /// The message id.
110        message: String,
111        /// The attribute, for an attribute accessor.
112        attribute: Option<String>,
113        /// The Boolean variable referenced directly.
114        variable: String,
115    },
116    /// A referenced term is not defined in the `.ftl`. Resolving it would
117    /// fail to format at runtime.
118    UnknownTerm {
119        /// The referenced `-term` name.
120        term: String,
121        /// Where the reference was found: a message id, `message.attribute`,
122        /// or another term rendered as `-term`.
123        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
201/// Validate external `.ftl` bytes against the compiled message contracts.
202///
203/// This is the check behind the generated `L10nLanguage::new_external`. It
204/// verifies that:
205///
206/// - the bytes are valid UTF-8 and parse as Fluent
207///   ([`L10nError::InvalidUtf8`] / [`L10nError::ResourceParse`] otherwise);
208/// - every contract message (and attribute) is defined;
209/// - no validated pattern references a variable outside its contract
210///   arguments;
211/// - selectors driven by Boolean arguments keep exactly `[true]` and `[false]`
212///   variants;
213/// - structured messages keep the exact `(Element)` marker sequence;
214/// - every term referenced from a validated pattern (transitively, through
215///   other terms) is defined in the file.
216///
217/// Contract violations are *collected* — the returned
218/// [`L10nError::Validation`] lists every problem, not just the first — so a
219/// translator can fix a rejected file in one pass.
220pub 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    // Terms already walked for undefined references, shared across contracts
247    // so a term reached from several messages is reported once.
248    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
352/// Flag references to undefined terms, walking transitively into the terms a
353/// pattern references (a term's own pattern may reference further terms).
354/// Only terms reachable from validated patterns are walked, so an unused,
355/// broken term does not reject the file.
356fn 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
383/// Render an element marker sequence as `$variable` / `-term` names.
384fn 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        // A locale that does not need an argument is fine — build-time
454        // locales are held to the same rule.
455        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        // `$nom` is not a contract argument: the accessor would leave it
499        // unfilled. This is the typo that used to panic at accessor time.
500        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        // Violations are collected both across and within messages: hello and
671        // login are missing; notice dropped its markers and added `$typo`.
672        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}