Skip to main content

fluent_zero_build/
lib.rs

1//! # fluent-zero-build
2//!
3//! Build-time code generator for [`fluent-zero`](https://docs.rs/fluent-zero).
4//! Call it from `build.rs`; it compiles the `.ftl` files of every locale
5//! directory into `$OUT_DIR/static_cache.rs`, which the consuming crate
6//! `include!`s at its root.
7//!
8//! ```rust,no_run
9//! // In build.rs's `main`:
10//! fluent_zero_build::FluentZeroBuilder::new("assets/locales").generate();
11//! ```
12//!
13//! [`FluentZeroBuilder::try_generate`] reports every failure as an [`Error`]
14//! instead of panicking:
15//!
16//! ```rust,no_run
17//! use fluent_zero_build::{FluentZeroBuilder, MissingTranslations};
18//!
19//! fn main() -> Result<(), fluent_zero_build::Error> {
20//!     FluentZeroBuilder::new("assets/locales")
21//!         .fallback_locale("en-US")
22//!         .missing_translations(MissingTranslations::Deny)
23//!         .try_generate()
24//! }
25//! ```
26
27#![forbid(unsafe_code)]
28#![warn(missing_docs)]
29#![deny(rustdoc::broken_intra_doc_links)]
30
31use std::{
32    collections::{BTreeSet, HashMap, HashSet},
33    env,
34    ffi::OsString,
35    fmt::{self, Write as _},
36    fs,
37    io::{self, Write},
38    path::{Path, PathBuf},
39};
40
41use fluent_bundle::{FluentBundle, FluentResource};
42use fluent_langneg::{NegotiationStrategy, negotiate_languages};
43use fluent_syntax::{ast, parser, serializer, unicode::unescape_unicode_to_string};
44use unic_langid::{LanguageIdentifier, subtags};
45
46/// The contract version between the generated code and the `fluent-zero`
47/// runtime; must equal `fluent_zero::__private::CODEGEN_VERSION`.
48///
49/// The generated file asserts it at compile time, so a mismatched pair of
50/// crates fails with a message saying which one to upgrade. It is a plain
51/// counter, independent of the crates' semver versions: bump it in both crates,
52/// to the same value, whenever the generated code changes what it needs from
53/// the runtime.
54const CODEGEN_VERSION: u32 = 1;
55
56/// The path the generated code uses for the runtime crate unless
57/// [`FluentZeroBuilder::crate_path`] overrides it.
58const DEFAULT_CRATE_PATH: &str = "::fluent_zero";
59
60/// The fallback locale when none is configured, matching the runtime's
61/// `fluent_zero::DEFAULT_FALLBACK`.
62const DEFAULT_FALLBACK: &str = "en-US";
63
64/// How many missing message IDs a coverage report names per locale.
65const COVERAGE_SAMPLE: usize = 5;
66
67/// Why [`FluentZeroBuilder::try_generate`] failed.
68///
69/// [`FluentZeroBuilder::generate`] panics with this error's `Display` text,
70/// prefixed with `fluent-zero-build: `.
71#[derive(Debug)]
72#[non_exhaustive]
73pub enum Error {
74    /// The `OUT_DIR` environment variable is not set, i.e. the builder is not
75    /// running inside a Cargo build script.
76    MissingOutDir,
77    /// The path given to [`FluentZeroBuilder::crate_path`] is not a Rust path.
78    InvalidCratePath(String),
79    /// The locale given to [`FluentZeroBuilder::fallback_locale`] is not a
80    /// valid language identifier.
81    InvalidFallbackLocale {
82        /// The locale as configured.
83        locale: String,
84        /// Why it does not parse.
85        reason: String,
86    },
87    /// The locales directory holds locales, but none of them is the
88    /// configured [fallback locale](FluentZeroBuilder::fallback_locale).
89    MissingFallbackLocale {
90        /// The configured fallback locale, in canonical form.
91        locale: String,
92        /// The locales directory that was searched.
93        locales_dir: PathBuf,
94        /// The locales that do exist, sorted.
95        available: Vec<String>,
96    },
97    /// Reading a locale file or writing generated output failed.
98    Io {
99        /// The file or directory being read or written.
100        path: PathBuf,
101        /// The underlying error.
102        source: io::Error,
103    },
104    /// A message or term ID is defined more than once within one locale.
105    DuplicateId {
106        /// The ID as written (terms keep their `-` prefix).
107        id: String,
108        /// The locale, e.g. `en-US`.
109        lang: String,
110        /// File and 1-based line of the first definition.
111        first: (PathBuf, usize),
112        /// File and 1-based line of the repeated definition.
113        second: (PathBuf, usize),
114    },
115    /// An `.ftl` file contains syntax errors.
116    Parse {
117        /// The offending file.
118        path: PathBuf,
119        /// Every error in the file, each as `path:line:col: error` followed by
120        /// the offending source line and a caret under the error position.
121        message: String,
122    },
123    /// Translations are missing under [`MissingTranslations::Deny`].
124    MissingTranslations {
125        /// One line per incomplete locale, sorted by locale: how many
126        /// messages of the fallback locale it lacks and the first few IDs.
127        report: Vec<String>,
128    },
129}
130
131impl fmt::Display for Error {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::MissingOutDir => {
135                f.write_str("OUT_DIR is not set; fluent-zero-build must run from a build script")
136            }
137            Self::InvalidCratePath(path) => write!(
138                f,
139                "invalid crate path {path:?}: expected a Rust path such as \"::fluent_zero\""
140            ),
141            Self::InvalidFallbackLocale { locale, reason } => write!(
142                f,
143                "fallback locale `{locale}` is not a valid language identifier: {reason}"
144            ),
145            Self::MissingFallbackLocale {
146                locale,
147                locales_dir,
148                available,
149            } => write!(
150                f,
151                "fallback locale `{locale}` has no directory in {} (available: {})",
152                locales_dir.display(),
153                available.join(", ")
154            ),
155            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
156            Self::DuplicateId {
157                id,
158                lang,
159                first,
160                second,
161            } => write!(
162                f,
163                "`{id}` is defined twice in locale `{lang}`: \
164                 first at {}:{}, again at {}:{}. \
165                 Fluent bundles allow one definition per ID; rename or remove one.",
166                first.0.display(),
167                first.1,
168                second.0.display(),
169                second.1,
170            ),
171            Self::Parse { path, message } => {
172                write!(f, "failed to parse {}:\n{message}", path.display())
173            }
174            Self::MissingTranslations { report } => {
175                write!(f, "missing translations:\n{}", report.join("\n"))
176            }
177        }
178    }
179}
180
181impl std::error::Error for Error {
182    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
183        match self {
184            Self::Io { source, .. } => Some(source),
185            _ => None,
186        }
187    }
188}
189
190/// Adapts an [`io::Error`] on `path` into an [`Error`], for `map_err`.
191fn io_err(path: &Path) -> impl FnOnce(io::Error) -> Error + '_ {
192    move |source| Error::Io {
193        path: path.to_path_buf(),
194        source,
195    }
196}
197
198/// What the builder does about messages present in the fallback locale but
199/// missing from another locale.
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
201pub enum MissingTranslations {
202    /// Say nothing.
203    Allow,
204    /// Emit one `cargo:warning` per incomplete locale (the default).
205    #[default]
206    Warn,
207    /// Fail the build, listing every incomplete locale
208    /// ([`Error::MissingTranslations`]).
209    Deny,
210}
211
212/// A builder to configure and generate the static cache code for `fluent-zero`.
213#[derive(Debug, Clone)]
214pub struct FluentZeroBuilder {
215    locales_dir: PathBuf,
216    charset_dest: Option<PathBuf>,
217    crate_path: String,
218    fallback_locale: Option<String>,
219    missing_translations: MissingTranslations,
220    use_isolating: bool,
221}
222
223impl Default for FluentZeroBuilder {
224    fn default() -> Self {
225        Self::new("assets/locales")
226    }
227}
228
229impl FluentZeroBuilder {
230    /// Creates a new builder pointing to the directory containing locale subdirectories.
231    ///
232    /// # Arguments
233    ///
234    /// * `locales_dir_path` - Relative path to the folder containing locale subdirectories.
235    #[must_use]
236    pub fn new<P: AsRef<Path>>(locales_dir_path: P) -> Self {
237        Self {
238            locales_dir: locales_dir_path.as_ref().to_path_buf(),
239            charset_dest: None,
240            crate_path: DEFAULT_CRATE_PATH.to_string(),
241            fallback_locale: None,
242            missing_translations: MissingTranslations::default(),
243            use_isolating: true,
244        }
245    }
246
247    /// Sets the path the generated code uses to reach the `fluent-zero` crate.
248    ///
249    /// Defaults to `::fluent_zero`. Set it when the dependency is renamed in
250    /// `Cargo.toml`, e.g. `fz = { package = "fluent-zero", ... }` needs
251    /// `.crate_path("::fz")`. An invalid path makes
252    /// [`try_generate`](Self::try_generate) return [`Error::InvalidCratePath`].
253    #[must_use]
254    pub fn crate_path(mut self, path: impl Into<String>) -> Self {
255        self.crate_path = path.into();
256        self
257    }
258
259    /// Sets how messages present in the fallback locale (see
260    /// [`fallback_locale`](Self::fallback_locale), `en-US` by default) but
261    /// missing from another locale are reported. Defaults to
262    /// [`MissingTranslations::Warn`].
263    #[must_use]
264    pub fn missing_translations(mut self, level: MissingTranslations) -> Self {
265        self.missing_translations = level;
266        self
267    }
268
269    /// Opt-in to exporting a deterministic text file containing all unique
270    /// characters used across all `.ftl` files in this crate **and its dependencies**.
271    ///
272    /// **Dependency Aggregation:** This natively aggregates characters from dependencies
273    /// using Cargo's standard IPC. No metadata scraping or tree walking is required.
274    ///
275    /// `dest` should live inside `OUT_DIR` (e.g. `Path::new(&out_dir).join("chars.txt")`)
276    /// so the build stays hermetic. A relative path is resolved against the
277    /// build script's working directory (the package root); one that lands
278    /// outside `OUT_DIR` still works, but emits a `cargo:warning`.
279    #[must_use]
280    pub fn export_charset<P: AsRef<Path>>(mut self, dest: P) -> Self {
281        self.charset_dest = Some(dest.as_ref().to_path_buf());
282        self
283    }
284
285    /// Sets the locale lookups fall back to when neither the active language
286    /// nor its fallback chain has a message. Defaults to `en-US`.
287    ///
288    /// The generated code exports it as `FALLBACK` and the `t!` macros use it.
289    /// It is also the locale the [missing translations](Self::missing_translations)
290    /// report compares every other locale against, and the one whose source
291    /// documents the generated `keys` constants.
292    /// When the locales directory holds any locale,
293    /// [`try_generate`](Self::try_generate) returns
294    /// [`Error::MissingFallbackLocale`] unless one of them is this locale, so a
295    /// typo cannot silently disable the fallback; a locale that does not parse
296    /// is [`Error::InvalidFallbackLocale`].
297    #[must_use]
298    pub fn fallback_locale(mut self, locale: &str) -> Self {
299        self.fallback_locale = Some(locale.to_string());
300        self
301    }
302
303    /// Whether placeables are wrapped in the Unicode bidi isolation marks
304    /// U+2068 / U+2069 (default `true`, matching `fluent-bundle`).
305    ///
306    /// The marks keep right-to-left arguments from scrambling surrounding
307    /// left-to-right text (and vice versa), but many GUI font stacks render
308    /// them as boxes or odd gaps. The setting applies to the generated runtime
309    /// bundles and to static messages formatted at build time alike, so both
310    /// stay byte-identical. While enabled, the marks are added to `CHARSET`
311    /// whenever some message can emit them.
312    #[must_use]
313    pub fn use_isolating(mut self, use_isolating: bool) -> Self {
314        self.use_isolating = use_isolating;
315        self
316    }
317
318    /// Consumes the builder, parses the Fluent files, aggregates IPC variables,
319    /// and generates the Rust static cache code.
320    ///
321    /// This is the panicking convenience over [`try_generate`](Self::try_generate),
322    /// meant for build scripts where a panic is the natural way to fail.
323    ///
324    /// # Panics
325    ///
326    /// Panics with the error's message (prefixed with `fluent-zero-build: `)
327    /// whenever [`try_generate`](Self::try_generate) returns an error.
328    pub fn generate(self) {
329        if let Err(err) = self.try_generate() {
330            panic!("fluent-zero-build: {err}");
331        }
332    }
333
334    /// Consumes the builder, parses the Fluent files, aggregates IPC variables,
335    /// and generates the Rust static cache code.
336    ///
337    /// # Errors
338    ///
339    /// Returns an [`Error`] if `OUT_DIR` is not set, the
340    /// [`crate_path`](Self::crate_path) is invalid, the
341    /// [`fallback_locale`](Self::fallback_locale) is invalid or has no
342    /// directory, an `.ftl` file fails to parse, an ID is defined twice in one
343    /// locale, translations are missing under [`MissingTranslations::Deny`],
344    /// or reading the locales or writing the output fails.
345    pub fn try_generate(self) -> Result<(), Error> {
346        if !is_rust_path(&self.crate_path) {
347            return Err(Error::InvalidCratePath(self.crate_path));
348        }
349        let fallback = self.fallback_key()?;
350        let out_dir = env::var("OUT_DIR").map_err(|_| Error::MissingOutDir)?;
351        let dest_path = Path::new(&out_dir).join("static_cache.rs");
352        let mut file = fs::File::create(&dest_path).map_err(io_err(&dest_path))?;
353        let krate = self.crate_path.as_str();
354
355        writeln!(
356            &mut file,
357            "// @generated by fluent-zero-build\n\
358             const _: () = {krate}::__private::assert_codegen_version({CODEGEN_VERSION});"
359        )
360        .map_err(io_err(&dest_path))?;
361
362        let mut unique_chars: BTreeSet<char> = BTreeSet::new();
363
364        // 1. Dependency Aggregation via Cargo IPC.
365        // Cargo automatically propagates `cargo:KEY=VALUE` emitted by dependency build
366        // scripts up to dependents as `DEP_<LINKS>_<KEY>` if the dependency specifies a `links` key.
367        for charset_path in dependency_charset_paths(env::vars_os()) {
368            if let Ok(content) = fs::read_to_string(&charset_path) {
369                // Cargo already reruns this script when the dependency's build
370                // script reruns; registering the file as well is cheap and keeps
371                // the dependency link explicit. Only existing files are
372                // registered: Cargo treats a missing path as always stale.
373                println!("cargo:rerun-if-changed={charset_path}");
374                extend_visible(&mut unique_chars, &content);
375            }
376        }
377
378        let mut bundle_entries: Vec<(String, String)> = Vec::new();
379        let mut cache_root_entries: Vec<(String, String)> = Vec::new();
380        let mut available: Vec<LanguageIdentifier> = Vec::new();
381        let mut message_ids: Vec<(String, BTreeSet<String>)> = Vec::new();
382        let mut all_keys: BTreeSet<String> = BTreeSet::new();
383        let mut fallback_sources: HashMap<String, String> = HashMap::new();
384
385        // 2. Process Local Translations (If present)
386        if self.locales_dir.exists() {
387            println!("cargo:rerun-if-changed={}", self.locales_dir.display());
388
389            // Sorted so the generated file is identical on every filesystem.
390            for path in sorted_dir_entries(&self.locales_dir)? {
391                if !path.is_dir() {
392                    continue;
393                }
394
395                let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
396                    continue;
397                };
398                let Ok(lang_id) = dir_name.parse::<LanguageIdentifier>() else {
399                    continue;
400                };
401
402                let lang_key = lang_id.to_string();
403                let sanitized_lang = table_suffix(&lang_key);
404
405                let cache_name = format!("CACHE_{sanitized_lang}");
406                let bundle_name = format!("BUNDLE_{sanitized_lang}");
407
408                let combined_ftl_source = Self::process_locale_dir(
409                    &path,
410                    &lang_key,
411                    &mut unique_chars,
412                    self.use_isolating,
413                )?;
414                let cache_entries =
415                    static_cache_entries(krate, &lang_id, &combined_ftl_source, self.use_isolating);
416                all_keys.extend(cache_entries.iter().map(|(key, _)| key.clone()));
417                if lang_key == fallback {
418                    fallback_sources = message_sources(&combined_ftl_source);
419                }
420
421                Self::write_bundle_initializer(
422                    &mut file,
423                    krate,
424                    &bundle_name,
425                    &lang_key,
426                    &combined_ftl_source,
427                    self.use_isolating,
428                )
429                .map_err(io_err(&dest_path))?;
430                Self::write_cache_map(&mut file, krate, &cache_name, &cache_entries)
431                    .map_err(io_err(&dest_path))?;
432
433                // Coverage is per message: attribute keys (`message.attribute`)
434                // are left out, so a message counts as translated once its ID is.
435                message_ids.push((
436                    lang_key.clone(),
437                    cache_entries
438                        .into_iter()
439                        .map(|(id, _)| id)
440                        .filter(|id| !id.contains('.'))
441                        .collect(),
442                ));
443
444                bundle_entries.push((lang_key.clone(), format!("&{bundle_name}")));
445                cache_root_entries.push((lang_key, cache_name));
446                available.push(lang_id);
447            }
448        } else if let Some(watch) = missing_dir_watch_path(&self.locales_dir, Path::new(&out_dir)) {
449            // Cargo treats a missing `rerun-if-changed` path as always stale,
450            // which would rerun this script and rebuild the crate on every
451            // build. Watching the closest existing ancestor instead still
452            // notices the locales directory being created later.
453            println!("cargo:rerun-if-changed={}", watch.display());
454        }
455
456        if self.fallback_locale.is_some()
457            && let Err(available) = check_fallback(&fallback, &available)
458        {
459            return Err(Error::MissingFallbackLocale {
460                locale: fallback,
461                locales_dir: self.locales_dir,
462                available,
463            });
464        }
465
466        self.check_coverage(&fallback, &message_ids)?;
467
468        // 3. Complete Code Generation & Upstream Exporting
469        // This unconditionally runs to guarantee valid empty PHF maps in aggregator crates.
470        Self::write_root_maps(&mut file, krate, &cache_root_entries, &bundle_entries)
471            .map_err(io_err(&dest_path))?;
472        Self::write_catalog(&mut file, krate, &fallback, &available).map_err(io_err(&dest_path))?;
473        Self::write_keys(&mut file, &all_keys, &fallback, &fallback_sources)
474            .map_err(io_err(&dest_path))?;
475        self.export_charset_data(&mut file, &dest_path, &unique_chars, &out_dir)
476    }
477
478    fn process_locale_dir(
479        path: &Path,
480        lang_key: &str,
481        unique_chars: &mut BTreeSet<char>,
482        use_isolating: bool,
483    ) -> Result<String, Error> {
484        let mut combined_ftl_source = String::new();
485        let mut definitions = Definitions::default();
486        let mut emits_isolation_marks = false;
487
488        for file_path in sorted_dir_entries(path)? {
489            if file_path.extension().is_some_and(|ext| ext == "ftl") {
490                println!("cargo:rerun-if-changed={}", file_path.display());
491                let source = fs::read_to_string(&file_path).map_err(io_err(&file_path))?;
492
493                combined_ftl_source.push_str(&source);
494                combined_ftl_source.push('\n');
495
496                definitions.record(lang_key, &file_path, &source)?;
497                let ast = match parser::parse(source.clone()) {
498                    Ok(ast) => ast,
499                    Err((_, errors)) => {
500                        return Err(Error::Parse {
501                            message: syntax_report(&file_path, &source, &errors),
502                            path: file_path,
503                        });
504                    }
505                };
506                for entry in ast.body {
507                    match entry {
508                        ast::Entry::Message(msg) => {
509                            harvest_message_chars(&msg, unique_chars);
510                            emits_isolation_marks |= msg
511                                .value
512                                .iter()
513                                .chain(msg.attributes.iter().map(|a| &a.value))
514                                .any(pattern_isolates);
515                        }
516                        ast::Entry::Term(term) => {
517                            harvest_term_chars(&term, unique_chars);
518                            emits_isolation_marks |= std::iter::once(&term.value)
519                                .chain(term.attributes.iter().map(|a| &a.value))
520                                .any(pattern_isolates);
521                        }
522                        _ => {}
523                    }
524                }
525            }
526        }
527
528        if use_isolating && emits_isolation_marks {
529            unique_chars.extend(['\u{2068}', '\u{2069}']);
530        }
531
532        Ok(combined_ftl_source)
533    }
534
535    /// Reports message IDs of the `fallback` locale missing from other
536    /// locales, as configured by [`Self::missing_translations`].
537    fn check_coverage(
538        &self,
539        fallback: &str,
540        message_ids: &[(String, BTreeSet<String>)],
541    ) -> Result<(), Error> {
542        if self.missing_translations == MissingTranslations::Allow {
543            return Ok(());
544        }
545        let report = coverage_report(fallback, message_ids);
546        if report.is_empty() {
547            return Ok(());
548        }
549        if self.missing_translations == MissingTranslations::Deny {
550            return Err(Error::MissingTranslations { report });
551        }
552        for line in report {
553            println!("cargo:warning={line}");
554        }
555        Ok(())
556    }
557
558    fn write_bundle_initializer(
559        file: &mut impl Write,
560        krate: &str,
561        bundle_name: &str,
562        lang_key: &str,
563        combined_ftl_source: &str,
564        use_isolating: bool,
565    ) -> io::Result<()> {
566        let escaped_ftl = format!("{combined_ftl_source:?}");
567        let isolating_code = if use_isolating {
568            ""
569        } else {
570            "\x20   bundle.set_use_isolating(false);\n"
571        };
572        // This runs once per locale, on its first dynamic lookup, so it must
573        // never panic. Each fallible step below was already proven to succeed
574        // at build time, so the fallbacks are unreachable and exist only to
575        // keep panics out of consumers' binaries:
576        // - `lang_key` is the `Display` of a parsed `LanguageIdentifier`,
577        //   which always parses back.
578        // - Every file was parsed without errors in `process_locale_dir`.
579        // - Duplicate IDs were rejected there too, the only way adding a
580        //   resource can fail; overriding makes that infallible by signature.
581        let init_code = format!(
582            "std::sync::LazyLock::new(|| {{\n\
583            \x20   let lang: {krate}::LanguageIdentifier = \"{lang_key}\".parse().unwrap_or_default();\n\
584            \x20   let mut bundle = {krate}::__private::ConcurrentFluentBundle::new_concurrent(vec![lang]);\n\
585            {isolating_code}\
586            \x20   let res = {krate}::__private::FluentResource::try_new({escaped_ftl}.to_string()).unwrap_or_else(|(res, _)| res);\n\
587            \x20   bundle.add_resource_overriding(res);\n\
588            \x20   bundle\n\
589            }})"
590        );
591        writeln!(
592            file,
593            "static {bundle_name}: std::sync::LazyLock<{krate}::__private::ConcurrentFluentBundle<{krate}::__private::FluentResource>> = {init_code};"
594        )
595    }
596
597    fn write_cache_map(
598        file: &mut impl Write,
599        krate: &str,
600        cache_name: &str,
601        cache_entries: &[(String, String)],
602    ) -> io::Result<()> {
603        let phf_path = format!("{krate}::__private::phf");
604        let mut map = phf_codegen::Map::new();
605        map.phf_path(&phf_path);
606        for (k, v) in cache_entries {
607            map.entry(k.as_str(), v.as_str());
608        }
609        writeln!(
610            file,
611            "static {cache_name}: {phf_path}::Map<&'static str, {krate}::CacheEntry> = {};",
612            map.build()
613        )
614    }
615
616    fn write_root_maps(
617        file: &mut impl Write,
618        krate: &str,
619        cache_root_entries: &[(String, String)],
620        bundle_entries: &[(String, String)],
621    ) -> io::Result<()> {
622        let phf_path = format!("{krate}::__private::phf");
623        let mut root_map = phf_codegen::Map::new();
624        root_map.phf_path(&phf_path);
625        for (l, v) in cache_root_entries {
626            root_map.entry(l.as_str(), format!("&{v}"));
627        }
628        writeln!(
629            file,
630            "pub static CACHE: {phf_path}::Map<&'static str, &'static {phf_path}::Map<&'static str, {krate}::CacheEntry>> = {};",
631            root_map.build()
632        )?;
633
634        let mut bundle_map = phf_codegen::Map::new();
635        bundle_map.phf_path(&phf_path);
636        for (l, c) in bundle_entries {
637            bundle_map.entry(l.as_str(), c.as_str());
638        }
639        writeln!(
640            file,
641            "pub static LOCALES: {phf_path}::Map<&'static str, &'static std::sync::LazyLock<{krate}::__private::ConcurrentFluentBundle<{krate}::__private::FluentResource>>> = {};",
642            bundle_map.build()
643        )
644    }
645
646    /// The canonical key of the configured fallback locale.
647    fn fallback_key(&self) -> Result<String, Error> {
648        let Some(locale) = &self.fallback_locale else {
649            return Ok(DEFAULT_FALLBACK.to_string());
650        };
651        match locale.parse::<LanguageIdentifier>() {
652            Ok(id) => Ok(id.to_string()),
653            Err(err) => Err(Error::InvalidFallbackLocale {
654                locale: locale.clone(),
655                reason: err.to_string(),
656            }),
657        }
658    }
659
660    /// Writes `FALLBACK` and `CATALOG`, the tables the `t!` macros use: every
661    /// locale under its own key plus the negotiation aliases of
662    /// [`locale_aliases`], each pointing at the same cache and bundle statics.
663    fn write_catalog(
664        file: &mut impl Write,
665        krate: &str,
666        fallback: &str,
667        available: &[LanguageIdentifier],
668    ) -> io::Result<()> {
669        writeln!(
670            file,
671            "\n/// The locale lookups fall back to when the active language has no message.\n\
672             pub const FALLBACK: &str = {fallback:?};"
673        )?;
674
675        let tables = |key: &str| {
676            let suffix = table_suffix(key);
677            format!("(&CACHE_{suffix}, &BUNDLE_{suffix})")
678        };
679        let phf_path = format!("{krate}::__private::phf");
680        let mut map = phf_codegen::Map::new();
681        map.phf_path(&phf_path);
682        for id in available {
683            let key = id.to_string();
684            let value = tables(&key);
685            map.entry(key, value);
686        }
687        for (alias, target) in locale_aliases(available) {
688            map.entry(alias, tables(&target));
689        }
690        writeln!(
691            file,
692            "static CATALOG_LOCALES: {phf_path}::Map<&'static str, {krate}::__private::CatalogLocale> = {};",
693            map.build()
694        )?;
695        writeln!(
696            file,
697            "/// This crate's lookup tables, with locale negotiation aliases, as used by `t!` and `t_in!`.\n\
698             pub static CATALOG: {krate}::Catalog = {krate}::__private::catalog(FALLBACK, &CATALOG_LOCALES);"
699        )
700    }
701
702    /// Writes `pub mod keys` (one constant per message ID) and the sorted
703    /// key list `t!` checks string literal keys against at compile time.
704    fn write_keys(
705        file: &mut impl Write,
706        all_keys: &BTreeSet<String>,
707        fallback: &str,
708        fallback_sources: &HashMap<String, String>,
709    ) -> io::Result<()> {
710        writeln!(
711            file,
712            "\n/// Every message ID and `message.attribute` key found in any locale, as a\n\
713             /// constant. Using these instead of string literals turns a mistyped key\n\
714             /// into a compile error.\n\
715             pub mod keys {{"
716        )?;
717        for (name, key) in key_const_names(all_keys) {
718            if name != key_const_base(key) {
719                println!(
720                    "cargo:warning=fluent-zero: message ID `{key}` clashes with another ID's constant name; it is available as `keys::{name}`"
721                );
722            }
723            // Fluent IDs cannot contain `.`, so a dotted key is an attribute.
724            let kind = if key.contains('.') {
725                "Attribute"
726            } else {
727                "Message"
728            };
729            let doc = match fallback_sources.get(key) {
730                Some(source) => format!(
731                    "{kind} `{key}`. Fallback locale (`{fallback}`) text:\n\n{}",
732                    fenced_text(source)
733                ),
734                None => {
735                    format!("{kind} `{key}`. Not defined in the fallback locale (`{fallback}`).")
736                }
737            };
738            writeln!(
739                file,
740                "    #[doc = {doc:?}]\n    pub const {name}: &str = {key:?};"
741            )?;
742        }
743        writeln!(file, "}}")?;
744
745        // `t!` binary-searches this list in a `const` item, so it must stay
746        // sorted by byte order: `BTreeSet<String>` iterates in exactly `str`'s `Ord`.
747        let sorted: Vec<&String> = all_keys.iter().collect();
748        writeln!(
749            file,
750            "\n/// Sorted message IDs; `t!` validates string literal keys against it.\n\
751             #[doc(hidden)]\n\
752             pub const __FLUENT_ZERO_KEYS: &[&str] = &{sorted:?};"
753        )
754    }
755
756    fn export_charset_data(
757        &self,
758        file: &mut impl Write,
759        dest_path: &Path,
760        unique_chars: &BTreeSet<char>,
761        out_dir: &str,
762    ) -> Result<(), Error> {
763        let charset_string: String = unique_chars.iter().collect();
764
765        // Embed the charset constant natively into the module.
766        writeln!(
767            file,
768            "\n/// A deterministically sorted string containing all unique characters\n\
769             /// used across all `.ftl` files in this crate and its dependencies.\n\
770             pub const CHARSET: &str = {charset_string:?};"
771        )
772        .map_err(io_err(dest_path))?;
773
774        // 1. Emit absolute path for the NEXT build.rs to consume natively.
775        // Cargo automatically captures this and converts it into `DEP_<LINKS>_FLUENT_CHARSET_PATH`.
776        // We write to a file rather than passing the string to bypass Windows 32KB limits.
777        let internal_dest = Path::new(out_dir).join("fluent_charset_internal.txt");
778        if write_if_changed(&internal_dest, &charset_string).is_ok() {
779            println!("cargo:fluent_charset_path={}", internal_dest.display());
780        }
781
782        if let Some(charset_dest) = &self.charset_dest {
783            if charset_dest.is_relative()
784                && let Ok(cwd) = env::current_dir()
785                && !lexically_inside(&cwd.join(charset_dest), Path::new(out_dir))
786            {
787                println!(
788                    "cargo:warning=fluent-zero-build: export_charset path `{}` is outside OUT_DIR; \
789                     write it under OUT_DIR to keep the build hermetic",
790                    charset_dest.display()
791                );
792            }
793            if let Some(parent) = charset_dest.parent() {
794                let _ = fs::create_dir_all(parent);
795            }
796            write_if_changed(charset_dest, &charset_string).map_err(io_err(charset_dest))?;
797            println!("cargo:rerun-if-changed={}", charset_dest.display());
798        }
799        Ok(())
800    }
801}
802
803/// Whether `path` is a plain Rust path (`::a::b`, `a`, `crate::a`) that the
804/// generated code can prefix item paths with.
805fn is_rust_path(path: &str) -> bool {
806    let path = path.strip_prefix("::").unwrap_or(path);
807    !path.is_empty()
808        && path.split("::").all(|segment| {
809            let mut chars = segment.chars();
810            chars
811                .next()
812                .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
813                && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
814                && segment != "_"
815        })
816}
817
818/// The suffix of a locale's generated `CACHE_*` / `BUNDLE_*` statics.
819fn table_suffix(lang_key: &str) -> String {
820    lang_key.replace('-', "_").to_uppercase()
821}
822
823/// Checks that `fallback` is one of the `available` locales, if there are any
824/// (crates that only aggregate dependencies have none). On failure, returns
825/// the sorted list of available locale keys for the error.
826fn check_fallback(fallback: &str, available: &[LanguageIdentifier]) -> Result<(), Vec<String>> {
827    let mut keys: Vec<String> = available.iter().map(ToString::to_string).collect();
828    if keys.is_empty() || keys.iter().any(|key| key == fallback) {
829        return Ok(());
830    }
831    keys.sort();
832    Err(keys)
833}
834
835/// Maps the keys that end a runtime fallback chain to the best locale this
836/// crate has for them, as `(alias, target locale key)` pairs.
837///
838/// At runtime a language's chain drops variants, script and region in turn
839/// and always ends with the bare language (`pt-BR` tries `pt-BR`, then `pt`),
840/// so the chain alone already finds a more general locale. What it cannot find
841/// is a *more specific* one: `ru` when only `ru-RU/` exists, or `zh-TW` when
842/// only `zh-Hant/` exists. Those are resolved here, once per crate at build
843/// time against its own locales, so the runtime needs no likely-subtags data
844/// and each chain step stays a single map probe. Aliases are emitted for:
845///
846/// - every bare language, matched to its best locale by `fluent-langneg`
847///   (with CLDR likely subtags, so `pt` prefers `pt-BR` and `zh` prefers
848///   `zh-CN` when several regions exist), or else to the first locale of that
849///   language;
850/// - for locales written in a script that is not the language's default
851///   (`zh-Hant`, `zh-TW`, `sr-Latn`): the `language-script` key and every
852///   `language-region` key whose likely script is that script (`zh-TW`,
853///   `zh-HK`, `zh-MO`), but only when negotiation lands on the same script.
854///
855/// Keys that name an existing locale are never aliased.
856fn locale_aliases(available: &[LanguageIdentifier]) -> Vec<(String, String)> {
857    let mut available = available.to_vec();
858    // Negotiation breaks ties by order; directory order is not stable.
859    available.sort();
860    let existing: BTreeSet<String> = available.iter().map(ToString::to_string).collect();
861
862    let mut wanted: BTreeSet<LanguageIdentifier> = BTreeSet::new();
863    for id in &available {
864        let language = LanguageIdentifier::from_parts(id.language, None, None, &[]);
865        let script = likely_script(id);
866        if script.is_some() && script != likely_script(&language) {
867            wanted.insert(LanguageIdentifier::from_parts(
868                id.language,
869                script,
870                None,
871                &[],
872            ));
873            for region in two_letter_regions() {
874                let candidate =
875                    LanguageIdentifier::from_parts(id.language, None, Some(region), &[]);
876                if likely_script(&candidate) == script {
877                    wanted.insert(candidate);
878                }
879            }
880        }
881        wanted.insert(language);
882    }
883
884    wanted
885        .into_iter()
886        .filter(|requested| !existing.contains(&requested.to_string()))
887        .filter_map(|requested| {
888            let negotiated = negotiate_languages(
889                std::slice::from_ref(&requested),
890                &available,
891                None,
892                NegotiationStrategy::Lookup,
893            )
894            .first()
895            .copied();
896            let bare = requested.script.is_none() && requested.region.is_none();
897            let target = if bare {
898                // The bare language is the chain's last step before the
899                // fallback locale, and negotiation never crosses scripts
900                // (`zh` does not match `zh-Hant`): any locale of the language
901                // still beats the fallback.
902                negotiated.or_else(|| {
903                    available
904                        .iter()
905                        .find(|id| id.language == requested.language)
906                })?
907            } else {
908                // A script- or region-level alias that crosses scripts would
909                // beat the bare-language step of the chain for no gain.
910                negotiated.filter(|target| likely_script(target) == likely_script(&requested))?
911            };
912            Some((requested.to_string(), target.to_string()))
913        })
914        .collect()
915}
916
917/// The script `id` is written in, explicit or implied by CLDR likely subtags.
918fn likely_script(id: &LanguageIdentifier) -> Option<subtags::Script> {
919    let mut id = id.clone();
920    id.maximize();
921    id.script
922}
923
924/// Every syntactically valid two-letter region subtag (`AA` to `ZZ`).
925fn two_letter_regions() -> impl Iterator<Item = subtags::Region> {
926    (b'A'..=b'Z').flat_map(|first| {
927        (b'A'..=b'Z').filter_map(move |second| {
928            std::str::from_utf8(&[first, second])
929                .ok()
930                .and_then(|code| code.parse().ok())
931        })
932    })
933}
934
935/// Formats parser errors as `path:line:col: error`, each followed by the
936/// offending source line and a caret under the error position, one per line
937/// (the [`Error::Parse`] message).
938fn syntax_report(path: &Path, source: &str, errors: &[parser::ParserError]) -> String {
939    let mut report = String::new();
940    for error in errors {
941        let offset = error.pos.start.min(source.len());
942        // `floor_char_boundary` is unstable; offsets from the parser are
943        // boundaries in practice, but never slice inside a character.
944        let offset = (0..=offset)
945            .rev()
946            .find(|&i| source.is_char_boundary(i))
947            .unwrap_or(0);
948        let line_start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
949        let line_end = source[offset..]
950            .find('\n')
951            .map_or(source.len(), |i| offset + i);
952        let line_no = source[..line_start].matches('\n').count() + 1;
953        let column = source[line_start..offset].chars().count() + 1;
954        let line = source[line_start..line_end].trim_end_matches('\r');
955        let gutter = " ".repeat(line_no.to_string().len());
956        let caret_pad = " ".repeat(column - 1);
957        if !report.is_empty() {
958            report.push('\n');
959        }
960        let _ = write!(
961            report,
962            "{}:{line_no}:{column}: {error}\n{line_no} | {line}\n{gutter} | {caret_pad}^",
963            path.display()
964        );
965    }
966    report
967}
968
969/// Lists, per locale, the message IDs of `fallback` that the locale lacks:
970/// one line per incomplete locale with a count and the first few IDs, sorted
971/// by locale. Empty when the fallback locale is absent or nothing is missing.
972fn coverage_report(fallback: &str, message_ids: &[(String, BTreeSet<String>)]) -> Vec<String> {
973    let Some((_, expected)) = message_ids.iter().find(|(lang, _)| lang == fallback) else {
974        return Vec::new();
975    };
976    let mut locales: Vec<_> = message_ids
977        .iter()
978        .filter(|(lang, _)| lang != fallback)
979        .collect();
980    locales.sort_by(|a, b| a.0.cmp(&b.0));
981
982    let mut report = Vec::new();
983    for (lang, ids) in locales {
984        let missing: Vec<&str> = expected.difference(ids).map(String::as_str).collect();
985        if missing.is_empty() {
986            continue;
987        }
988        let mut line = format!(
989            "{lang} is missing {} of {} messages from {fallback}: {}",
990            missing.len(),
991            expected.len(),
992            missing[..missing.len().min(COVERAGE_SAMPLE)].join(", ")
993        );
994        if missing.len() > COVERAGE_SAMPLE {
995            let _ = write!(line, " and {} more", missing.len() - COVERAGE_SAMPLE);
996        }
997        report.push(line);
998    }
999    report
1000}
1001
1002/// Where each message and term of one locale is defined, to reject duplicates.
1003///
1004/// Every file of a locale is merged into a single `FluentBundle`, which
1005/// refuses a second definition of the same ID. Catching it here turns what
1006/// would be a runtime failure into a build error that names both locations.
1007#[derive(Default)]
1008struct Definitions {
1009    /// Keyed by the ID as written, so terms keep their `-` prefix and live in
1010    /// their own namespace, exactly as Fluent treats them.
1011    locations: HashMap<String, (PathBuf, usize)>,
1012}
1013
1014impl Definitions {
1015    /// Records every entry defined in `source`.
1016    ///
1017    /// # Errors
1018    ///
1019    /// Returns [`Error::DuplicateId`] with both locations when an ID is
1020    /// already defined in this locale.
1021    fn record(&mut self, lang_key: &str, file: &Path, source: &str) -> Result<(), Error> {
1022        for (id, line) in entry_definitions(source) {
1023            if let Some(first) = self.locations.get(id) {
1024                return Err(Error::DuplicateId {
1025                    id: id.to_string(),
1026                    lang: lang_key.to_string(),
1027                    first: first.clone(),
1028                    second: (file.to_path_buf(), line),
1029                });
1030            }
1031            self.locations
1032                .insert(id.to_string(), (file.to_path_buf(), line));
1033        }
1034        Ok(())
1035    }
1036}
1037
1038/// Yields `(id, 1-based line)` for every message and term in `source`.
1039///
1040/// In Fluent syntax an entry can only start at column 0 with an identifier
1041/// (`-` for terms) followed by `=`: continuation lines must be indented and
1042/// comments start with `#`, so this line scan finds exactly the entries the
1043/// parser does, plus the line numbers its AST does not carry.
1044fn entry_definitions(source: &str) -> impl Iterator<Item = (&str, usize)> {
1045    source.lines().enumerate().filter_map(|(index, line)| {
1046        let (head, _) = line.split_once('=')?;
1047        let id = head.trim_end();
1048        let name = id.strip_prefix('-').unwrap_or(id);
1049        let mut chars = name.chars();
1050        let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
1051            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
1052        valid.then_some((id, index + 1))
1053    })
1054}
1055
1056/// Builds the `(key, CacheEntry expression)` pairs for one locale.
1057///
1058/// Every message gets an entry under its id, and every attribute an entry
1059/// under `"message.attribute"` (Fluent ids cannot contain `.`, so the keys
1060/// never collide). Attributes are classified and formatted exactly like
1061/// values, so a message with only attributes still gets static attributes.
1062///
1063/// A message is static when its output cannot depend on runtime arguments:
1064/// only text, string/number literals, and references to other static
1065/// messages or terms, including selects over static selectors. Static
1066/// messages are formatted here, at build time, by the same `fluent-bundle`
1067/// the runtime uses with the same settings, so the embedded `&'static str` is
1068/// byte-identical to what the runtime bundle would produce (bidi isolation
1069/// marks included, or omitted alike when `use_isolating` is off). Anything
1070/// that fails to format cleanly stays dynamic so the runtime keeps its
1071/// existing behaviour for it.
1072fn static_cache_entries(
1073    krate: &str,
1074    lang_id: &LanguageIdentifier,
1075    source: &str,
1076    use_isolating: bool,
1077) -> Vec<(String, String)> {
1078    // Syntax errors were already rejected per file, so a parse error here is
1079    // impossible; fall back to the partial resource rather than panicking.
1080    let resource = FluentResource::try_new(source.to_owned()).unwrap_or_else(|(res, _)| res);
1081    let mut bundle = FluentBundle::new(vec![lang_id.clone()]);
1082    bundle.set_use_isolating(use_isolating);
1083    bundle.add_resource_overriding(&resource);
1084
1085    let mut analysis = StaticAnalysis::new(&resource);
1086    let mut entries = Vec::new();
1087    for entry in resource.entries() {
1088        let ast::Entry::Message(msg) = entry else {
1089            continue;
1090        };
1091        let id = msg.id.name;
1092        let static_value = msg
1093            .value
1094            .as_ref()
1095            .filter(|_| analysis.message(id, None))
1096            .and_then(|pattern| format_static(&bundle, pattern));
1097        entries.push((id.to_string(), cache_entry(krate, static_value)));
1098
1099        let mut seen = HashSet::new();
1100        for attribute in &msg.attributes {
1101            let name = attribute.id.name;
1102            // The runtime resolves a repeated attribute name to its first
1103            // definition; emit only that one (a duplicate PHF key would panic).
1104            if !seen.insert(name) {
1105                continue;
1106            }
1107            let static_value = analysis
1108                .message(id, Some(name))
1109                .then(|| format_static(&bundle, &attribute.value))
1110                .flatten();
1111            entries.push((format!("{id}.{name}"), cache_entry(krate, static_value)));
1112        }
1113    }
1114    entries
1115}
1116
1117/// Formats a pattern the analysis proved static, or `None` if it does not
1118/// format cleanly (it then stays dynamic).
1119fn format_static<'r>(
1120    bundle: &FluentBundle<&'r FluentResource>,
1121    pattern: &'r ast::Pattern<&'r str>,
1122) -> Option<String> {
1123    let mut errors = Vec::new();
1124    let value = bundle.format_pattern(pattern, None, &mut errors);
1125    errors.is_empty().then(|| value.into_owned())
1126}
1127
1128fn cache_entry(krate: &str, static_value: Option<String>) -> String {
1129    match static_value {
1130        // `{:?}` on a `str` is a valid Rust string literal: quotes,
1131        // backslashes and invisible characters are all escaped.
1132        Some(value) => format!("{krate}::CacheEntry::Static({value:?})"),
1133        None => format!("{krate}::CacheEntry::Dynamic"),
1134    }
1135}
1136
1137/// Decides which messages and terms render the same text for any arguments.
1138struct StaticAnalysis<'r> {
1139    messages: HashMap<&'r str, &'r ast::Message<&'r str>>,
1140    terms: HashMap<&'r str, &'r ast::Term<&'r str>>,
1141    /// Memoised verdicts keyed by (is term, id, attribute). An entry is seeded
1142    /// with `false` while it is being analysed, so reference cycles (which
1143    /// fail to format anyway) resolve to dynamic instead of recursing forever.
1144    verdicts: HashMap<(bool, &'r str, Option<&'r str>), bool>,
1145}
1146
1147impl<'r> StaticAnalysis<'r> {
1148    fn new(resource: &'r FluentResource) -> Self {
1149        let mut messages = HashMap::new();
1150        let mut terms = HashMap::new();
1151        for entry in resource.entries() {
1152            match entry {
1153                ast::Entry::Message(msg) => {
1154                    messages.insert(msg.id.name, msg);
1155                }
1156                ast::Entry::Term(term) => {
1157                    terms.insert(term.id.name, term);
1158                }
1159                _ => {}
1160            }
1161        }
1162        Self {
1163            messages,
1164            terms,
1165            verdicts: HashMap::new(),
1166        }
1167    }
1168
1169    fn message(&mut self, id: &'r str, attribute: Option<&'r str>) -> bool {
1170        let messages = &self.messages;
1171        let pattern = messages.get(id).and_then(|msg| match attribute {
1172            None => msg.value.as_ref(),
1173            Some(name) => attribute_value(&msg.attributes, name),
1174        });
1175        self.verdict((false, id, attribute), pattern)
1176    }
1177
1178    fn term(&mut self, id: &'r str, attribute: Option<&'r str>) -> bool {
1179        let terms = &self.terms;
1180        let pattern = terms.get(id).and_then(|term| match attribute {
1181            None => Some(&term.value),
1182            Some(name) => attribute_value(&term.attributes, name),
1183        });
1184        self.verdict((true, id, attribute), pattern)
1185    }
1186
1187    fn verdict(
1188        &mut self,
1189        key: (bool, &'r str, Option<&'r str>),
1190        pattern: Option<&'r ast::Pattern<&'r str>>,
1191    ) -> bool {
1192        if let Some(&verdict) = self.verdicts.get(&key) {
1193            return verdict;
1194        }
1195        self.verdicts.insert(key, false);
1196        let verdict = pattern.is_some_and(|pattern| self.pattern(pattern));
1197        self.verdicts.insert(key, verdict);
1198        verdict
1199    }
1200
1201    fn pattern(&mut self, pattern: &'r ast::Pattern<&'r str>) -> bool {
1202        pattern.elements.iter().all(|element| match element {
1203            ast::PatternElement::TextElement { .. } => true,
1204            ast::PatternElement::Placeable { expression } => self.expression(expression),
1205        })
1206    }
1207
1208    fn expression(&mut self, expression: &'r ast::Expression<&'r str>) -> bool {
1209        match expression {
1210            ast::Expression::Select { selector, variants } => {
1211                self.inline(selector) && variants.iter().all(|v| self.pattern(&v.value))
1212            }
1213            ast::Expression::Inline(inline) => self.inline(inline),
1214        }
1215    }
1216
1217    fn inline(&mut self, inline: &'r ast::InlineExpression<&'r str>) -> bool {
1218        match inline {
1219            ast::InlineExpression::StringLiteral { .. }
1220            | ast::InlineExpression::NumberLiteral { .. } => true,
1221            ast::InlineExpression::MessageReference { id, attribute } => {
1222                self.message(id.name, attribute.as_ref().map(|a| a.name))
1223            }
1224            // Term arguments only feed `$variables` inside the term, and any
1225            // variable makes a pattern dynamic, so literal arguments are fine.
1226            ast::InlineExpression::TermReference {
1227                id,
1228                attribute,
1229                arguments,
1230            } => {
1231                arguments.as_ref().is_none_or(|args| {
1232                    args.positional.iter().all(|a| self.inline(a))
1233                        && args.named.iter().all(|a| self.inline(&a.value))
1234                }) && self.term(id.name, attribute.as_ref().map(|a| a.name))
1235            }
1236            ast::InlineExpression::Placeable { expression } => self.expression(expression),
1237            // Functions may format with runtime state (e.g. locale-aware
1238            // `NUMBER`) and variables are runtime arguments by definition.
1239            ast::InlineExpression::FunctionReference { .. }
1240            | ast::InlineExpression::VariableReference { .. } => false,
1241        }
1242    }
1243}
1244
1245/// The FTL source of every message in `source` (value and attributes, without
1246/// comments), normalised by the `fluent-syntax` serializer, keyed by message
1247/// ID; plus, under each `"message.attribute"` key, the message reduced to just
1248/// that attribute. The first definition of an ID or attribute wins, as it does
1249/// in the runtime bundle.
1250fn message_sources(source: &str) -> HashMap<String, String> {
1251    fn serialize(msg: ast::Message<&str>) -> String {
1252        let resource = ast::Resource {
1253            body: vec![ast::Entry::Message(msg)],
1254        };
1255        serializer::serialize(&resource).trim_end().to_string()
1256    }
1257
1258    let resource = FluentResource::try_new(source.to_owned()).unwrap_or_else(|(res, _)| res);
1259    let mut sources = HashMap::new();
1260    for entry in resource.entries() {
1261        if let ast::Entry::Message(msg) = entry {
1262            let id = msg.id.name;
1263            if sources.contains_key(id) {
1264                continue;
1265            }
1266            for attribute in &msg.attributes {
1267                sources
1268                    .entry(format!("{id}.{}", attribute.id.name))
1269                    .or_insert_with(|| {
1270                        serialize(ast::Message {
1271                            id: msg.id.clone(),
1272                            value: None,
1273                            attributes: vec![attribute.clone()],
1274                            comment: None,
1275                        })
1276                    });
1277            }
1278            let mut msg = msg.clone();
1279            msg.comment = None;
1280            sources.insert(id.to_string(), serialize(msg));
1281        }
1282    }
1283    sources
1284}
1285
1286/// Wraps `text` in a fenced `text` code block, so rustdoc neither renders it
1287/// as Markdown nor runs it as a doctest. The fence is longer than any run of
1288/// backticks inside `text`.
1289fn fenced_text(text: &str) -> String {
1290    let mut longest = 0;
1291    let mut run = 0;
1292    for c in text.chars() {
1293        run = if c == '`' { run + 1 } else { 0 };
1294        longest = longest.max(run);
1295    }
1296    let fence = "`".repeat(longest.max(2) + 1);
1297    format!("{fence}text\n{text}\n{fence}")
1298}
1299
1300/// The constant name a key gets when nothing clashes with it: ASCII letters
1301/// are uppercased, ASCII digits kept, and every other character (`-`, `.`,
1302/// `_`, anything non-ASCII) becomes `_`. A name that would not start with an
1303/// ASCII letter is prefixed with `KEY_`. The result is always a valid Rust
1304/// identifier, and never a keyword (keywords are not all uppercase).
1305fn key_const_base(key: &str) -> String {
1306    let name: String = key
1307        .chars()
1308        .map(|c| {
1309            if c.is_ascii_alphanumeric() {
1310                c.to_ascii_uppercase()
1311            } else {
1312                '_'
1313            }
1314        })
1315        .collect();
1316    if name.starts_with(|c: char| c.is_ascii_alphabetic()) {
1317        name
1318    } else {
1319        format!("KEY_{name}")
1320    }
1321}
1322
1323/// Assigns every key a unique constant name, deterministically.
1324///
1325/// Keys are visited in byte order. The first key to want a base name (see
1326/// [`key_const_base`]) gets it; each later key wanting the same name (e.g.
1327/// `foo_bar` after `foo-bar`) gets `{BASE}_{n}` with the smallest `n >= 2`
1328/// that is neither another key's base name nor already assigned, so a
1329/// suffixed name never takes the plain name of a different key.
1330fn key_const_names(keys: &BTreeSet<String>) -> Vec<(String, &str)> {
1331    let bases: BTreeSet<String> = keys.iter().map(|key| key_const_base(key)).collect();
1332    let mut assigned: BTreeSet<String> = BTreeSet::new();
1333    let mut names = Vec::with_capacity(keys.len());
1334    for key in keys {
1335        let base = key_const_base(key);
1336        let mut name = base.clone();
1337        if assigned.contains(&name) {
1338            let mut n = 2;
1339            loop {
1340                name = format!("{base}_{n}");
1341                if !bases.contains(&name) && !assigned.contains(&name) {
1342                    break;
1343                }
1344                n += 1;
1345            }
1346        }
1347        assigned.insert(name.clone());
1348        names.push((name, key.as_str()));
1349    }
1350    names
1351}
1352
1353fn attribute_value<'r>(
1354    attributes: &'r [ast::Attribute<&'r str>],
1355    name: &str,
1356) -> Option<&'r ast::Pattern<&'r str>> {
1357    attributes
1358        .iter()
1359        .find(|attribute| attribute.id.name == name)
1360        .map(|attribute| &attribute.value)
1361}
1362
1363/// Collects every character a message can render: its value, all of its
1364/// attributes, and every branch of any select expression inside them.
1365fn harvest_message_chars(msg: &ast::Message<String>, unique_chars: &mut BTreeSet<char>) {
1366    if let Some(pattern) = &msg.value {
1367        harvest_pattern_chars(pattern, unique_chars);
1368    }
1369    for attribute in &msg.attributes {
1370        harvest_pattern_chars(&attribute.value, unique_chars);
1371    }
1372}
1373
1374/// Terms are never looked up directly, but their text is spliced into any
1375/// message that references them, so it is rendered all the same.
1376fn harvest_term_chars(term: &ast::Term<String>, unique_chars: &mut BTreeSet<char>) {
1377    harvest_pattern_chars(&term.value, unique_chars);
1378    for attribute in &term.attributes {
1379        harvest_pattern_chars(&attribute.value, unique_chars);
1380    }
1381}
1382
1383fn harvest_pattern_chars(pattern: &ast::Pattern<String>, unique_chars: &mut BTreeSet<char>) {
1384    for element in &pattern.elements {
1385        match element {
1386            ast::PatternElement::TextElement { value } => extend_visible(unique_chars, value),
1387            ast::PatternElement::Placeable { expression } => {
1388                harvest_expression_chars(expression, unique_chars);
1389            }
1390        }
1391    }
1392}
1393
1394fn harvest_expression_chars(
1395    expression: &ast::Expression<String>,
1396    unique_chars: &mut BTreeSet<char>,
1397) {
1398    match expression {
1399        // Every variant can be rendered depending on the runtime arguments, not
1400        // just the default one. The selector itself is only compared, never shown.
1401        ast::Expression::Select { variants, .. } => {
1402            for variant in variants {
1403                harvest_pattern_chars(&variant.value, unique_chars);
1404            }
1405        }
1406        ast::Expression::Inline(inline) => harvest_inline_chars(inline, unique_chars),
1407    }
1408}
1409
1410fn harvest_inline_chars(inline: &ast::InlineExpression<String>, unique_chars: &mut BTreeSet<char>) {
1411    match inline {
1412        // String literals may carry escapes (`{"\u00A0"}`); harvest what renders.
1413        ast::InlineExpression::StringLiteral { value } => {
1414            extend_visible(unique_chars, &unescape_unicode_to_string(value));
1415        }
1416        ast::InlineExpression::NumberLiteral { value } => extend_visible(unique_chars, value),
1417        // Positional arguments may be echoed by custom functions. Named arguments
1418        // are formatting options (`style: "percent"`) and never rendered.
1419        ast::InlineExpression::FunctionReference { arguments, .. } => {
1420            for argument in &arguments.positional {
1421                harvest_inline_chars(argument, unique_chars);
1422            }
1423        }
1424        ast::InlineExpression::Placeable { expression } => {
1425            harvest_expression_chars(expression, unique_chars);
1426        }
1427        // References resolve to entries harvested on their own; variables are
1428        // runtime data the charset cannot know about.
1429        ast::InlineExpression::MessageReference { .. }
1430        | ast::InlineExpression::TermReference { .. }
1431        | ast::InlineExpression::VariableReference { .. } => {}
1432    }
1433}
1434
1435/// Whether formatting `pattern` (or a select variant inside it) can wrap a
1436/// placeable in U+2068 / U+2069. Mirrors `fluent-bundle`: only placeables in
1437/// patterns of more than one element are isolated, and message references,
1438/// term references and string literals never are. Referenced messages and
1439/// terms are checked on their own.
1440fn pattern_isolates(pattern: &ast::Pattern<String>) -> bool {
1441    let multi_element = pattern.elements.len() > 1;
1442    pattern.elements.iter().any(|element| match element {
1443        ast::PatternElement::TextElement { .. } => false,
1444        ast::PatternElement::Placeable { expression } => {
1445            let isolated = multi_element
1446                && !matches!(
1447                    expression,
1448                    ast::Expression::Inline(
1449                        ast::InlineExpression::MessageReference { .. }
1450                            | ast::InlineExpression::TermReference { .. }
1451                            | ast::InlineExpression::StringLiteral { .. }
1452                    )
1453                );
1454            isolated || expression_isolates(expression)
1455        }
1456    })
1457}
1458
1459fn expression_isolates(expression: &ast::Expression<String>) -> bool {
1460    match expression {
1461        ast::Expression::Select { variants, .. } => variants
1462            .iter()
1463            .any(|variant| pattern_isolates(&variant.value)),
1464        ast::Expression::Inline(ast::InlineExpression::Placeable { expression }) => {
1465            expression_isolates(expression)
1466        }
1467        ast::Expression::Inline(_) => false,
1468    }
1469}
1470
1471fn extend_visible(unique_chars: &mut BTreeSet<char>, text: &str) {
1472    unique_chars.extend(text.chars().filter(|c| !c.is_control()));
1473}
1474
1475/// Lists the entries of `dir` sorted by path, so processing order (and with
1476/// it the generated code) does not depend on the filesystem's `read_dir` order.
1477fn sorted_dir_entries(dir: &Path) -> Result<Vec<PathBuf>, Error> {
1478    let mut paths = fs::read_dir(dir)
1479        .and_then(|entries| {
1480            entries
1481                .map(|entry| entry.map(|entry| entry.path()))
1482                .collect::<io::Result<Vec<PathBuf>>>()
1483        })
1484        .map_err(io_err(dir))?;
1485    paths.sort();
1486    Ok(paths)
1487}
1488
1489/// Picks the charset files dependencies exported through Cargo's `links`
1490/// IPC (`DEP_<LINKS>_FLUENT_CHARSET_PATH`), sorted for stable output.
1491///
1492/// Takes `vars_os` rather than `vars`, which panics on the first non-UTF-8
1493/// variable anywhere in the environment; such entries are skipped instead.
1494fn dependency_charset_paths(vars: impl IntoIterator<Item = (OsString, OsString)>) -> Vec<String> {
1495    let mut paths: Vec<String> = vars
1496        .into_iter()
1497        .filter_map(|(key, value)| {
1498            let key = key.into_string().ok()?;
1499            let is_charset = key.starts_with("DEP_") && key.ends_with("_FLUENT_CHARSET_PATH");
1500            is_charset.then(|| value.into_string().ok()).flatten()
1501        })
1502        .collect();
1503    paths.sort();
1504    paths
1505}
1506
1507/// The closest existing ancestor of the missing directory `dir`, to watch for
1508/// its creation, unless that ancestor contains `out_dir`: the build writes
1509/// there, so watching it would leave the script permanently stale.
1510fn missing_dir_watch_path(dir: &Path, out_dir: &Path) -> Option<PathBuf> {
1511    let out_dir = fs::canonicalize(out_dir).ok()?;
1512    let ancestor = dir
1513        .ancestors()
1514        .skip(1)
1515        .map(|a| {
1516            if a.as_os_str().is_empty() {
1517                Path::new(".")
1518            } else {
1519                a
1520            }
1521        })
1522        .find(|a| a.is_dir())?;
1523    let canonical = fs::canonicalize(ancestor).ok()?;
1524    (!out_dir.starts_with(canonical)).then(|| ancestor.to_path_buf())
1525}
1526
1527/// Whether `path` lies inside `base`, resolving `.` and `..` lexically (the
1528/// path may not exist yet, so it cannot be canonicalised).
1529fn lexically_inside(path: &Path, base: &Path) -> bool {
1530    fn normalize(path: &Path) -> PathBuf {
1531        let mut normalized = PathBuf::new();
1532        for component in path.components() {
1533            match component {
1534                std::path::Component::CurDir => {}
1535                std::path::Component::ParentDir => {
1536                    normalized.pop();
1537                }
1538                other => normalized.push(other),
1539            }
1540        }
1541        normalized
1542    }
1543    normalize(path).starts_with(normalize(base))
1544}
1545
1546/// Generates the static cache code for `fluent-zero` from `locales_dir_path`
1547/// with the default settings; shorthand for
1548/// `FluentZeroBuilder::new(locales_dir_path).generate()`.
1549///
1550/// # Panics
1551///
1552/// Panics when [`FluentZeroBuilder::generate`] does.
1553pub fn generate_static_cache(locales_dir_path: &str) {
1554    FluentZeroBuilder::new(locales_dir_path).generate();
1555}
1556
1557/// Writes `contents` to `path` only when it differs from what is already
1558/// there, so an unchanged file keeps its modification time.
1559///
1560/// Cargo compares a `rerun-if-changed` path against the time the build
1561/// script STARTED: a file the script rewrites on every run is always newer,
1562/// so the script (and the crate depending on its output) re-ran on every
1563/// cargo invocation. Unchanged content is now left untouched; a changed or
1564/// deleted file is still (re)written, exactly as before.
1565fn write_if_changed(path: &Path, contents: &str) -> std::io::Result<()> {
1566    if fs::read_to_string(path).is_ok_and(|existing| existing == contents) {
1567        return Ok(());
1568    }
1569    fs::write(path, contents)
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use super::*;
1575
1576    /// Harvests the charset of an FTL source exactly as `generate` does.
1577    fn charset(source: &str) -> String {
1578        let ast = parser::parse(source.to_string()).expect("test FTL must parse");
1579        let mut unique_chars = BTreeSet::new();
1580        for entry in &ast.body {
1581            match entry {
1582                ast::Entry::Message(msg) => harvest_message_chars(msg, &mut unique_chars),
1583                ast::Entry::Term(term) => harvest_term_chars(term, &mut unique_chars),
1584                _ => {}
1585            }
1586        }
1587        unique_chars.into_iter().collect()
1588    }
1589
1590    fn assert_contains_all(charset: &str, expected: &str) {
1591        for c in expected.chars() {
1592            assert!(
1593                charset.contains(c),
1594                "{c:?} missing from charset {charset:?}"
1595            );
1596        }
1597    }
1598
1599    /// The generated `CacheEntry` expression for each message, by id.
1600    fn entries(source: &str) -> HashMap<String, String> {
1601        static_cache_entries(DEFAULT_CRATE_PATH, &"en-US".parse().unwrap(), source, true)
1602            .into_iter()
1603            .collect()
1604    }
1605
1606    fn static_literal(value: &str) -> String {
1607        format!("::fluent_zero::CacheEntry::Static({value:?})")
1608    }
1609
1610    const DYNAMIC: &str = "::fluent_zero::CacheEntry::Dynamic";
1611
1612    #[test]
1613    fn finds_entry_definitions_with_line_numbers() {
1614        let source = "\
1615# comment = not an entry
1616hello = Hi
1617    continued = not an entry
1618-brand=Acme
1619
1620multi =
1621    .attr = x
1622bad id = not an entry
1623";
1624        let found: Vec<_> = entry_definitions(source).collect();
1625        assert_eq!(found, [("hello", 2), ("-brand", 4), ("multi", 6)]);
1626    }
1627
1628    #[test]
1629    fn terms_and_messages_have_separate_namespaces() {
1630        let mut definitions = Definitions::default();
1631        definitions
1632            .record("en-US", Path::new("a.ftl"), "brand = Message\n")
1633            .unwrap();
1634        definitions
1635            .record("en-US", Path::new("b.ftl"), "-brand = Term\n")
1636            .unwrap();
1637    }
1638
1639    #[test]
1640    fn duplicate_across_files_names_both_locations() {
1641        let mut definitions = Definitions::default();
1642        definitions
1643            .record("en-US", Path::new("a.ftl"), "# header\nhello = Hi\n")
1644            .unwrap();
1645        let err = definitions
1646            .record("en-US", Path::new("b.ftl"), "x = 1\n\nhello = Again\n")
1647            .unwrap_err();
1648        assert!(
1649            matches!(&err, Error::DuplicateId { id, lang, first, second }
1650                if id == "hello" && lang == "en-US"
1651                    && first == &(PathBuf::from("a.ftl"), 2)
1652                    && second == &(PathBuf::from("b.ftl"), 3)),
1653            "{err:?}"
1654        );
1655        assert!(
1656            err.to_string().contains(
1657                "`hello` is defined twice in locale `en-US`: first at a.ftl:2, again at b.ftl:3"
1658            ),
1659            "{err}"
1660        );
1661    }
1662
1663    #[test]
1664    fn duplicate_within_one_file_is_rejected() {
1665        let err = Definitions::default()
1666            .record("fr-FR", Path::new("a.ftl"), "-brand = A\n-brand = B\n")
1667            .unwrap_err();
1668        assert!(
1669            err.to_string().contains(
1670                "`-brand` is defined twice in locale `fr-FR`: first at a.ftl:1, again at a.ftl:2"
1671            ),
1672            "{err}"
1673        );
1674    }
1675
1676    #[test]
1677    fn plain_text_is_static() {
1678        assert_eq!(entries("a = Hello\n")["a"], static_literal("Hello"));
1679    }
1680
1681    #[test]
1682    fn quotes_and_backslashes_are_escaped_and_stay_static() {
1683        let entries = entries("q = Say \"hi\"\nb = C:\\path\n");
1684        assert_eq!(
1685            entries["q"],
1686            r#"::fluent_zero::CacheEntry::Static("Say \"hi\"")"#
1687        );
1688        assert_eq!(
1689            entries["b"],
1690            r#"::fluent_zero::CacheEntry::Static("C:\\path")"#
1691        );
1692    }
1693
1694    #[test]
1695    fn multiline_text_is_static() {
1696        let entries = entries("m =\n    First line\n    second line\n");
1697        assert_eq!(entries["m"], static_literal("First line\nsecond line"));
1698    }
1699
1700    #[test]
1701    fn string_literals_are_resolved() {
1702        let entries = entries("brace = Open {\"{\"} here\nnbsp = a{\"\\u00A0\"}b\n");
1703        assert_eq!(entries["brace"], static_literal("Open { here"));
1704        assert_eq!(entries["nbsp"], static_literal("a\u{a0}b"));
1705    }
1706
1707    #[test]
1708    fn number_literals_keep_bundle_isolation_marks() {
1709        // fluent-bundle isolates number placeables in multi-element patterns;
1710        // the static value must match it byte for byte.
1711        assert_eq!(
1712            entries("n = Take { 5 } now\n")["n"],
1713            static_literal("Take \u{2068}5\u{2069} now")
1714        );
1715    }
1716
1717    #[test]
1718    fn static_terms_messages_and_selectors_are_inlined() {
1719        let source = "\
1720-brand = Acme
1721    .gender = neuter
1722about = About { -brand }
1723pronoun = { -brand.gender ->
1724    [neuter] it
1725   *[other] they
1726}
1727title = { about }!
1728";
1729        let entries = entries(source);
1730        assert_eq!(entries["about"], static_literal("About Acme"));
1731        assert_eq!(entries["pronoun"], static_literal("it"));
1732        assert_eq!(entries["title"], static_literal("About Acme!"));
1733    }
1734
1735    #[test]
1736    fn term_arguments_feeding_variables_are_dynamic() {
1737        let source = "\
1738-thing = { $case ->
1739    [gen] thing's
1740   *[nom] thing
1741}
1742a = The { -thing(case: \"gen\") }
1743";
1744        assert_eq!(entries(source)["a"], DYNAMIC);
1745    }
1746
1747    #[test]
1748    fn anything_touching_variables_or_functions_is_dynamic() {
1749        let source = "\
1750v = Hi { $name }
1751f = { NUMBER(5) }
1752s = { $n ->
1753    [one] one
1754   *[other] many
1755}
1756via = Says { v }
1757";
1758        let entries = entries(source);
1759        for id in ["v", "f", "s", "via"] {
1760            assert_eq!(entries[id], DYNAMIC, "{id} should be dynamic");
1761        }
1762    }
1763
1764    #[test]
1765    fn broken_references_and_cycles_stay_dynamic() {
1766        let entries = entries("missing = { nope }\nx = { y }\ny = { x }\nattrs =\n    .a = A\n");
1767        for id in ["missing", "x", "y", "attrs"] {
1768            assert_eq!(entries[id], DYNAMIC, "{id} should be dynamic");
1769        }
1770    }
1771
1772    /// The negotiation aliases for `locales`, as a map.
1773    fn aliases(locales: &[&str]) -> HashMap<String, String> {
1774        let ids: Vec<LanguageIdentifier> = locales.iter().map(|l| l.parse().unwrap()).collect();
1775        locale_aliases(&ids).into_iter().collect()
1776    }
1777
1778    #[test]
1779    fn bare_language_aliases_its_only_locale() {
1780        let aliases = aliases(&["en-US", "ru-RU"]);
1781        assert_eq!(aliases["ru"], "ru-RU");
1782        assert_eq!(aliases["en"], "en-US");
1783        assert_eq!(aliases.len(), 2, "{aliases:?}");
1784    }
1785
1786    #[test]
1787    fn bare_language_prefers_the_likely_region() {
1788        let aliases = aliases(&["pt-PT", "pt-BR", "en-GB", "en-US", "zh-TW", "zh-CN"]);
1789        assert_eq!(aliases["pt"], "pt-BR");
1790        assert_eq!(aliases["en"], "en-US");
1791        assert_eq!(aliases["zh"], "zh-CN");
1792    }
1793
1794    #[test]
1795    fn existing_locales_are_never_aliased() {
1796        let aliases = aliases(&["pt", "pt-BR", "sr-Latn"]);
1797        assert!(!aliases.contains_key("pt"), "{aliases:?}");
1798        assert!(!aliases.contains_key("pt-BR"), "{aliases:?}");
1799        assert!(!aliases.contains_key("sr-Latn"), "{aliases:?}");
1800    }
1801
1802    #[test]
1803    fn script_implying_regions_find_the_script_locale() {
1804        let aliases = aliases(&["zh-Hans", "zh-Hant"]);
1805        for region in ["zh-TW", "zh-HK", "zh-MO"] {
1806            assert_eq!(aliases[region], "zh-Hant", "{region}");
1807        }
1808        assert_eq!(aliases["zh"], "zh-Hans");
1809        // Regions written in the default script are left to the chain.
1810        assert!(!aliases.contains_key("zh-CN"), "{aliases:?}");
1811    }
1812
1813    #[test]
1814    fn script_aliases_point_at_regional_locales() {
1815        let aliases = aliases(&["zh-TW", "zh-CN"]);
1816        assert_eq!(aliases["zh-Hant"], "zh-TW");
1817        assert_eq!(aliases["zh-HK"], "zh-TW");
1818        assert_eq!(aliases["zh"], "zh-CN");
1819    }
1820
1821    #[test]
1822    fn script_aliases_never_cross_scripts() {
1823        // Only a Traditional locale: Simplified-script keys must not alias to
1824        // it, the bare language is enough.
1825        let aliases = aliases(&["zh-TW"]);
1826        assert_eq!(aliases["zh"], "zh-TW");
1827        assert!(!aliases.contains_key("zh-Hans"), "{aliases:?}");
1828        assert!(!aliases.contains_key("zh-SG"), "{aliases:?}");
1829    }
1830
1831    #[test]
1832    fn bare_language_falls_back_across_scripts() {
1833        let aliases = aliases(&["zh-Hant", "en-US"]);
1834        assert_eq!(aliases["zh"], "zh-Hant");
1835        assert_eq!(aliases["zh-TW"], "zh-Hant");
1836    }
1837
1838    #[test]
1839    fn no_locales_no_aliases() {
1840        assert!(aliases(&[]).is_empty());
1841    }
1842
1843    #[test]
1844    fn fallback_key_is_canonicalised() {
1845        let builder = FluentZeroBuilder::new("locales").fallback_locale("en-gb");
1846        assert_eq!(builder.fallback_key().unwrap(), "en-GB");
1847        assert_eq!(
1848            FluentZeroBuilder::new("locales").fallback_key().unwrap(),
1849            "en-US"
1850        );
1851    }
1852
1853    #[test]
1854    fn fallback_must_be_an_available_locale() {
1855        let available: Vec<LanguageIdentifier> = ["fr-FR", "en-US"]
1856            .iter()
1857            .map(|l| l.parse().unwrap())
1858            .collect();
1859        assert_eq!(check_fallback("en-US", &available), Ok(()));
1860        let available = check_fallback("en-GB", &available).unwrap_err();
1861        assert_eq!(available, ["en-US", "fr-FR"]);
1862        // A crate without locales of its own accepts any fallback.
1863        assert_eq!(check_fallback("en-GB", &[]), Ok(()));
1864
1865        let err = Error::MissingFallbackLocale {
1866            locale: "en-GB".to_string(),
1867            locales_dir: PathBuf::from("locales"),
1868            available,
1869        };
1870        assert_eq!(
1871            err.to_string(),
1872            "fallback locale `en-GB` has no directory in locales (available: en-US, fr-FR)"
1873        );
1874    }
1875
1876    #[test]
1877    fn invalid_fallback_is_an_error_before_anything_is_written() {
1878        // Checked before `OUT_DIR`, which unit tests do not have.
1879        let err = FluentZeroBuilder::new("locales")
1880            .fallback_locale("not a locale")
1881            .try_generate()
1882            .unwrap_err();
1883        assert!(
1884            matches!(&err, Error::InvalidFallbackLocale { locale, .. } if locale == "not a locale"),
1885            "{err:?}"
1886        );
1887        assert!(
1888            err.to_string()
1889                .starts_with("fallback locale `not a locale` is not a valid language identifier: "),
1890            "{err}"
1891        );
1892    }
1893
1894    #[test]
1895    #[should_panic(expected = "fluent-zero-build: fallback locale `not a locale` is not a valid")]
1896    fn generate_panics_with_the_error_message() {
1897        FluentZeroBuilder::new("locales")
1898            .fallback_locale("not a locale")
1899            .generate();
1900    }
1901
1902    fn syntax_errors(source: &str) -> String {
1903        let (_, errors) = parser::parse(source).expect_err("test FTL must not parse");
1904        syntax_report(Path::new("locales/en-US/main.ftl"), source, &errors)
1905    }
1906
1907    #[test]
1908    fn syntax_errors_report_path_line_column_and_line() {
1909        let report = syntax_errors("ok = Fine\nbroken Value\n");
1910        assert_eq!(
1911            report,
1912            "locales/en-US/main.ftl:2:8: Expected a token starting with \"=\"\n\
1913             2 | broken Value\n\
1914             \x20 |        ^"
1915        );
1916        assert!(!report.contains("Junk"), "AST leaked: {report}");
1917    }
1918
1919    #[test]
1920    fn syntax_errors_count_columns_in_characters_and_list_every_error() {
1921        let report = syntax_errors("a = ok\nx = Ü { 1 2 }\nb Wert\n");
1922        assert!(report.contains("main.ftl:2:11: "), "{report}");
1923        assert!(
1924            report.contains("2 | x = Ü { 1 2 }\n  |           ^"),
1925            "{report}"
1926        );
1927        assert!(report.contains("main.ftl:3:3: "), "{report}");
1928    }
1929
1930    #[test]
1931    fn syntax_error_fails_the_build_with_the_report() {
1932        let dir = env::temp_dir().join(format!("fluent-zero-build-test-{}", std::process::id()));
1933        fs::create_dir_all(&dir).unwrap();
1934        let file = dir.join("bad.ftl");
1935        fs::write(&file, "bad Value\n").unwrap();
1936        let err = FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut BTreeSet::new(), true)
1937            .expect_err("a syntax error must fail the build");
1938        fs::remove_dir_all(&dir).unwrap();
1939        let Error::Parse { path, message } = &err else {
1940            panic!("expected a parse error, got {err:?}");
1941        };
1942        assert_eq!(path, &file);
1943        assert!(
1944            message.starts_with(&format!("{}:1:5: Expected a token", file.display())),
1945            "{message}"
1946        );
1947        assert_eq!(
1948            err.to_string(),
1949            format!("failed to parse {}:\n{message}", file.display())
1950        );
1951        assert!(std::error::Error::source(&err).is_none());
1952    }
1953
1954    fn ids(lang: &str, ids: &[&str]) -> (String, BTreeSet<String>) {
1955        (
1956            lang.to_string(),
1957            ids.iter().map(ToString::to_string).collect(),
1958        )
1959    }
1960
1961    #[test]
1962    fn coverage_lists_missing_ids_per_locale_sorted() {
1963        let report = coverage_report(
1964            "en-US",
1965            &[
1966                ids("fr-FR", &["a"]),
1967                ids("en-US", &["a", "b", "c"]),
1968                ids("de", &["a", "b", "c", "extra"]),
1969                ids("cs", &["c"]),
1970            ],
1971        );
1972        assert_eq!(
1973            report,
1974            [
1975                "cs is missing 2 of 3 messages from en-US: a, b",
1976                "fr-FR is missing 2 of 3 messages from en-US: b, c",
1977            ]
1978        );
1979    }
1980
1981    #[test]
1982    fn coverage_truncates_long_lists() {
1983        let all: Vec<String> = (0..8).map(|i| format!("m{i}")).collect();
1984        let all: Vec<&str> = all.iter().map(String::as_str).collect();
1985        let report = coverage_report("en-US", &[ids("en-US", &all), ids("fr", &[])]);
1986        assert_eq!(
1987            report,
1988            ["fr is missing 8 of 8 messages from en-US: m0, m1, m2, m3, m4 and 3 more"]
1989        );
1990    }
1991
1992    #[test]
1993    fn coverage_is_silent_without_fallback_or_gaps() {
1994        assert!(coverage_report("en-US", &[ids("fr", &["a"])]).is_empty());
1995        assert!(coverage_report("en-US", &[ids("en-US", &["a"]), ids("fr", &["a"])]).is_empty());
1996    }
1997
1998    #[test]
1999    fn coverage_deny_fails_and_allow_is_silent() {
2000        let ids = [ids("en-US", &["a"]), ids("fr", &[])];
2001        FluentZeroBuilder::default()
2002            .missing_translations(MissingTranslations::Allow)
2003            .check_coverage("en-US", &ids)
2004            .unwrap();
2005        let err = FluentZeroBuilder::default()
2006            .missing_translations(MissingTranslations::Deny)
2007            .check_coverage("en-US", &ids)
2008            .expect_err("deny must fail the build");
2009        assert!(
2010            matches!(&err, Error::MissingTranslations { report } if report.len() == 1),
2011            "{err:?}"
2012        );
2013        assert_eq!(
2014            err.to_string(),
2015            "missing translations:\nfr is missing 1 of 1 messages from en-US: a"
2016        );
2017    }
2018
2019    #[test]
2020    fn coverage_compares_against_the_configured_fallback() {
2021        let ids = [
2022            ids("en-US", &["a", "b"]),
2023            ids("en-GB", &["a"]),
2024            ids("fr", &[]),
2025        ];
2026        let builder = FluentZeroBuilder::default()
2027            .fallback_locale("en-GB")
2028            .missing_translations(MissingTranslations::Deny);
2029        let err = builder
2030            .check_coverage(&builder.fallback_key().unwrap(), &ids)
2031            .expect_err("deny must fail the build");
2032        assert_eq!(
2033            err.to_string(),
2034            "missing translations:\nfr is missing 1 of 1 messages from en-GB: a"
2035        );
2036    }
2037
2038    fn const_names(keys: &[&str]) -> Vec<(String, String)> {
2039        let keys: BTreeSet<String> = keys.iter().map(ToString::to_string).collect();
2040        key_const_names(&keys)
2041            .into_iter()
2042            .map(|(name, key)| (key.to_string(), name))
2043            .collect()
2044    }
2045
2046    #[test]
2047    fn key_constants_are_screaming_snake_case() {
2048        assert_eq!(key_const_base("app-title"), "APP_TITLE");
2049        assert_eq!(key_const_base("btn_submit"), "BTN_SUBMIT");
2050        assert_eq!(key_const_base("menuTitle2"), "MENUTITLE2");
2051        assert_eq!(key_const_base("login.tooltip"), "LOGIN_TOOLTIP");
2052    }
2053
2054    #[test]
2055    fn key_constants_are_always_valid_identifiers() {
2056        assert_eq!(key_const_base("1st"), "KEY_1ST");
2057        assert_eq!(key_const_base("-x"), "KEY__X");
2058        assert_eq!(key_const_base(""), "KEY_");
2059        assert_eq!(key_const_base("héllo"), "H_LLO");
2060    }
2061
2062    #[test]
2063    fn colliding_keys_get_deterministic_suffixes() {
2064        // Byte order: "a-b" < "aB" < "a_b"; the first takes the plain name.
2065        assert_eq!(
2066            const_names(&["a_b", "aB", "a-b"]),
2067            [
2068                ("a-b".to_string(), "A_B".to_string()),
2069                ("aB".to_string(), "AB".to_string()),
2070                ("a_b".to_string(), "A_B_2".to_string()),
2071            ]
2072        );
2073    }
2074
2075    #[test]
2076    fn suffixes_never_take_another_keys_plain_name() {
2077        // `a_b-2` owns `A_B_2`, so the clash between `a-b` and `a_b` moves on to `_3`.
2078        let names = const_names(&["a-b", "a_b", "a_b-2"]);
2079        assert_eq!(
2080            names,
2081            [
2082                ("a-b".to_string(), "A_B".to_string()),
2083                ("a_b".to_string(), "A_B_3".to_string()),
2084                ("a_b-2".to_string(), "A_B_2".to_string()),
2085            ]
2086        );
2087    }
2088
2089    #[test]
2090    fn message_sources_document_value_and_attributes_without_comments() {
2091        let sources = message_sources("# note\nlogin = Log in\n    .tooltip = Hi { $name }\n");
2092        assert_eq!(
2093            sources["login"],
2094            "login = Log in\n    .tooltip = Hi { $name }"
2095        );
2096    }
2097
2098    #[test]
2099    fn message_sources_document_each_attribute_on_its_own() {
2100        let sources = message_sources(
2101            "login = Log in\n    .tooltip = Hi\n    .label = Go\n    .tooltip = Dup\nsearch =\n    .placeholder = Find\n",
2102        );
2103        assert_eq!(sources["login.tooltip"], "login =\n    .tooltip = Hi");
2104        assert_eq!(sources["login.label"], "login =\n    .label = Go");
2105        assert_eq!(
2106            sources["search.placeholder"],
2107            "search =\n    .placeholder = Find"
2108        );
2109        assert!(sources.contains_key("search"));
2110    }
2111
2112    #[test]
2113    fn attribute_keys_get_constants() {
2114        assert_eq!(
2115            const_names(&["login", "login.tooltip", "search.aria-label"]),
2116            [
2117                ("login".to_string(), "LOGIN".to_string()),
2118                ("login.tooltip".to_string(), "LOGIN_TOOLTIP".to_string()),
2119                (
2120                    "search.aria-label".to_string(),
2121                    "SEARCH_ARIA_LABEL".to_string()
2122                ),
2123            ]
2124        );
2125    }
2126
2127    #[test]
2128    fn fenced_text_outlasts_backtick_runs() {
2129        assert_eq!(fenced_text("a = b"), "```text\na = b\n```");
2130        assert_eq!(fenced_text("a = ````"), "`````text\na = ````\n`````");
2131    }
2132
2133    #[test]
2134    fn attributes_get_dotted_keys_and_static_values() {
2135        let source = "\
2136-brand = Acme
2137login = Log in
2138    .tooltip = Sign in to { -brand }
2139    .aria = { login }
2140    .hint = Hi { $name }
2141";
2142        let entries = entries(source);
2143        assert_eq!(entries["login"], static_literal("Log in"));
2144        assert_eq!(entries["login.tooltip"], static_literal("Sign in to Acme"));
2145        assert_eq!(entries["login.aria"], static_literal("Log in"));
2146        assert_eq!(entries["login.hint"], DYNAMIC);
2147    }
2148
2149    #[test]
2150    fn attribute_only_messages_get_attribute_entries() {
2151        let entries =
2152            entries("field =\n    .placeholder = Name\n    .label = { field.placeholder }:\n");
2153        // The bare id has no value; the runtime falls through it as before.
2154        assert_eq!(entries["field"], DYNAMIC);
2155        assert_eq!(entries["field.placeholder"], static_literal("Name"));
2156        assert_eq!(entries["field.label"], static_literal("Name:"));
2157    }
2158
2159    #[test]
2160    fn repeated_attribute_names_keep_the_first() {
2161        let source = "m = M\n    .a = First\n    .a = Second\n";
2162        let generated =
2163            static_cache_entries(DEFAULT_CRATE_PATH, &"en-US".parse().unwrap(), source, true);
2164        let keys: Vec<&str> = generated.iter().map(|(k, _)| k.as_str()).collect();
2165        assert_eq!(keys, ["m", "m.a"]);
2166        assert_eq!(generated[1].1, static_literal("First"));
2167    }
2168
2169    #[test]
2170    fn harvests_plain_text() {
2171        assert_eq!(charset("hello = Hi you\n"), " Hiouy");
2172    }
2173
2174    #[test]
2175    fn harvests_every_select_variant() {
2176        let charset = charset("files = { $n ->\n    [one] Ёж\n   *[other] Щука\n}\n");
2177        assert_contains_all(&charset, "ЁжЩука");
2178    }
2179
2180    #[test]
2181    fn harvests_nested_selects() {
2182        let source = "\
2183toggle = { $side ->
2184    [left] { $shown ->
2185        [true] ▶ 左
2186       *[false] ◀ 左
2187    }
2188   *[right] 右侧
2189}
2190";
2191        assert_contains_all(&charset(source), "▶◀左右侧");
2192    }
2193
2194    #[test]
2195    fn harvests_attributes() {
2196        let charset = charset("login = Log in\n    .tooltip = Ẩn\n");
2197        assert_contains_all(&charset, "Ẩ");
2198    }
2199
2200    #[test]
2201    fn harvests_message_with_only_attributes() {
2202        let charset = charset("login =\n    .placeholder = 왼쪽\n");
2203        assert_contains_all(&charset, "왼쪽");
2204    }
2205
2206    #[test]
2207    fn harvests_terms_and_their_attributes() {
2208        let charset = charset("-brand = Ωmega\n    .short = Ж\n");
2209        assert_contains_all(&charset, "ΩmegaЖ");
2210    }
2211
2212    #[test]
2213    fn harvests_unescaped_string_literals() {
2214        let charset = charset("price = {\"\\u00A0\"}{\"€\"}\n");
2215        assert_contains_all(&charset, "\u{a0}€");
2216        assert!(!charset.contains('\\'), "escape syntax leaked: {charset:?}");
2217    }
2218
2219    #[test]
2220    fn harvests_number_literals() {
2221        assert_contains_all(&charset("n = { 42 }\n"), "42");
2222    }
2223
2224    #[test]
2225    fn skips_identifiers_variables_and_named_options() {
2226        let source = "\
2227n = { NUMBER($count, style: \"percent\") ->
2228    [one] a
2229   *[other] b
2230}
2231";
2232        assert_eq!(charset(source), "ab");
2233    }
2234
2235    #[test]
2236    fn number_literals_are_bare_without_isolation() {
2237        let entries: HashMap<_, _> = static_cache_entries(
2238            DEFAULT_CRATE_PATH,
2239            &"en-US".parse().unwrap(),
2240            "n = Take { 5 } now\n",
2241            false,
2242        )
2243        .into_iter()
2244        .collect();
2245        assert_eq!(entries["n"], static_literal("Take 5 now"));
2246    }
2247
2248    fn isolates(source: &str) -> bool {
2249        let ast = parser::parse(source.to_string()).expect("test FTL must parse");
2250        ast.body.iter().any(|entry| match entry {
2251            ast::Entry::Message(msg) => msg
2252                .value
2253                .iter()
2254                .chain(msg.attributes.iter().map(|a| &a.value))
2255                .any(pattern_isolates),
2256            ast::Entry::Term(term) => pattern_isolates(&term.value),
2257            _ => false,
2258        })
2259    }
2260
2261    #[test]
2262    fn detects_patterns_that_emit_isolation_marks() {
2263        assert!(isolates("a = Hi { $name }\n"));
2264        assert!(isolates("a = Take { 5 } now\n"));
2265        assert!(isolates("a =\n    .tip = Hi { $name }\n"));
2266        assert!(isolates(
2267            "a = { $n ->\n    [one] { $n } file\n   *[other] many\n}\n"
2268        ));
2269        assert!(isolates("-t = x { $y }\n"));
2270    }
2271
2272    #[test]
2273    fn ignores_patterns_that_never_emit_isolation_marks() {
2274        assert!(!isolates("a = Plain text\n"));
2275        assert!(!isolates("a = { $name }\n"));
2276        assert!(!isolates("a = About { -brand } and { b }\n"));
2277        assert!(!isolates("a = Open {\"{\"} here\n"));
2278        assert!(!isolates(
2279            "a = { $n ->\n    [one] { $n }\n   *[other] many\n}\n"
2280        ));
2281    }
2282
2283    /// A fresh, empty directory under the system temp dir.
2284    fn temp_dir(name: &str) -> PathBuf {
2285        let dir = env::temp_dir().join(format!(
2286            "fluent-zero-build-test-{}-{name}",
2287            std::process::id()
2288        ));
2289        let _ = fs::remove_dir_all(&dir);
2290        fs::create_dir_all(&dir).unwrap();
2291        dir
2292    }
2293
2294    #[test]
2295    fn locale_files_are_combined_in_sorted_order() {
2296        let dir = temp_dir("sorted");
2297        // Created out of order so creation order cannot pass for sorting.
2298        for name in ["c.ftl", "a.ftl", "b.ftl"] {
2299            let id = name.trim_end_matches(".ftl");
2300            fs::write(dir.join(name), format!("{id} = {id}\n")).unwrap();
2301        }
2302        fs::write(dir.join("notes.txt"), "ignored").unwrap();
2303
2304        let entries = sorted_dir_entries(&dir).unwrap();
2305        let names: Vec<_> = entries.iter().map(|p| p.file_name().unwrap()).collect();
2306        assert_eq!(names, ["a.ftl", "b.ftl", "c.ftl", "notes.txt"]);
2307
2308        let source =
2309            FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut BTreeSet::new(), true)
2310                .unwrap();
2311        assert_eq!(source, "a = a\n\nb = b\n\nc = c\n\n");
2312        fs::remove_dir_all(&dir).unwrap();
2313    }
2314
2315    #[test]
2316    fn isolation_marks_join_the_charset_only_when_they_can_render() {
2317        let dir = temp_dir("isolation");
2318        fs::write(dir.join("main.ftl"), "a = Hi { $name }\n").unwrap();
2319        let mut on = BTreeSet::new();
2320        FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut on, true).unwrap();
2321        assert!(on.contains(&'\u{2068}') && on.contains(&'\u{2069}'));
2322
2323        let mut off = BTreeSet::new();
2324        FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut off, false).unwrap();
2325        assert!(!off.contains(&'\u{2068}') && !off.contains(&'\u{2069}'));
2326
2327        fs::write(dir.join("main.ftl"), "a = { $name }\nb = Plain\n").unwrap();
2328        let mut single = BTreeSet::new();
2329        FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut single, true).unwrap();
2330        assert!(!single.contains(&'\u{2068}'));
2331        fs::remove_dir_all(&dir).unwrap();
2332    }
2333
2334    #[test]
2335    fn dependency_charset_paths_are_filtered_and_sorted() {
2336        let vars = [
2337            ("DEP_ZED_FLUENT_CHARSET_PATH", "/z.txt"),
2338            ("PATH", "/usr/bin"),
2339            ("DEP_ZED_OTHER", "/nope"),
2340            ("DEP_ABC_FLUENT_CHARSET_PATH", "/a.txt"),
2341        ]
2342        .map(|(k, v)| (OsString::from(k), OsString::from(v)));
2343        assert_eq!(dependency_charset_paths(vars), ["/a.txt", "/z.txt"]);
2344    }
2345
2346    #[cfg(unix)]
2347    #[test]
2348    fn non_utf8_environment_entries_are_skipped() {
2349        use std::os::unix::ffi::OsStringExt as _;
2350        let bad = || OsString::from_vec(vec![b'D', b'E', b'P', 0xff]);
2351        let vars = vec![
2352            (bad(), OsString::from("/x.txt")),
2353            (OsString::from("DEP_A_FLUENT_CHARSET_PATH"), bad()),
2354            (
2355                OsString::from("DEP_B_FLUENT_CHARSET_PATH"),
2356                OsString::from("/b.txt"),
2357            ),
2358        ];
2359        assert_eq!(dependency_charset_paths(vars), ["/b.txt"]);
2360    }
2361
2362    #[test]
2363    fn missing_locales_dir_watches_closest_existing_ancestor() {
2364        let root = temp_dir("watch");
2365        let out_dir = root.join("target/out");
2366        fs::create_dir_all(&out_dir).unwrap();
2367        fs::create_dir_all(root.join("pkg/assets")).unwrap();
2368
2369        assert_eq!(
2370            missing_dir_watch_path(&root.join("pkg/assets/locales"), &out_dir),
2371            Some(root.join("pkg/assets"))
2372        );
2373        assert_eq!(
2374            missing_dir_watch_path(&root.join("pkg/missing/deeper/locales"), &out_dir),
2375            Some(root.join("pkg"))
2376        );
2377        // `root` contains OUT_DIR: watching it would never be fresh.
2378        assert_eq!(
2379            missing_dir_watch_path(&root.join("locales"), &out_dir),
2380            None
2381        );
2382        fs::remove_dir_all(&root).unwrap();
2383    }
2384
2385    #[test]
2386    fn lexical_containment_resolves_dot_segments() {
2387        let out = Path::new("/t/debug/build/x/out");
2388        assert!(lexically_inside(
2389            Path::new("/t/debug/build/x/out/c.txt"),
2390            out
2391        ));
2392        assert!(lexically_inside(
2393            Path::new("/t/debug/build/x/out/./a/../c.txt"),
2394            out
2395        ));
2396        assert!(!lexically_inside(Path::new("/pkg/target/chars.txt"), out));
2397        assert!(!lexically_inside(
2398            Path::new("/t/debug/build/x/out/../c.txt"),
2399            out
2400        ));
2401    }
2402
2403    #[test]
2404    fn crate_path_prefixes_cache_entries() {
2405        let entries: HashMap<_, _> = static_cache_entries(
2406            "::fz",
2407            &"en-US".parse().unwrap(),
2408            "a = A\nb = { $x }\n",
2409            true,
2410        )
2411        .into_iter()
2412        .collect();
2413        assert_eq!(entries["a"], r#"::fz::CacheEntry::Static("A")"#);
2414        assert_eq!(entries["b"], "::fz::CacheEntry::Dynamic");
2415    }
2416
2417    #[test]
2418    fn crate_path_prefixes_generated_items() {
2419        let mut out = Vec::new();
2420        FluentZeroBuilder::write_bundle_initializer(&mut out, "::fz", "B", "en-US", "a = A", false)
2421            .unwrap();
2422        FluentZeroBuilder::write_cache_map(&mut out, "::fz", "CACHE_EN_US", &[]).unwrap();
2423        FluentZeroBuilder::write_root_maps(&mut out, "::fz", &[], &[]).unwrap();
2424        FluentZeroBuilder::write_catalog(&mut out, "::fz", "en-US", &[]).unwrap();
2425        FluentZeroBuilder::write_keys(&mut out, &BTreeSet::new(), "en-US", &HashMap::new())
2426            .unwrap();
2427        let out = String::from_utf8(out).unwrap();
2428        assert!(!out.contains("fluent_zero"), "default path leaked: {out}");
2429        assert!(out.contains("::fz::__private::ConcurrentFluentBundle"));
2430        assert!(out.contains("::fz::__private::phf::Map"));
2431        assert!(out.contains("::fz::__private::CatalogLocale"));
2432        assert!(out.contains("pub static CATALOG: ::fz::Catalog = ::fz::__private::catalog("));
2433    }
2434
2435    #[test]
2436    fn validates_crate_paths() {
2437        for ok in ["::fluent_zero", "fz", "crate::reexports::fz", "_fz2"] {
2438            assert!(is_rust_path(ok), "{ok:?} should be accepted");
2439        }
2440        for bad in [
2441            "",
2442            "::",
2443            "fz::",
2444            "a::::b",
2445            "2fz",
2446            "fluent-zero",
2447            "_",
2448            "fz;",
2449            "::a b",
2450        ] {
2451            assert!(!is_rust_path(bad), "{bad:?} should be rejected");
2452        }
2453    }
2454
2455    #[test]
2456    fn invalid_crate_path_is_an_error_not_a_panic() {
2457        let err = FluentZeroBuilder::new("locales")
2458            .crate_path("fluent-zero")
2459            .try_generate()
2460            .unwrap_err();
2461        assert!(matches!(&err, Error::InvalidCratePath(p) if p == "fluent-zero"));
2462        assert!(err.to_string().contains("\"fluent-zero\""), "{err}");
2463    }
2464
2465    #[test]
2466    fn io_errors_carry_path_and_source() {
2467        let err = io_err(Path::new("x/y.ftl"))(io::Error::new(io::ErrorKind::NotFound, "gone"));
2468        assert_eq!(
2469            err.to_string(),
2470            format!("{}: gone", Path::new("x/y.ftl").display())
2471        );
2472        assert!(std::error::Error::source(&err).is_some());
2473    }
2474
2475    #[test]
2476    fn unreadable_locale_dir_is_an_io_error() {
2477        let missing = temp_dir("unreadable").join("missing");
2478        let err =
2479            FluentZeroBuilder::process_locale_dir(&missing, "en-US", &mut BTreeSet::new(), true)
2480                .unwrap_err();
2481        assert!(
2482            matches!(&err, Error::Io { path, .. } if path == &missing),
2483            "{err:?}"
2484        );
2485        fs::remove_dir_all(missing.parent().unwrap()).unwrap();
2486    }
2487}