fluent-typed 0.8.0

Type-safe access to Fluent localization messages
Documentation
use std::collections::{HashMap, HashSet};

use crate::build::LangBundle;
use crate::build::typed::{ElementKind, Id, Message, RefKind};
use crate::ftl_refs::check_refs;

/// The result of comparing every locale against the default-locale contract.
#[derive(Debug)]
pub struct Analyzed {
    /// The message ids that will be generated: defined in the default locale,
    /// present in every other locale, and structurally compatible everywhere.
    pub common: HashSet<Id>,
    /// Human-readable warnings for messages that could *not* be generated.
    /// Each names the `.ftl` file and line involved. Sorted for determinism.
    pub warnings: Vec<String>,
}

impl Analyzed {
    /// Analyze the locales against `default`, the default-language bundle.
    ///
    /// The default locale defines each message's contract (its variables and
    /// `(Element)` layout). A message is generated only when every other locale
    /// defines it too, with a structurally compatible pattern.
    pub fn from(langs: &[LangBundle], default: &LangBundle) -> Self {
        // Each non-default locale, paired with its messages indexed by id, so
        // the per-message contract check below is a hash lookup rather than a
        // linear scan — keeping the whole analysis O(messages · locales).
        let others: Vec<(&LangBundle, HashMap<&Id, &Message>)> = langs
            .iter()
            .filter(|l| l.language_id != default.language_id)
            .map(|l| {
                let mut by_id: HashMap<&Id, &Message> = HashMap::new();
                for m in &l.messages {
                    // Keep the first occurrence, matching the previous
                    // `.find()` — significant when duplicate keys are allowed.
                    by_id.entry(&m.id).or_insert(m);
                }
                (l, by_id)
            })
            .collect();

        let mut common = HashSet::new();
        let mut warnings = Vec::new();

        for contract in &default.messages {
            let id = &contract.id;
            let mut missing_in: Vec<String> = Vec::new();
            let mut incompatible_in: Vec<String> = Vec::new();

            for (lang, by_id) in &others {
                match by_id.get(id) {
                    None => missing_in.push(lang.language_id.clone()),
                    Some(msg) if !compatible(contract, msg) => {
                        incompatible_in
                            .push(format!("{} ({}:{})", lang.language_id, msg.file, msg.line));
                    }
                    Some(_) => {}
                }
            }

            if !missing_in.is_empty() {
                warnings.push(format!(
                    "{}:{}: {id} is not generated — missing from locale(s): {}",
                    contract.file,
                    contract.line,
                    missing_in.join(", "),
                ));
            } else if !incompatible_in.is_empty() {
                warnings.push(format!(
                    "{}:{}: {id} is not generated — incompatible variables or \
                     elements in locale(s): {}",
                    contract.file,
                    contract.line,
                    incompatible_in.join(", "),
                ));
            } else {
                common.insert(id.clone());
            }
        }

        warnings.extend(orphan_warnings(&others, default));
        warnings.sort();

        Self { common, warnings }
    }
}

/// Warn about messages that exist in non-default locales but are absent from
/// the default locale — they have no contract, so no accessor is generated.
fn orphan_warnings(
    others: &[(&LangBundle, HashMap<&Id, &Message>)],
    default: &LangBundle,
) -> Vec<String> {
    let default_ids: HashSet<&Id> = default.messages.iter().map(|m| &m.id).collect();
    let mut orphans: HashMap<Id, Vec<String>> = HashMap::new();

    for (lang, _) in others {
        for msg in &lang.messages {
            if !default_ids.contains(&msg.id) {
                orphans
                    .entry(msg.id.clone())
                    .or_default()
                    .push(lang.language_id.clone());
            }
        }
    }

    orphans
        .into_iter()
        .map(|(id, mut langs)| {
            langs.sort();
            format!(
                "{id} is not generated — present in locale(s) {} but missing from \
                 the default locale '{}'",
                langs.join(", "),
                default.language_id,
            )
        })
        .collect()
}

/// Whether `other` (a non-default locale's message) is structurally compatible
/// with the `contract` (the default locale's message of the same id).
///
/// The check is comment-independent: it uses `pattern_refs`, the raw
/// `$variable`/`-term` references, never the per-locale comment annotations.
/// It delegates to [`check_refs`], the same check `validate_ftl` applies to
/// external translations at runtime, so the two can never drift apart.
fn compatible(contract: &Message, other: &Message) -> bool {
    let vars: Vec<&str> = contract.variables.iter().map(|v| v.id.as_str()).collect();
    let elements: Vec<(&str, RefKind)> = contract
        .elements
        .iter()
        .map(|e| {
            let kind = match e.kind {
                ElementKind::Variable => RefKind::Variable,
                ElementKind::Term => RefKind::Term,
            };
            (e.name.as_str(), kind)
        })
        .collect();
    check_refs(&vars, &elements, &other.pattern_refs).is_ok()
}