1#![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
46const CODEGEN_VERSION: u32 = 1;
55
56const DEFAULT_CRATE_PATH: &str = "::fluent_zero";
59
60const DEFAULT_FALLBACK: &str = "en-US";
63
64const COVERAGE_SAMPLE: usize = 5;
66
67#[derive(Debug)]
72#[non_exhaustive]
73pub enum Error {
74 MissingOutDir,
77 InvalidCratePath(String),
79 InvalidFallbackLocale {
82 locale: String,
84 reason: String,
86 },
87 MissingFallbackLocale {
90 locale: String,
92 locales_dir: PathBuf,
94 available: Vec<String>,
96 },
97 Io {
99 path: PathBuf,
101 source: io::Error,
103 },
104 DuplicateId {
106 id: String,
108 lang: String,
110 first: (PathBuf, usize),
112 second: (PathBuf, usize),
114 },
115 Parse {
117 path: PathBuf,
119 message: String,
122 },
123 MissingTranslations {
125 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
190fn 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
201pub enum MissingTranslations {
202 Allow,
204 #[default]
206 Warn,
207 Deny,
210}
211
212#[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 #[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 #[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 #[must_use]
264 pub fn missing_translations(mut self, level: MissingTranslations) -> Self {
265 self.missing_translations = level;
266 self
267 }
268
269 #[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 #[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 #[must_use]
313 pub fn use_isolating(mut self, use_isolating: bool) -> Self {
314 self.use_isolating = use_isolating;
315 self
316 }
317
318 pub fn generate(self) {
329 if let Err(err) = self.try_generate() {
330 panic!("fluent-zero-build: {err}");
331 }
332 }
333
334 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 for charset_path in dependency_charset_paths(env::vars_os()) {
368 if let Ok(content) = fs::read_to_string(&charset_path) {
369 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 if self.locales_dir.exists() {
387 println!("cargo:rerun-if-changed={}", self.locales_dir.display());
388
389 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 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 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 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 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 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 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 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 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 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 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 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 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
803fn 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
818fn table_suffix(lang_key: &str) -> String {
820 lang_key.replace('-', "_").to_uppercase()
821}
822
823fn 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
835fn locale_aliases(available: &[LanguageIdentifier]) -> Vec<(String, String)> {
857 let mut available = available.to_vec();
858 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 negotiated.or_else(|| {
903 available
904 .iter()
905 .find(|id| id.language == requested.language)
906 })?
907 } else {
908 negotiated.filter(|target| likely_script(target) == likely_script(&requested))?
911 };
912 Some((requested.to_string(), target.to_string()))
913 })
914 .collect()
915}
916
917fn likely_script(id: &LanguageIdentifier) -> Option<subtags::Script> {
919 let mut id = id.clone();
920 id.maximize();
921 id.script
922}
923
924fn 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
935fn 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 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
969fn 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#[derive(Default)]
1008struct Definitions {
1009 locations: HashMap<String, (PathBuf, usize)>,
1012}
1013
1014impl Definitions {
1015 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
1038fn 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
1056fn static_cache_entries(
1073 krate: &str,
1074 lang_id: &LanguageIdentifier,
1075 source: &str,
1076 use_isolating: bool,
1077) -> Vec<(String, String)> {
1078 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 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
1117fn 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 Some(value) => format!("{krate}::CacheEntry::Static({value:?})"),
1133 None => format!("{krate}::CacheEntry::Dynamic"),
1134 }
1135}
1136
1137struct StaticAnalysis<'r> {
1139 messages: HashMap<&'r str, &'r ast::Message<&'r str>>,
1140 terms: HashMap<&'r str, &'r ast::Term<&'r str>>,
1141 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 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 ast::InlineExpression::FunctionReference { .. }
1240 | ast::InlineExpression::VariableReference { .. } => false,
1241 }
1242 }
1243}
1244
1245fn 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
1286fn 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
1300fn 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
1323fn 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
1363fn 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
1374fn 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 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 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 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 ast::InlineExpression::MessageReference { .. }
1430 | ast::InlineExpression::TermReference { .. }
1431 | ast::InlineExpression::VariableReference { .. } => {}
1432 }
1433}
1434
1435fn 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
1475fn 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
1489fn 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
1507fn 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
1527fn 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
1546pub fn generate_static_cache(locales_dir_path: &str) {
1554 FluentZeroBuilder::new(locales_dir_path).generate();
1555}
1556
1557fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}