#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use std::{
collections::{BTreeSet, HashMap, HashSet},
env,
ffi::OsString,
fmt::{self, Write as _},
fs,
io::{self, Write},
path::{Path, PathBuf},
};
use fluent_bundle::{FluentBundle, FluentResource};
use fluent_langneg::{NegotiationStrategy, negotiate_languages};
use fluent_syntax::{ast, parser, serializer, unicode::unescape_unicode_to_string};
use unic_langid::{LanguageIdentifier, subtags};
const CODEGEN_VERSION: u32 = 1;
const DEFAULT_CRATE_PATH: &str = "::fluent_zero";
const DEFAULT_FALLBACK: &str = "en-US";
const COVERAGE_SAMPLE: usize = 5;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
MissingOutDir,
InvalidCratePath(String),
InvalidFallbackLocale {
locale: String,
reason: String,
},
MissingFallbackLocale {
locale: String,
locales_dir: PathBuf,
available: Vec<String>,
},
Io {
path: PathBuf,
source: io::Error,
},
DuplicateId {
id: String,
lang: String,
first: (PathBuf, usize),
second: (PathBuf, usize),
},
Parse {
path: PathBuf,
message: String,
},
MissingTranslations {
report: Vec<String>,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingOutDir => {
f.write_str("OUT_DIR is not set; fluent-zero-build must run from a build script")
}
Self::InvalidCratePath(path) => write!(
f,
"invalid crate path {path:?}: expected a Rust path such as \"::fluent_zero\""
),
Self::InvalidFallbackLocale { locale, reason } => write!(
f,
"fallback locale `{locale}` is not a valid language identifier: {reason}"
),
Self::MissingFallbackLocale {
locale,
locales_dir,
available,
} => write!(
f,
"fallback locale `{locale}` has no directory in {} (available: {})",
locales_dir.display(),
available.join(", ")
),
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::DuplicateId {
id,
lang,
first,
second,
} => write!(
f,
"`{id}` is defined twice in locale `{lang}`: \
first at {}:{}, again at {}:{}. \
Fluent bundles allow one definition per ID; rename or remove one.",
first.0.display(),
first.1,
second.0.display(),
second.1,
),
Self::Parse { path, message } => {
write!(f, "failed to parse {}:\n{message}", path.display())
}
Self::MissingTranslations { report } => {
write!(f, "missing translations:\n{}", report.join("\n"))
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
fn io_err(path: &Path) -> impl FnOnce(io::Error) -> Error + '_ {
move |source| Error::Io {
path: path.to_path_buf(),
source,
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MissingTranslations {
Allow,
#[default]
Warn,
Deny,
}
#[derive(Debug, Clone)]
pub struct FluentZeroBuilder {
locales_dir: PathBuf,
charset_dest: Option<PathBuf>,
crate_path: String,
fallback_locale: Option<String>,
missing_translations: MissingTranslations,
use_isolating: bool,
}
impl Default for FluentZeroBuilder {
fn default() -> Self {
Self::new("assets/locales")
}
}
impl FluentZeroBuilder {
#[must_use]
pub fn new<P: AsRef<Path>>(locales_dir_path: P) -> Self {
Self {
locales_dir: locales_dir_path.as_ref().to_path_buf(),
charset_dest: None,
crate_path: DEFAULT_CRATE_PATH.to_string(),
fallback_locale: None,
missing_translations: MissingTranslations::default(),
use_isolating: true,
}
}
#[must_use]
pub fn crate_path(mut self, path: impl Into<String>) -> Self {
self.crate_path = path.into();
self
}
#[must_use]
pub fn missing_translations(mut self, level: MissingTranslations) -> Self {
self.missing_translations = level;
self
}
#[must_use]
pub fn export_charset<P: AsRef<Path>>(mut self, dest: P) -> Self {
self.charset_dest = Some(dest.as_ref().to_path_buf());
self
}
#[must_use]
pub fn fallback_locale(mut self, locale: &str) -> Self {
self.fallback_locale = Some(locale.to_string());
self
}
#[must_use]
pub fn use_isolating(mut self, use_isolating: bool) -> Self {
self.use_isolating = use_isolating;
self
}
pub fn generate(self) {
if let Err(err) = self.try_generate() {
panic!("fluent-zero-build: {err}");
}
}
pub fn try_generate(self) -> Result<(), Error> {
if !is_rust_path(&self.crate_path) {
return Err(Error::InvalidCratePath(self.crate_path));
}
let fallback = self.fallback_key()?;
let out_dir = env::var("OUT_DIR").map_err(|_| Error::MissingOutDir)?;
let dest_path = Path::new(&out_dir).join("static_cache.rs");
let mut file = fs::File::create(&dest_path).map_err(io_err(&dest_path))?;
let krate = self.crate_path.as_str();
writeln!(
&mut file,
"// @generated by fluent-zero-build\n\
const _: () = {krate}::__private::assert_codegen_version({CODEGEN_VERSION});"
)
.map_err(io_err(&dest_path))?;
let mut unique_chars: BTreeSet<char> = BTreeSet::new();
for charset_path in dependency_charset_paths(env::vars_os()) {
if let Ok(content) = fs::read_to_string(&charset_path) {
println!("cargo:rerun-if-changed={charset_path}");
extend_visible(&mut unique_chars, &content);
}
}
let mut bundle_entries: Vec<(String, String)> = Vec::new();
let mut cache_root_entries: Vec<(String, String)> = Vec::new();
let mut available: Vec<LanguageIdentifier> = Vec::new();
let mut message_ids: Vec<(String, BTreeSet<String>)> = Vec::new();
let mut all_keys: BTreeSet<String> = BTreeSet::new();
let mut fallback_sources: HashMap<String, String> = HashMap::new();
if self.locales_dir.exists() {
println!("cargo:rerun-if-changed={}", self.locales_dir.display());
for path in sorted_dir_entries(&self.locales_dir)? {
if !path.is_dir() {
continue;
}
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Ok(lang_id) = dir_name.parse::<LanguageIdentifier>() else {
continue;
};
let lang_key = lang_id.to_string();
let sanitized_lang = table_suffix(&lang_key);
let cache_name = format!("CACHE_{sanitized_lang}");
let bundle_name = format!("BUNDLE_{sanitized_lang}");
let combined_ftl_source = Self::process_locale_dir(
&path,
&lang_key,
&mut unique_chars,
self.use_isolating,
)?;
let cache_entries =
static_cache_entries(krate, &lang_id, &combined_ftl_source, self.use_isolating);
all_keys.extend(cache_entries.iter().map(|(key, _)| key.clone()));
if lang_key == fallback {
fallback_sources = message_sources(&combined_ftl_source);
}
Self::write_bundle_initializer(
&mut file,
krate,
&bundle_name,
&lang_key,
&combined_ftl_source,
self.use_isolating,
)
.map_err(io_err(&dest_path))?;
Self::write_cache_map(&mut file, krate, &cache_name, &cache_entries)
.map_err(io_err(&dest_path))?;
message_ids.push((
lang_key.clone(),
cache_entries
.into_iter()
.map(|(id, _)| id)
.filter(|id| !id.contains('.'))
.collect(),
));
bundle_entries.push((lang_key.clone(), format!("&{bundle_name}")));
cache_root_entries.push((lang_key, cache_name));
available.push(lang_id);
}
} else if let Some(watch) = missing_dir_watch_path(&self.locales_dir, Path::new(&out_dir)) {
println!("cargo:rerun-if-changed={}", watch.display());
}
if self.fallback_locale.is_some()
&& let Err(available) = check_fallback(&fallback, &available)
{
return Err(Error::MissingFallbackLocale {
locale: fallback,
locales_dir: self.locales_dir,
available,
});
}
self.check_coverage(&fallback, &message_ids)?;
Self::write_root_maps(&mut file, krate, &cache_root_entries, &bundle_entries)
.map_err(io_err(&dest_path))?;
Self::write_catalog(&mut file, krate, &fallback, &available).map_err(io_err(&dest_path))?;
Self::write_keys(&mut file, &all_keys, &fallback, &fallback_sources)
.map_err(io_err(&dest_path))?;
self.export_charset_data(&mut file, &dest_path, &unique_chars, &out_dir)
}
fn process_locale_dir(
path: &Path,
lang_key: &str,
unique_chars: &mut BTreeSet<char>,
use_isolating: bool,
) -> Result<String, Error> {
let mut combined_ftl_source = String::new();
let mut definitions = Definitions::default();
let mut emits_isolation_marks = false;
for file_path in sorted_dir_entries(path)? {
if file_path.extension().is_some_and(|ext| ext == "ftl") {
println!("cargo:rerun-if-changed={}", file_path.display());
let source = fs::read_to_string(&file_path).map_err(io_err(&file_path))?;
combined_ftl_source.push_str(&source);
combined_ftl_source.push('\n');
definitions.record(lang_key, &file_path, &source)?;
let ast = match parser::parse(source.clone()) {
Ok(ast) => ast,
Err((_, errors)) => {
return Err(Error::Parse {
message: syntax_report(&file_path, &source, &errors),
path: file_path,
});
}
};
for entry in ast.body {
match entry {
ast::Entry::Message(msg) => {
harvest_message_chars(&msg, unique_chars);
emits_isolation_marks |= msg
.value
.iter()
.chain(msg.attributes.iter().map(|a| &a.value))
.any(pattern_isolates);
}
ast::Entry::Term(term) => {
harvest_term_chars(&term, unique_chars);
emits_isolation_marks |= std::iter::once(&term.value)
.chain(term.attributes.iter().map(|a| &a.value))
.any(pattern_isolates);
}
_ => {}
}
}
}
}
if use_isolating && emits_isolation_marks {
unique_chars.extend(['\u{2068}', '\u{2069}']);
}
Ok(combined_ftl_source)
}
fn check_coverage(
&self,
fallback: &str,
message_ids: &[(String, BTreeSet<String>)],
) -> Result<(), Error> {
if self.missing_translations == MissingTranslations::Allow {
return Ok(());
}
let report = coverage_report(fallback, message_ids);
if report.is_empty() {
return Ok(());
}
if self.missing_translations == MissingTranslations::Deny {
return Err(Error::MissingTranslations { report });
}
for line in report {
println!("cargo:warning={line}");
}
Ok(())
}
fn write_bundle_initializer(
file: &mut impl Write,
krate: &str,
bundle_name: &str,
lang_key: &str,
combined_ftl_source: &str,
use_isolating: bool,
) -> io::Result<()> {
let escaped_ftl = format!("{combined_ftl_source:?}");
let isolating_code = if use_isolating {
""
} else {
"\x20 bundle.set_use_isolating(false);\n"
};
let init_code = format!(
"std::sync::LazyLock::new(|| {{\n\
\x20 let lang: {krate}::LanguageIdentifier = \"{lang_key}\".parse().unwrap_or_default();\n\
\x20 let mut bundle = {krate}::__private::ConcurrentFluentBundle::new_concurrent(vec![lang]);\n\
{isolating_code}\
\x20 let res = {krate}::__private::FluentResource::try_new({escaped_ftl}.to_string()).unwrap_or_else(|(res, _)| res);\n\
\x20 bundle.add_resource_overriding(res);\n\
\x20 bundle\n\
}})"
);
writeln!(
file,
"static {bundle_name}: std::sync::LazyLock<{krate}::__private::ConcurrentFluentBundle<{krate}::__private::FluentResource>> = {init_code};"
)
}
fn write_cache_map(
file: &mut impl Write,
krate: &str,
cache_name: &str,
cache_entries: &[(String, String)],
) -> io::Result<()> {
let phf_path = format!("{krate}::__private::phf");
let mut map = phf_codegen::Map::new();
map.phf_path(&phf_path);
for (k, v) in cache_entries {
map.entry(k.as_str(), v.as_str());
}
writeln!(
file,
"static {cache_name}: {phf_path}::Map<&'static str, {krate}::CacheEntry> = {};",
map.build()
)
}
fn write_root_maps(
file: &mut impl Write,
krate: &str,
cache_root_entries: &[(String, String)],
bundle_entries: &[(String, String)],
) -> io::Result<()> {
let phf_path = format!("{krate}::__private::phf");
let mut root_map = phf_codegen::Map::new();
root_map.phf_path(&phf_path);
for (l, v) in cache_root_entries {
root_map.entry(l.as_str(), format!("&{v}"));
}
writeln!(
file,
"pub static CACHE: {phf_path}::Map<&'static str, &'static {phf_path}::Map<&'static str, {krate}::CacheEntry>> = {};",
root_map.build()
)?;
let mut bundle_map = phf_codegen::Map::new();
bundle_map.phf_path(&phf_path);
for (l, c) in bundle_entries {
bundle_map.entry(l.as_str(), c.as_str());
}
writeln!(
file,
"pub static LOCALES: {phf_path}::Map<&'static str, &'static std::sync::LazyLock<{krate}::__private::ConcurrentFluentBundle<{krate}::__private::FluentResource>>> = {};",
bundle_map.build()
)
}
fn fallback_key(&self) -> Result<String, Error> {
let Some(locale) = &self.fallback_locale else {
return Ok(DEFAULT_FALLBACK.to_string());
};
match locale.parse::<LanguageIdentifier>() {
Ok(id) => Ok(id.to_string()),
Err(err) => Err(Error::InvalidFallbackLocale {
locale: locale.clone(),
reason: err.to_string(),
}),
}
}
fn write_catalog(
file: &mut impl Write,
krate: &str,
fallback: &str,
available: &[LanguageIdentifier],
) -> io::Result<()> {
writeln!(
file,
"\n/// The locale lookups fall back to when the active language has no message.\n\
pub const FALLBACK: &str = {fallback:?};"
)?;
let tables = |key: &str| {
let suffix = table_suffix(key);
format!("(&CACHE_{suffix}, &BUNDLE_{suffix})")
};
let phf_path = format!("{krate}::__private::phf");
let mut map = phf_codegen::Map::new();
map.phf_path(&phf_path);
for id in available {
let key = id.to_string();
let value = tables(&key);
map.entry(key, value);
}
for (alias, target) in locale_aliases(available) {
map.entry(alias, tables(&target));
}
writeln!(
file,
"static CATALOG_LOCALES: {phf_path}::Map<&'static str, {krate}::__private::CatalogLocale> = {};",
map.build()
)?;
writeln!(
file,
"/// This crate's lookup tables, with locale negotiation aliases, as used by `t!` and `t_in!`.\n\
pub static CATALOG: {krate}::Catalog = {krate}::__private::catalog(FALLBACK, &CATALOG_LOCALES);"
)
}
fn write_keys(
file: &mut impl Write,
all_keys: &BTreeSet<String>,
fallback: &str,
fallback_sources: &HashMap<String, String>,
) -> io::Result<()> {
writeln!(
file,
"\n/// Every message ID and `message.attribute` key found in any locale, as a\n\
/// constant. Using these instead of string literals turns a mistyped key\n\
/// into a compile error.\n\
pub mod keys {{"
)?;
for (name, key) in key_const_names(all_keys) {
if name != key_const_base(key) {
println!(
"cargo:warning=fluent-zero: message ID `{key}` clashes with another ID's constant name; it is available as `keys::{name}`"
);
}
let kind = if key.contains('.') {
"Attribute"
} else {
"Message"
};
let doc = match fallback_sources.get(key) {
Some(source) => format!(
"{kind} `{key}`. Fallback locale (`{fallback}`) text:\n\n{}",
fenced_text(source)
),
None => {
format!("{kind} `{key}`. Not defined in the fallback locale (`{fallback}`).")
}
};
writeln!(
file,
" #[doc = {doc:?}]\n pub const {name}: &str = {key:?};"
)?;
}
writeln!(file, "}}")?;
let sorted: Vec<&String> = all_keys.iter().collect();
writeln!(
file,
"\n/// Sorted message IDs; `t!` validates string literal keys against it.\n\
#[doc(hidden)]\n\
pub const __FLUENT_ZERO_KEYS: &[&str] = &{sorted:?};"
)
}
fn export_charset_data(
&self,
file: &mut impl Write,
dest_path: &Path,
unique_chars: &BTreeSet<char>,
out_dir: &str,
) -> Result<(), Error> {
let charset_string: String = unique_chars.iter().collect();
writeln!(
file,
"\n/// A deterministically sorted string containing all unique characters\n\
/// used across all `.ftl` files in this crate and its dependencies.\n\
pub const CHARSET: &str = {charset_string:?};"
)
.map_err(io_err(dest_path))?;
let internal_dest = Path::new(out_dir).join("fluent_charset_internal.txt");
if write_if_changed(&internal_dest, &charset_string).is_ok() {
println!("cargo:fluent_charset_path={}", internal_dest.display());
}
if let Some(charset_dest) = &self.charset_dest {
if charset_dest.is_relative()
&& let Ok(cwd) = env::current_dir()
&& !lexically_inside(&cwd.join(charset_dest), Path::new(out_dir))
{
println!(
"cargo:warning=fluent-zero-build: export_charset path `{}` is outside OUT_DIR; \
write it under OUT_DIR to keep the build hermetic",
charset_dest.display()
);
}
if let Some(parent) = charset_dest.parent() {
let _ = fs::create_dir_all(parent);
}
write_if_changed(charset_dest, &charset_string).map_err(io_err(charset_dest))?;
println!("cargo:rerun-if-changed={}", charset_dest.display());
}
Ok(())
}
}
fn is_rust_path(path: &str) -> bool {
let path = path.strip_prefix("::").unwrap_or(path);
!path.is_empty()
&& path.split("::").all(|segment| {
let mut chars = segment.chars();
chars
.next()
.is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
&& chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
&& segment != "_"
})
}
fn table_suffix(lang_key: &str) -> String {
lang_key.replace('-', "_").to_uppercase()
}
fn check_fallback(fallback: &str, available: &[LanguageIdentifier]) -> Result<(), Vec<String>> {
let mut keys: Vec<String> = available.iter().map(ToString::to_string).collect();
if keys.is_empty() || keys.iter().any(|key| key == fallback) {
return Ok(());
}
keys.sort();
Err(keys)
}
fn locale_aliases(available: &[LanguageIdentifier]) -> Vec<(String, String)> {
let mut available = available.to_vec();
available.sort();
let existing: BTreeSet<String> = available.iter().map(ToString::to_string).collect();
let mut wanted: BTreeSet<LanguageIdentifier> = BTreeSet::new();
for id in &available {
let language = LanguageIdentifier::from_parts(id.language, None, None, &[]);
let script = likely_script(id);
if script.is_some() && script != likely_script(&language) {
wanted.insert(LanguageIdentifier::from_parts(
id.language,
script,
None,
&[],
));
for region in two_letter_regions() {
let candidate =
LanguageIdentifier::from_parts(id.language, None, Some(region), &[]);
if likely_script(&candidate) == script {
wanted.insert(candidate);
}
}
}
wanted.insert(language);
}
wanted
.into_iter()
.filter(|requested| !existing.contains(&requested.to_string()))
.filter_map(|requested| {
let negotiated = negotiate_languages(
std::slice::from_ref(&requested),
&available,
None,
NegotiationStrategy::Lookup,
)
.first()
.copied();
let bare = requested.script.is_none() && requested.region.is_none();
let target = if bare {
negotiated.or_else(|| {
available
.iter()
.find(|id| id.language == requested.language)
})?
} else {
negotiated.filter(|target| likely_script(target) == likely_script(&requested))?
};
Some((requested.to_string(), target.to_string()))
})
.collect()
}
fn likely_script(id: &LanguageIdentifier) -> Option<subtags::Script> {
let mut id = id.clone();
id.maximize();
id.script
}
fn two_letter_regions() -> impl Iterator<Item = subtags::Region> {
(b'A'..=b'Z').flat_map(|first| {
(b'A'..=b'Z').filter_map(move |second| {
std::str::from_utf8(&[first, second])
.ok()
.and_then(|code| code.parse().ok())
})
})
}
fn syntax_report(path: &Path, source: &str, errors: &[parser::ParserError]) -> String {
let mut report = String::new();
for error in errors {
let offset = error.pos.start.min(source.len());
let offset = (0..=offset)
.rev()
.find(|&i| source.is_char_boundary(i))
.unwrap_or(0);
let line_start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
let line_end = source[offset..]
.find('\n')
.map_or(source.len(), |i| offset + i);
let line_no = source[..line_start].matches('\n').count() + 1;
let column = source[line_start..offset].chars().count() + 1;
let line = source[line_start..line_end].trim_end_matches('\r');
let gutter = " ".repeat(line_no.to_string().len());
let caret_pad = " ".repeat(column - 1);
if !report.is_empty() {
report.push('\n');
}
let _ = write!(
report,
"{}:{line_no}:{column}: {error}\n{line_no} | {line}\n{gutter} | {caret_pad}^",
path.display()
);
}
report
}
fn coverage_report(fallback: &str, message_ids: &[(String, BTreeSet<String>)]) -> Vec<String> {
let Some((_, expected)) = message_ids.iter().find(|(lang, _)| lang == fallback) else {
return Vec::new();
};
let mut locales: Vec<_> = message_ids
.iter()
.filter(|(lang, _)| lang != fallback)
.collect();
locales.sort_by(|a, b| a.0.cmp(&b.0));
let mut report = Vec::new();
for (lang, ids) in locales {
let missing: Vec<&str> = expected.difference(ids).map(String::as_str).collect();
if missing.is_empty() {
continue;
}
let mut line = format!(
"{lang} is missing {} of {} messages from {fallback}: {}",
missing.len(),
expected.len(),
missing[..missing.len().min(COVERAGE_SAMPLE)].join(", ")
);
if missing.len() > COVERAGE_SAMPLE {
let _ = write!(line, " and {} more", missing.len() - COVERAGE_SAMPLE);
}
report.push(line);
}
report
}
#[derive(Default)]
struct Definitions {
locations: HashMap<String, (PathBuf, usize)>,
}
impl Definitions {
fn record(&mut self, lang_key: &str, file: &Path, source: &str) -> Result<(), Error> {
for (id, line) in entry_definitions(source) {
if let Some(first) = self.locations.get(id) {
return Err(Error::DuplicateId {
id: id.to_string(),
lang: lang_key.to_string(),
first: first.clone(),
second: (file.to_path_buf(), line),
});
}
self.locations
.insert(id.to_string(), (file.to_path_buf(), line));
}
Ok(())
}
}
fn entry_definitions(source: &str) -> impl Iterator<Item = (&str, usize)> {
source.lines().enumerate().filter_map(|(index, line)| {
let (head, _) = line.split_once('=')?;
let id = head.trim_end();
let name = id.strip_prefix('-').unwrap_or(id);
let mut chars = name.chars();
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
valid.then_some((id, index + 1))
})
}
fn static_cache_entries(
krate: &str,
lang_id: &LanguageIdentifier,
source: &str,
use_isolating: bool,
) -> Vec<(String, String)> {
let resource = FluentResource::try_new(source.to_owned()).unwrap_or_else(|(res, _)| res);
let mut bundle = FluentBundle::new(vec![lang_id.clone()]);
bundle.set_use_isolating(use_isolating);
bundle.add_resource_overriding(&resource);
let mut analysis = StaticAnalysis::new(&resource);
let mut entries = Vec::new();
for entry in resource.entries() {
let ast::Entry::Message(msg) = entry else {
continue;
};
let id = msg.id.name;
let static_value = msg
.value
.as_ref()
.filter(|_| analysis.message(id, None))
.and_then(|pattern| format_static(&bundle, pattern));
entries.push((id.to_string(), cache_entry(krate, static_value)));
let mut seen = HashSet::new();
for attribute in &msg.attributes {
let name = attribute.id.name;
if !seen.insert(name) {
continue;
}
let static_value = analysis
.message(id, Some(name))
.then(|| format_static(&bundle, &attribute.value))
.flatten();
entries.push((format!("{id}.{name}"), cache_entry(krate, static_value)));
}
}
entries
}
fn format_static<'r>(
bundle: &FluentBundle<&'r FluentResource>,
pattern: &'r ast::Pattern<&'r str>,
) -> Option<String> {
let mut errors = Vec::new();
let value = bundle.format_pattern(pattern, None, &mut errors);
errors.is_empty().then(|| value.into_owned())
}
fn cache_entry(krate: &str, static_value: Option<String>) -> String {
match static_value {
Some(value) => format!("{krate}::CacheEntry::Static({value:?})"),
None => format!("{krate}::CacheEntry::Dynamic"),
}
}
struct StaticAnalysis<'r> {
messages: HashMap<&'r str, &'r ast::Message<&'r str>>,
terms: HashMap<&'r str, &'r ast::Term<&'r str>>,
verdicts: HashMap<(bool, &'r str, Option<&'r str>), bool>,
}
impl<'r> StaticAnalysis<'r> {
fn new(resource: &'r FluentResource) -> Self {
let mut messages = HashMap::new();
let mut terms = HashMap::new();
for entry in resource.entries() {
match entry {
ast::Entry::Message(msg) => {
messages.insert(msg.id.name, msg);
}
ast::Entry::Term(term) => {
terms.insert(term.id.name, term);
}
_ => {}
}
}
Self {
messages,
terms,
verdicts: HashMap::new(),
}
}
fn message(&mut self, id: &'r str, attribute: Option<&'r str>) -> bool {
let messages = &self.messages;
let pattern = messages.get(id).and_then(|msg| match attribute {
None => msg.value.as_ref(),
Some(name) => attribute_value(&msg.attributes, name),
});
self.verdict((false, id, attribute), pattern)
}
fn term(&mut self, id: &'r str, attribute: Option<&'r str>) -> bool {
let terms = &self.terms;
let pattern = terms.get(id).and_then(|term| match attribute {
None => Some(&term.value),
Some(name) => attribute_value(&term.attributes, name),
});
self.verdict((true, id, attribute), pattern)
}
fn verdict(
&mut self,
key: (bool, &'r str, Option<&'r str>),
pattern: Option<&'r ast::Pattern<&'r str>>,
) -> bool {
if let Some(&verdict) = self.verdicts.get(&key) {
return verdict;
}
self.verdicts.insert(key, false);
let verdict = pattern.is_some_and(|pattern| self.pattern(pattern));
self.verdicts.insert(key, verdict);
verdict
}
fn pattern(&mut self, pattern: &'r ast::Pattern<&'r str>) -> bool {
pattern.elements.iter().all(|element| match element {
ast::PatternElement::TextElement { .. } => true,
ast::PatternElement::Placeable { expression } => self.expression(expression),
})
}
fn expression(&mut self, expression: &'r ast::Expression<&'r str>) -> bool {
match expression {
ast::Expression::Select { selector, variants } => {
self.inline(selector) && variants.iter().all(|v| self.pattern(&v.value))
}
ast::Expression::Inline(inline) => self.inline(inline),
}
}
fn inline(&mut self, inline: &'r ast::InlineExpression<&'r str>) -> bool {
match inline {
ast::InlineExpression::StringLiteral { .. }
| ast::InlineExpression::NumberLiteral { .. } => true,
ast::InlineExpression::MessageReference { id, attribute } => {
self.message(id.name, attribute.as_ref().map(|a| a.name))
}
ast::InlineExpression::TermReference {
id,
attribute,
arguments,
} => {
arguments.as_ref().is_none_or(|args| {
args.positional.iter().all(|a| self.inline(a))
&& args.named.iter().all(|a| self.inline(&a.value))
}) && self.term(id.name, attribute.as_ref().map(|a| a.name))
}
ast::InlineExpression::Placeable { expression } => self.expression(expression),
ast::InlineExpression::FunctionReference { .. }
| ast::InlineExpression::VariableReference { .. } => false,
}
}
}
fn message_sources(source: &str) -> HashMap<String, String> {
fn serialize(msg: ast::Message<&str>) -> String {
let resource = ast::Resource {
body: vec![ast::Entry::Message(msg)],
};
serializer::serialize(&resource).trim_end().to_string()
}
let resource = FluentResource::try_new(source.to_owned()).unwrap_or_else(|(res, _)| res);
let mut sources = HashMap::new();
for entry in resource.entries() {
if let ast::Entry::Message(msg) = entry {
let id = msg.id.name;
if sources.contains_key(id) {
continue;
}
for attribute in &msg.attributes {
sources
.entry(format!("{id}.{}", attribute.id.name))
.or_insert_with(|| {
serialize(ast::Message {
id: msg.id.clone(),
value: None,
attributes: vec![attribute.clone()],
comment: None,
})
});
}
let mut msg = msg.clone();
msg.comment = None;
sources.insert(id.to_string(), serialize(msg));
}
}
sources
}
fn fenced_text(text: &str) -> String {
let mut longest = 0;
let mut run = 0;
for c in text.chars() {
run = if c == '`' { run + 1 } else { 0 };
longest = longest.max(run);
}
let fence = "`".repeat(longest.max(2) + 1);
format!("{fence}text\n{text}\n{fence}")
}
fn key_const_base(key: &str) -> String {
let name: String = key
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect();
if name.starts_with(|c: char| c.is_ascii_alphabetic()) {
name
} else {
format!("KEY_{name}")
}
}
fn key_const_names(keys: &BTreeSet<String>) -> Vec<(String, &str)> {
let bases: BTreeSet<String> = keys.iter().map(|key| key_const_base(key)).collect();
let mut assigned: BTreeSet<String> = BTreeSet::new();
let mut names = Vec::with_capacity(keys.len());
for key in keys {
let base = key_const_base(key);
let mut name = base.clone();
if assigned.contains(&name) {
let mut n = 2;
loop {
name = format!("{base}_{n}");
if !bases.contains(&name) && !assigned.contains(&name) {
break;
}
n += 1;
}
}
assigned.insert(name.clone());
names.push((name, key.as_str()));
}
names
}
fn attribute_value<'r>(
attributes: &'r [ast::Attribute<&'r str>],
name: &str,
) -> Option<&'r ast::Pattern<&'r str>> {
attributes
.iter()
.find(|attribute| attribute.id.name == name)
.map(|attribute| &attribute.value)
}
fn harvest_message_chars(msg: &ast::Message<String>, unique_chars: &mut BTreeSet<char>) {
if let Some(pattern) = &msg.value {
harvest_pattern_chars(pattern, unique_chars);
}
for attribute in &msg.attributes {
harvest_pattern_chars(&attribute.value, unique_chars);
}
}
fn harvest_term_chars(term: &ast::Term<String>, unique_chars: &mut BTreeSet<char>) {
harvest_pattern_chars(&term.value, unique_chars);
for attribute in &term.attributes {
harvest_pattern_chars(&attribute.value, unique_chars);
}
}
fn harvest_pattern_chars(pattern: &ast::Pattern<String>, unique_chars: &mut BTreeSet<char>) {
for element in &pattern.elements {
match element {
ast::PatternElement::TextElement { value } => extend_visible(unique_chars, value),
ast::PatternElement::Placeable { expression } => {
harvest_expression_chars(expression, unique_chars);
}
}
}
}
fn harvest_expression_chars(
expression: &ast::Expression<String>,
unique_chars: &mut BTreeSet<char>,
) {
match expression {
ast::Expression::Select { variants, .. } => {
for variant in variants {
harvest_pattern_chars(&variant.value, unique_chars);
}
}
ast::Expression::Inline(inline) => harvest_inline_chars(inline, unique_chars),
}
}
fn harvest_inline_chars(inline: &ast::InlineExpression<String>, unique_chars: &mut BTreeSet<char>) {
match inline {
ast::InlineExpression::StringLiteral { value } => {
extend_visible(unique_chars, &unescape_unicode_to_string(value));
}
ast::InlineExpression::NumberLiteral { value } => extend_visible(unique_chars, value),
ast::InlineExpression::FunctionReference { arguments, .. } => {
for argument in &arguments.positional {
harvest_inline_chars(argument, unique_chars);
}
}
ast::InlineExpression::Placeable { expression } => {
harvest_expression_chars(expression, unique_chars);
}
ast::InlineExpression::MessageReference { .. }
| ast::InlineExpression::TermReference { .. }
| ast::InlineExpression::VariableReference { .. } => {}
}
}
fn pattern_isolates(pattern: &ast::Pattern<String>) -> bool {
let multi_element = pattern.elements.len() > 1;
pattern.elements.iter().any(|element| match element {
ast::PatternElement::TextElement { .. } => false,
ast::PatternElement::Placeable { expression } => {
let isolated = multi_element
&& !matches!(
expression,
ast::Expression::Inline(
ast::InlineExpression::MessageReference { .. }
| ast::InlineExpression::TermReference { .. }
| ast::InlineExpression::StringLiteral { .. }
)
);
isolated || expression_isolates(expression)
}
})
}
fn expression_isolates(expression: &ast::Expression<String>) -> bool {
match expression {
ast::Expression::Select { variants, .. } => variants
.iter()
.any(|variant| pattern_isolates(&variant.value)),
ast::Expression::Inline(ast::InlineExpression::Placeable { expression }) => {
expression_isolates(expression)
}
ast::Expression::Inline(_) => false,
}
}
fn extend_visible(unique_chars: &mut BTreeSet<char>, text: &str) {
unique_chars.extend(text.chars().filter(|c| !c.is_control()));
}
fn sorted_dir_entries(dir: &Path) -> Result<Vec<PathBuf>, Error> {
let mut paths = fs::read_dir(dir)
.and_then(|entries| {
entries
.map(|entry| entry.map(|entry| entry.path()))
.collect::<io::Result<Vec<PathBuf>>>()
})
.map_err(io_err(dir))?;
paths.sort();
Ok(paths)
}
fn dependency_charset_paths(vars: impl IntoIterator<Item = (OsString, OsString)>) -> Vec<String> {
let mut paths: Vec<String> = vars
.into_iter()
.filter_map(|(key, value)| {
let key = key.into_string().ok()?;
let is_charset = key.starts_with("DEP_") && key.ends_with("_FLUENT_CHARSET_PATH");
is_charset.then(|| value.into_string().ok()).flatten()
})
.collect();
paths.sort();
paths
}
fn missing_dir_watch_path(dir: &Path, out_dir: &Path) -> Option<PathBuf> {
let out_dir = fs::canonicalize(out_dir).ok()?;
let ancestor = dir
.ancestors()
.skip(1)
.map(|a| {
if a.as_os_str().is_empty() {
Path::new(".")
} else {
a
}
})
.find(|a| a.is_dir())?;
let canonical = fs::canonicalize(ancestor).ok()?;
(!out_dir.starts_with(canonical)).then(|| ancestor.to_path_buf())
}
fn lexically_inside(path: &Path, base: &Path) -> bool {
fn normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other),
}
}
normalized
}
normalize(path).starts_with(normalize(base))
}
pub fn generate_static_cache(locales_dir_path: &str) {
FluentZeroBuilder::new(locales_dir_path).generate();
}
fn write_if_changed(path: &Path, contents: &str) -> std::io::Result<()> {
if fs::read_to_string(path).is_ok_and(|existing| existing == contents) {
return Ok(());
}
fs::write(path, contents)
}
#[cfg(test)]
mod tests {
use super::*;
fn charset(source: &str) -> String {
let ast = parser::parse(source.to_string()).expect("test FTL must parse");
let mut unique_chars = BTreeSet::new();
for entry in &ast.body {
match entry {
ast::Entry::Message(msg) => harvest_message_chars(msg, &mut unique_chars),
ast::Entry::Term(term) => harvest_term_chars(term, &mut unique_chars),
_ => {}
}
}
unique_chars.into_iter().collect()
}
fn assert_contains_all(charset: &str, expected: &str) {
for c in expected.chars() {
assert!(
charset.contains(c),
"{c:?} missing from charset {charset:?}"
);
}
}
fn entries(source: &str) -> HashMap<String, String> {
static_cache_entries(DEFAULT_CRATE_PATH, &"en-US".parse().unwrap(), source, true)
.into_iter()
.collect()
}
fn static_literal(value: &str) -> String {
format!("::fluent_zero::CacheEntry::Static({value:?})")
}
const DYNAMIC: &str = "::fluent_zero::CacheEntry::Dynamic";
#[test]
fn finds_entry_definitions_with_line_numbers() {
let source = "\
# comment = not an entry
hello = Hi
continued = not an entry
-brand=Acme
multi =
.attr = x
bad id = not an entry
";
let found: Vec<_> = entry_definitions(source).collect();
assert_eq!(found, [("hello", 2), ("-brand", 4), ("multi", 6)]);
}
#[test]
fn terms_and_messages_have_separate_namespaces() {
let mut definitions = Definitions::default();
definitions
.record("en-US", Path::new("a.ftl"), "brand = Message\n")
.unwrap();
definitions
.record("en-US", Path::new("b.ftl"), "-brand = Term\n")
.unwrap();
}
#[test]
fn duplicate_across_files_names_both_locations() {
let mut definitions = Definitions::default();
definitions
.record("en-US", Path::new("a.ftl"), "# header\nhello = Hi\n")
.unwrap();
let err = definitions
.record("en-US", Path::new("b.ftl"), "x = 1\n\nhello = Again\n")
.unwrap_err();
assert!(
matches!(&err, Error::DuplicateId { id, lang, first, second }
if id == "hello" && lang == "en-US"
&& first == &(PathBuf::from("a.ftl"), 2)
&& second == &(PathBuf::from("b.ftl"), 3)),
"{err:?}"
);
assert!(
err.to_string().contains(
"`hello` is defined twice in locale `en-US`: first at a.ftl:2, again at b.ftl:3"
),
"{err}"
);
}
#[test]
fn duplicate_within_one_file_is_rejected() {
let err = Definitions::default()
.record("fr-FR", Path::new("a.ftl"), "-brand = A\n-brand = B\n")
.unwrap_err();
assert!(
err.to_string().contains(
"`-brand` is defined twice in locale `fr-FR`: first at a.ftl:1, again at a.ftl:2"
),
"{err}"
);
}
#[test]
fn plain_text_is_static() {
assert_eq!(entries("a = Hello\n")["a"], static_literal("Hello"));
}
#[test]
fn quotes_and_backslashes_are_escaped_and_stay_static() {
let entries = entries("q = Say \"hi\"\nb = C:\\path\n");
assert_eq!(
entries["q"],
r#"::fluent_zero::CacheEntry::Static("Say \"hi\"")"#
);
assert_eq!(
entries["b"],
r#"::fluent_zero::CacheEntry::Static("C:\\path")"#
);
}
#[test]
fn multiline_text_is_static() {
let entries = entries("m =\n First line\n second line\n");
assert_eq!(entries["m"], static_literal("First line\nsecond line"));
}
#[test]
fn string_literals_are_resolved() {
let entries = entries("brace = Open {\"{\"} here\nnbsp = a{\"\\u00A0\"}b\n");
assert_eq!(entries["brace"], static_literal("Open { here"));
assert_eq!(entries["nbsp"], static_literal("a\u{a0}b"));
}
#[test]
fn number_literals_keep_bundle_isolation_marks() {
assert_eq!(
entries("n = Take { 5 } now\n")["n"],
static_literal("Take \u{2068}5\u{2069} now")
);
}
#[test]
fn static_terms_messages_and_selectors_are_inlined() {
let source = "\
-brand = Acme
.gender = neuter
about = About { -brand }
pronoun = { -brand.gender ->
[neuter] it
*[other] they
}
title = { about }!
";
let entries = entries(source);
assert_eq!(entries["about"], static_literal("About Acme"));
assert_eq!(entries["pronoun"], static_literal("it"));
assert_eq!(entries["title"], static_literal("About Acme!"));
}
#[test]
fn term_arguments_feeding_variables_are_dynamic() {
let source = "\
-thing = { $case ->
[gen] thing's
*[nom] thing
}
a = The { -thing(case: \"gen\") }
";
assert_eq!(entries(source)["a"], DYNAMIC);
}
#[test]
fn anything_touching_variables_or_functions_is_dynamic() {
let source = "\
v = Hi { $name }
f = { NUMBER(5) }
s = { $n ->
[one] one
*[other] many
}
via = Says { v }
";
let entries = entries(source);
for id in ["v", "f", "s", "via"] {
assert_eq!(entries[id], DYNAMIC, "{id} should be dynamic");
}
}
#[test]
fn broken_references_and_cycles_stay_dynamic() {
let entries = entries("missing = { nope }\nx = { y }\ny = { x }\nattrs =\n .a = A\n");
for id in ["missing", "x", "y", "attrs"] {
assert_eq!(entries[id], DYNAMIC, "{id} should be dynamic");
}
}
fn aliases(locales: &[&str]) -> HashMap<String, String> {
let ids: Vec<LanguageIdentifier> = locales.iter().map(|l| l.parse().unwrap()).collect();
locale_aliases(&ids).into_iter().collect()
}
#[test]
fn bare_language_aliases_its_only_locale() {
let aliases = aliases(&["en-US", "ru-RU"]);
assert_eq!(aliases["ru"], "ru-RU");
assert_eq!(aliases["en"], "en-US");
assert_eq!(aliases.len(), 2, "{aliases:?}");
}
#[test]
fn bare_language_prefers_the_likely_region() {
let aliases = aliases(&["pt-PT", "pt-BR", "en-GB", "en-US", "zh-TW", "zh-CN"]);
assert_eq!(aliases["pt"], "pt-BR");
assert_eq!(aliases["en"], "en-US");
assert_eq!(aliases["zh"], "zh-CN");
}
#[test]
fn existing_locales_are_never_aliased() {
let aliases = aliases(&["pt", "pt-BR", "sr-Latn"]);
assert!(!aliases.contains_key("pt"), "{aliases:?}");
assert!(!aliases.contains_key("pt-BR"), "{aliases:?}");
assert!(!aliases.contains_key("sr-Latn"), "{aliases:?}");
}
#[test]
fn script_implying_regions_find_the_script_locale() {
let aliases = aliases(&["zh-Hans", "zh-Hant"]);
for region in ["zh-TW", "zh-HK", "zh-MO"] {
assert_eq!(aliases[region], "zh-Hant", "{region}");
}
assert_eq!(aliases["zh"], "zh-Hans");
assert!(!aliases.contains_key("zh-CN"), "{aliases:?}");
}
#[test]
fn script_aliases_point_at_regional_locales() {
let aliases = aliases(&["zh-TW", "zh-CN"]);
assert_eq!(aliases["zh-Hant"], "zh-TW");
assert_eq!(aliases["zh-HK"], "zh-TW");
assert_eq!(aliases["zh"], "zh-CN");
}
#[test]
fn script_aliases_never_cross_scripts() {
let aliases = aliases(&["zh-TW"]);
assert_eq!(aliases["zh"], "zh-TW");
assert!(!aliases.contains_key("zh-Hans"), "{aliases:?}");
assert!(!aliases.contains_key("zh-SG"), "{aliases:?}");
}
#[test]
fn bare_language_falls_back_across_scripts() {
let aliases = aliases(&["zh-Hant", "en-US"]);
assert_eq!(aliases["zh"], "zh-Hant");
assert_eq!(aliases["zh-TW"], "zh-Hant");
}
#[test]
fn no_locales_no_aliases() {
assert!(aliases(&[]).is_empty());
}
#[test]
fn fallback_key_is_canonicalised() {
let builder = FluentZeroBuilder::new("locales").fallback_locale("en-gb");
assert_eq!(builder.fallback_key().unwrap(), "en-GB");
assert_eq!(
FluentZeroBuilder::new("locales").fallback_key().unwrap(),
"en-US"
);
}
#[test]
fn fallback_must_be_an_available_locale() {
let available: Vec<LanguageIdentifier> = ["fr-FR", "en-US"]
.iter()
.map(|l| l.parse().unwrap())
.collect();
assert_eq!(check_fallback("en-US", &available), Ok(()));
let available = check_fallback("en-GB", &available).unwrap_err();
assert_eq!(available, ["en-US", "fr-FR"]);
assert_eq!(check_fallback("en-GB", &[]), Ok(()));
let err = Error::MissingFallbackLocale {
locale: "en-GB".to_string(),
locales_dir: PathBuf::from("locales"),
available,
};
assert_eq!(
err.to_string(),
"fallback locale `en-GB` has no directory in locales (available: en-US, fr-FR)"
);
}
#[test]
fn invalid_fallback_is_an_error_before_anything_is_written() {
let err = FluentZeroBuilder::new("locales")
.fallback_locale("not a locale")
.try_generate()
.unwrap_err();
assert!(
matches!(&err, Error::InvalidFallbackLocale { locale, .. } if locale == "not a locale"),
"{err:?}"
);
assert!(
err.to_string()
.starts_with("fallback locale `not a locale` is not a valid language identifier: "),
"{err}"
);
}
#[test]
#[should_panic(expected = "fluent-zero-build: fallback locale `not a locale` is not a valid")]
fn generate_panics_with_the_error_message() {
FluentZeroBuilder::new("locales")
.fallback_locale("not a locale")
.generate();
}
fn syntax_errors(source: &str) -> String {
let (_, errors) = parser::parse(source).expect_err("test FTL must not parse");
syntax_report(Path::new("locales/en-US/main.ftl"), source, &errors)
}
#[test]
fn syntax_errors_report_path_line_column_and_line() {
let report = syntax_errors("ok = Fine\nbroken Value\n");
assert_eq!(
report,
"locales/en-US/main.ftl:2:8: Expected a token starting with \"=\"\n\
2 | broken Value\n\
\x20 | ^"
);
assert!(!report.contains("Junk"), "AST leaked: {report}");
}
#[test]
fn syntax_errors_count_columns_in_characters_and_list_every_error() {
let report = syntax_errors("a = ok\nx = Ü { 1 2 }\nb Wert\n");
assert!(report.contains("main.ftl:2:11: "), "{report}");
assert!(
report.contains("2 | x = Ü { 1 2 }\n | ^"),
"{report}"
);
assert!(report.contains("main.ftl:3:3: "), "{report}");
}
#[test]
fn syntax_error_fails_the_build_with_the_report() {
let dir = env::temp_dir().join(format!("fluent-zero-build-test-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let file = dir.join("bad.ftl");
fs::write(&file, "bad Value\n").unwrap();
let err = FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut BTreeSet::new(), true)
.expect_err("a syntax error must fail the build");
fs::remove_dir_all(&dir).unwrap();
let Error::Parse { path, message } = &err else {
panic!("expected a parse error, got {err:?}");
};
assert_eq!(path, &file);
assert!(
message.starts_with(&format!("{}:1:5: Expected a token", file.display())),
"{message}"
);
assert_eq!(
err.to_string(),
format!("failed to parse {}:\n{message}", file.display())
);
assert!(std::error::Error::source(&err).is_none());
}
fn ids(lang: &str, ids: &[&str]) -> (String, BTreeSet<String>) {
(
lang.to_string(),
ids.iter().map(ToString::to_string).collect(),
)
}
#[test]
fn coverage_lists_missing_ids_per_locale_sorted() {
let report = coverage_report(
"en-US",
&[
ids("fr-FR", &["a"]),
ids("en-US", &["a", "b", "c"]),
ids("de", &["a", "b", "c", "extra"]),
ids("cs", &["c"]),
],
);
assert_eq!(
report,
[
"cs is missing 2 of 3 messages from en-US: a, b",
"fr-FR is missing 2 of 3 messages from en-US: b, c",
]
);
}
#[test]
fn coverage_truncates_long_lists() {
let all: Vec<String> = (0..8).map(|i| format!("m{i}")).collect();
let all: Vec<&str> = all.iter().map(String::as_str).collect();
let report = coverage_report("en-US", &[ids("en-US", &all), ids("fr", &[])]);
assert_eq!(
report,
["fr is missing 8 of 8 messages from en-US: m0, m1, m2, m3, m4 and 3 more"]
);
}
#[test]
fn coverage_is_silent_without_fallback_or_gaps() {
assert!(coverage_report("en-US", &[ids("fr", &["a"])]).is_empty());
assert!(coverage_report("en-US", &[ids("en-US", &["a"]), ids("fr", &["a"])]).is_empty());
}
#[test]
fn coverage_deny_fails_and_allow_is_silent() {
let ids = [ids("en-US", &["a"]), ids("fr", &[])];
FluentZeroBuilder::default()
.missing_translations(MissingTranslations::Allow)
.check_coverage("en-US", &ids)
.unwrap();
let err = FluentZeroBuilder::default()
.missing_translations(MissingTranslations::Deny)
.check_coverage("en-US", &ids)
.expect_err("deny must fail the build");
assert!(
matches!(&err, Error::MissingTranslations { report } if report.len() == 1),
"{err:?}"
);
assert_eq!(
err.to_string(),
"missing translations:\nfr is missing 1 of 1 messages from en-US: a"
);
}
#[test]
fn coverage_compares_against_the_configured_fallback() {
let ids = [
ids("en-US", &["a", "b"]),
ids("en-GB", &["a"]),
ids("fr", &[]),
];
let builder = FluentZeroBuilder::default()
.fallback_locale("en-GB")
.missing_translations(MissingTranslations::Deny);
let err = builder
.check_coverage(&builder.fallback_key().unwrap(), &ids)
.expect_err("deny must fail the build");
assert_eq!(
err.to_string(),
"missing translations:\nfr is missing 1 of 1 messages from en-GB: a"
);
}
fn const_names(keys: &[&str]) -> Vec<(String, String)> {
let keys: BTreeSet<String> = keys.iter().map(ToString::to_string).collect();
key_const_names(&keys)
.into_iter()
.map(|(name, key)| (key.to_string(), name))
.collect()
}
#[test]
fn key_constants_are_screaming_snake_case() {
assert_eq!(key_const_base("app-title"), "APP_TITLE");
assert_eq!(key_const_base("btn_submit"), "BTN_SUBMIT");
assert_eq!(key_const_base("menuTitle2"), "MENUTITLE2");
assert_eq!(key_const_base("login.tooltip"), "LOGIN_TOOLTIP");
}
#[test]
fn key_constants_are_always_valid_identifiers() {
assert_eq!(key_const_base("1st"), "KEY_1ST");
assert_eq!(key_const_base("-x"), "KEY__X");
assert_eq!(key_const_base(""), "KEY_");
assert_eq!(key_const_base("héllo"), "H_LLO");
}
#[test]
fn colliding_keys_get_deterministic_suffixes() {
assert_eq!(
const_names(&["a_b", "aB", "a-b"]),
[
("a-b".to_string(), "A_B".to_string()),
("aB".to_string(), "AB".to_string()),
("a_b".to_string(), "A_B_2".to_string()),
]
);
}
#[test]
fn suffixes_never_take_another_keys_plain_name() {
let names = const_names(&["a-b", "a_b", "a_b-2"]);
assert_eq!(
names,
[
("a-b".to_string(), "A_B".to_string()),
("a_b".to_string(), "A_B_3".to_string()),
("a_b-2".to_string(), "A_B_2".to_string()),
]
);
}
#[test]
fn message_sources_document_value_and_attributes_without_comments() {
let sources = message_sources("# note\nlogin = Log in\n .tooltip = Hi { $name }\n");
assert_eq!(
sources["login"],
"login = Log in\n .tooltip = Hi { $name }"
);
}
#[test]
fn message_sources_document_each_attribute_on_its_own() {
let sources = message_sources(
"login = Log in\n .tooltip = Hi\n .label = Go\n .tooltip = Dup\nsearch =\n .placeholder = Find\n",
);
assert_eq!(sources["login.tooltip"], "login =\n .tooltip = Hi");
assert_eq!(sources["login.label"], "login =\n .label = Go");
assert_eq!(
sources["search.placeholder"],
"search =\n .placeholder = Find"
);
assert!(sources.contains_key("search"));
}
#[test]
fn attribute_keys_get_constants() {
assert_eq!(
const_names(&["login", "login.tooltip", "search.aria-label"]),
[
("login".to_string(), "LOGIN".to_string()),
("login.tooltip".to_string(), "LOGIN_TOOLTIP".to_string()),
(
"search.aria-label".to_string(),
"SEARCH_ARIA_LABEL".to_string()
),
]
);
}
#[test]
fn fenced_text_outlasts_backtick_runs() {
assert_eq!(fenced_text("a = b"), "```text\na = b\n```");
assert_eq!(fenced_text("a = ````"), "`````text\na = ````\n`````");
}
#[test]
fn attributes_get_dotted_keys_and_static_values() {
let source = "\
-brand = Acme
login = Log in
.tooltip = Sign in to { -brand }
.aria = { login }
.hint = Hi { $name }
";
let entries = entries(source);
assert_eq!(entries["login"], static_literal("Log in"));
assert_eq!(entries["login.tooltip"], static_literal("Sign in to Acme"));
assert_eq!(entries["login.aria"], static_literal("Log in"));
assert_eq!(entries["login.hint"], DYNAMIC);
}
#[test]
fn attribute_only_messages_get_attribute_entries() {
let entries =
entries("field =\n .placeholder = Name\n .label = { field.placeholder }:\n");
assert_eq!(entries["field"], DYNAMIC);
assert_eq!(entries["field.placeholder"], static_literal("Name"));
assert_eq!(entries["field.label"], static_literal("Name:"));
}
#[test]
fn repeated_attribute_names_keep_the_first() {
let source = "m = M\n .a = First\n .a = Second\n";
let generated =
static_cache_entries(DEFAULT_CRATE_PATH, &"en-US".parse().unwrap(), source, true);
let keys: Vec<&str> = generated.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, ["m", "m.a"]);
assert_eq!(generated[1].1, static_literal("First"));
}
#[test]
fn harvests_plain_text() {
assert_eq!(charset("hello = Hi you\n"), " Hiouy");
}
#[test]
fn harvests_every_select_variant() {
let charset = charset("files = { $n ->\n [one] Ёж\n *[other] Щука\n}\n");
assert_contains_all(&charset, "ЁжЩука");
}
#[test]
fn harvests_nested_selects() {
let source = "\
toggle = { $side ->
[left] { $shown ->
[true] ▶ 左
*[false] ◀ 左
}
*[right] 右侧
}
";
assert_contains_all(&charset(source), "▶◀左右侧");
}
#[test]
fn harvests_attributes() {
let charset = charset("login = Log in\n .tooltip = Ẩn\n");
assert_contains_all(&charset, "Ẩ");
}
#[test]
fn harvests_message_with_only_attributes() {
let charset = charset("login =\n .placeholder = 왼쪽\n");
assert_contains_all(&charset, "왼쪽");
}
#[test]
fn harvests_terms_and_their_attributes() {
let charset = charset("-brand = Ωmega\n .short = Ж\n");
assert_contains_all(&charset, "ΩmegaЖ");
}
#[test]
fn harvests_unescaped_string_literals() {
let charset = charset("price = {\"\\u00A0\"}{\"€\"}\n");
assert_contains_all(&charset, "\u{a0}€");
assert!(!charset.contains('\\'), "escape syntax leaked: {charset:?}");
}
#[test]
fn harvests_number_literals() {
assert_contains_all(&charset("n = { 42 }\n"), "42");
}
#[test]
fn skips_identifiers_variables_and_named_options() {
let source = "\
n = { NUMBER($count, style: \"percent\") ->
[one] a
*[other] b
}
";
assert_eq!(charset(source), "ab");
}
#[test]
fn number_literals_are_bare_without_isolation() {
let entries: HashMap<_, _> = static_cache_entries(
DEFAULT_CRATE_PATH,
&"en-US".parse().unwrap(),
"n = Take { 5 } now\n",
false,
)
.into_iter()
.collect();
assert_eq!(entries["n"], static_literal("Take 5 now"));
}
fn isolates(source: &str) -> bool {
let ast = parser::parse(source.to_string()).expect("test FTL must parse");
ast.body.iter().any(|entry| match entry {
ast::Entry::Message(msg) => msg
.value
.iter()
.chain(msg.attributes.iter().map(|a| &a.value))
.any(pattern_isolates),
ast::Entry::Term(term) => pattern_isolates(&term.value),
_ => false,
})
}
#[test]
fn detects_patterns_that_emit_isolation_marks() {
assert!(isolates("a = Hi { $name }\n"));
assert!(isolates("a = Take { 5 } now\n"));
assert!(isolates("a =\n .tip = Hi { $name }\n"));
assert!(isolates(
"a = { $n ->\n [one] { $n } file\n *[other] many\n}\n"
));
assert!(isolates("-t = x { $y }\n"));
}
#[test]
fn ignores_patterns_that_never_emit_isolation_marks() {
assert!(!isolates("a = Plain text\n"));
assert!(!isolates("a = { $name }\n"));
assert!(!isolates("a = About { -brand } and { b }\n"));
assert!(!isolates("a = Open {\"{\"} here\n"));
assert!(!isolates(
"a = { $n ->\n [one] { $n }\n *[other] many\n}\n"
));
}
fn temp_dir(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!(
"fluent-zero-build-test-{}-{name}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn locale_files_are_combined_in_sorted_order() {
let dir = temp_dir("sorted");
for name in ["c.ftl", "a.ftl", "b.ftl"] {
let id = name.trim_end_matches(".ftl");
fs::write(dir.join(name), format!("{id} = {id}\n")).unwrap();
}
fs::write(dir.join("notes.txt"), "ignored").unwrap();
let entries = sorted_dir_entries(&dir).unwrap();
let names: Vec<_> = entries.iter().map(|p| p.file_name().unwrap()).collect();
assert_eq!(names, ["a.ftl", "b.ftl", "c.ftl", "notes.txt"]);
let source =
FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut BTreeSet::new(), true)
.unwrap();
assert_eq!(source, "a = a\n\nb = b\n\nc = c\n\n");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn isolation_marks_join_the_charset_only_when_they_can_render() {
let dir = temp_dir("isolation");
fs::write(dir.join("main.ftl"), "a = Hi { $name }\n").unwrap();
let mut on = BTreeSet::new();
FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut on, true).unwrap();
assert!(on.contains(&'\u{2068}') && on.contains(&'\u{2069}'));
let mut off = BTreeSet::new();
FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut off, false).unwrap();
assert!(!off.contains(&'\u{2068}') && !off.contains(&'\u{2069}'));
fs::write(dir.join("main.ftl"), "a = { $name }\nb = Plain\n").unwrap();
let mut single = BTreeSet::new();
FluentZeroBuilder::process_locale_dir(&dir, "en-US", &mut single, true).unwrap();
assert!(!single.contains(&'\u{2068}'));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn dependency_charset_paths_are_filtered_and_sorted() {
let vars = [
("DEP_ZED_FLUENT_CHARSET_PATH", "/z.txt"),
("PATH", "/usr/bin"),
("DEP_ZED_OTHER", "/nope"),
("DEP_ABC_FLUENT_CHARSET_PATH", "/a.txt"),
]
.map(|(k, v)| (OsString::from(k), OsString::from(v)));
assert_eq!(dependency_charset_paths(vars), ["/a.txt", "/z.txt"]);
}
#[cfg(unix)]
#[test]
fn non_utf8_environment_entries_are_skipped() {
use std::os::unix::ffi::OsStringExt as _;
let bad = || OsString::from_vec(vec![b'D', b'E', b'P', 0xff]);
let vars = vec![
(bad(), OsString::from("/x.txt")),
(OsString::from("DEP_A_FLUENT_CHARSET_PATH"), bad()),
(
OsString::from("DEP_B_FLUENT_CHARSET_PATH"),
OsString::from("/b.txt"),
),
];
assert_eq!(dependency_charset_paths(vars), ["/b.txt"]);
}
#[test]
fn missing_locales_dir_watches_closest_existing_ancestor() {
let root = temp_dir("watch");
let out_dir = root.join("target/out");
fs::create_dir_all(&out_dir).unwrap();
fs::create_dir_all(root.join("pkg/assets")).unwrap();
assert_eq!(
missing_dir_watch_path(&root.join("pkg/assets/locales"), &out_dir),
Some(root.join("pkg/assets"))
);
assert_eq!(
missing_dir_watch_path(&root.join("pkg/missing/deeper/locales"), &out_dir),
Some(root.join("pkg"))
);
assert_eq!(
missing_dir_watch_path(&root.join("locales"), &out_dir),
None
);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn lexical_containment_resolves_dot_segments() {
let out = Path::new("/t/debug/build/x/out");
assert!(lexically_inside(
Path::new("/t/debug/build/x/out/c.txt"),
out
));
assert!(lexically_inside(
Path::new("/t/debug/build/x/out/./a/../c.txt"),
out
));
assert!(!lexically_inside(Path::new("/pkg/target/chars.txt"), out));
assert!(!lexically_inside(
Path::new("/t/debug/build/x/out/../c.txt"),
out
));
}
#[test]
fn crate_path_prefixes_cache_entries() {
let entries: HashMap<_, _> = static_cache_entries(
"::fz",
&"en-US".parse().unwrap(),
"a = A\nb = { $x }\n",
true,
)
.into_iter()
.collect();
assert_eq!(entries["a"], r#"::fz::CacheEntry::Static("A")"#);
assert_eq!(entries["b"], "::fz::CacheEntry::Dynamic");
}
#[test]
fn crate_path_prefixes_generated_items() {
let mut out = Vec::new();
FluentZeroBuilder::write_bundle_initializer(&mut out, "::fz", "B", "en-US", "a = A", false)
.unwrap();
FluentZeroBuilder::write_cache_map(&mut out, "::fz", "CACHE_EN_US", &[]).unwrap();
FluentZeroBuilder::write_root_maps(&mut out, "::fz", &[], &[]).unwrap();
FluentZeroBuilder::write_catalog(&mut out, "::fz", "en-US", &[]).unwrap();
FluentZeroBuilder::write_keys(&mut out, &BTreeSet::new(), "en-US", &HashMap::new())
.unwrap();
let out = String::from_utf8(out).unwrap();
assert!(!out.contains("fluent_zero"), "default path leaked: {out}");
assert!(out.contains("::fz::__private::ConcurrentFluentBundle"));
assert!(out.contains("::fz::__private::phf::Map"));
assert!(out.contains("::fz::__private::CatalogLocale"));
assert!(out.contains("pub static CATALOG: ::fz::Catalog = ::fz::__private::catalog("));
}
#[test]
fn validates_crate_paths() {
for ok in ["::fluent_zero", "fz", "crate::reexports::fz", "_fz2"] {
assert!(is_rust_path(ok), "{ok:?} should be accepted");
}
for bad in [
"",
"::",
"fz::",
"a::::b",
"2fz",
"fluent-zero",
"_",
"fz;",
"::a b",
] {
assert!(!is_rust_path(bad), "{bad:?} should be rejected");
}
}
#[test]
fn invalid_crate_path_is_an_error_not_a_panic() {
let err = FluentZeroBuilder::new("locales")
.crate_path("fluent-zero")
.try_generate()
.unwrap_err();
assert!(matches!(&err, Error::InvalidCratePath(p) if p == "fluent-zero"));
assert!(err.to_string().contains("\"fluent-zero\""), "{err}");
}
#[test]
fn io_errors_carry_path_and_source() {
let err = io_err(Path::new("x/y.ftl"))(io::Error::new(io::ErrorKind::NotFound, "gone"));
assert_eq!(
err.to_string(),
format!("{}: gone", Path::new("x/y.ftl").display())
);
assert!(std::error::Error::source(&err).is_some());
}
#[test]
fn unreadable_locale_dir_is_an_io_error() {
let missing = temp_dir("unreadable").join("missing");
let err =
FluentZeroBuilder::process_locale_dir(&missing, "en-US", &mut BTreeSet::new(), true)
.unwrap_err();
assert!(
matches!(&err, Error::Io { path, .. } if path == &missing),
"{err:?}"
);
fs::remove_dir_all(missing.parent().unwrap()).unwrap();
}
}