use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use mf2_catalog::{Dir, Manifest};
use mf2_model::Message;
use crate::catalog::{self, Catalog, Filler};
use crate::codegen;
use crate::config::{Config, DataSet, Layout};
use crate::corpus::{self, LocaleSource};
use crate::error::{Error, Result};
use crate::features::Features;
use crate::lint::{Level, Lint};
use crate::manifest;
use crate::report::{Report, Sink};
use crate::slice;
pub(crate) const MANIFEST_FILE: &str = "manifest.mf2m";
pub(crate) const GENERATED_FILE: &str = "mf2_generated.rs";
pub(crate) const CATALOGS_FILE: &str = "mf2_catalogs.rs";
pub(crate) const INDEX_FILE: &str = "index.json";
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
#[non_exhaustive]
pub enum Emit {
#[default]
Both,
Module,
Catalogs,
Native,
NativeFiles,
}
#[derive(Debug)]
pub struct Build {
root: PathBuf,
out_dir: PathBuf,
config: Option<Config>,
features: Option<Features>,
source_locale: Option<String>,
facade: String,
emit: Emit,
write: bool,
emit_cargo: bool,
inline_manifest: bool,
compress: catalog::Compress,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct LocaleInfo {
pub tag: String,
pub dir: Dir,
pub hash: String,
pub file_name: String,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Outcome {
#[doc(hidden)]
pub manifest: Manifest,
#[doc(hidden)]
pub added: Vec<String>,
#[doc(hidden)]
pub used: Vec<String>,
#[doc(hidden)]
pub defined: BTreeMap<String, (PathBuf, mf2_resource::Position)>,
pub manifest_hash: u64,
pub report: Report,
#[doc(hidden)]
pub coverage: Vec<crate::check::Coverage>,
#[doc(hidden)]
pub catalogs: Vec<Catalog>,
pub locales: Vec<LocaleInfo>,
pub source_locale: String,
pub written: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
pub generated: String,
pub catalogs_module: String,
pub out_dir: PathBuf,
}
impl Outcome {
#[doc(hidden)]
pub fn catalog(&self, tag: &str) -> Option<&Catalog> {
self.catalogs.iter().find(|c| c.tag == tag)
}
pub fn is_clean(&self) -> bool {
self.report.is_clean()
}
pub fn into_result(self) -> Result<Outcome> {
if self.report.is_clean() {
return Ok(self);
}
Err(self.corpus_error())
}
fn corpus_error(&self) -> Error {
Error::Corpus {
errors: self.report.errors(),
locales: self.report.failing_locales().len(),
}
}
pub fn publish(&self, dir: &Path) -> Result<Published> {
if !self.report.is_clean() {
return Err(self.corpus_error());
}
std::fs::create_dir_all(dir).map_err(|source| Error::io(dir.to_path_buf(), source))?;
let mut published = Published::default();
let keep = write_catalogs(dir, &self.catalogs, &mut published.written)?;
let index: serde_json::Map<String, serde_json::Value> = self
.catalogs
.iter()
.map(|c| (c.tag.clone(), serde_json::Value::String(c.file_name())))
.collect();
let mut json = serde_json::Value::Object(index).to_string();
json.push('\n');
let index_path = dir.join(INDEX_FILE);
if catalog::write_if_changed(&index_path, json.as_bytes())? {
published.written.push(index_path);
}
published.removed = catalog::remove_stale(dir, &keep)?;
Ok(published)
}
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct Published {
pub written: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
}
fn write_catalogs(
dir: &Path,
catalogs: &[Catalog],
written: &mut Vec<PathBuf>,
) -> Result<Vec<PathBuf>> {
let mut keep = Vec::new();
for catalog in catalogs {
let base = dir.join(catalog.file_name());
for (path, bytes) in [
(base.clone(), &catalog.bytes),
(with_suffix(&base, ".br"), &catalog.br),
(with_suffix(&base, ".gz"), &catalog.gz),
] {
if bytes.is_empty() {
continue;
}
if catalog::write_if_changed(&path, bytes)? {
written.push(path.clone());
}
keep.push(path);
}
}
Ok(keep)
}
impl Build {
pub fn new() -> Result<Build> {
let root = std::env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
Error::Layout(
"CARGO_MANIFEST_DIR is not set: outside a build script, use \
Build::at(root, out_dir)"
.to_owned(),
)
})?;
let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| {
Error::Layout(
"OUT_DIR is not set: outside a build script, use Build::at(root, out_dir)"
.to_owned(),
)
})?;
let mut build = Build::at(root, out_dir);
if std::env::var_os("PROFILE").is_some_and(|p| p != "release") {
build.compress = catalog::Compress::Fast;
}
Ok(build)
}
pub fn at(root: impl Into<PathBuf>, out_dir: impl Into<PathBuf>) -> Build {
Build {
root: root.into(),
out_dir: out_dir.into(),
config: None,
features: None,
source_locale: None,
facade: "::mf2".to_owned(),
emit: Emit::Both,
write: true,
emit_cargo: false,
inline_manifest: false,
compress: catalog::Compress::Best,
}
}
#[must_use]
pub fn source_locale(mut self, locale: impl Into<String>) -> Build {
self.source_locale = Some(locale.into());
self
}
#[must_use]
pub fn config(mut self, config: Config) -> Build {
self.config = Some(config);
self
}
#[must_use]
pub fn features(mut self, features: Features) -> Build {
self.features = Some(features);
self
}
#[must_use]
pub fn facade(mut self, path: impl Into<String>) -> Build {
self.facade = path.into();
self
}
#[must_use]
pub fn emit(mut self, emit: Emit) -> Build {
self.emit = emit;
self
}
#[must_use]
pub fn emit_cargo(mut self, emit: bool) -> Build {
self.emit_cargo = emit;
self
}
#[must_use]
pub fn manifest_inline(mut self, inline: bool) -> Build {
self.inline_manifest = inline;
self
}
pub fn run(self) -> Result<Outcome> {
self.go(true)
}
pub fn check(self) -> Result<Outcome> {
self.go(false)
}
fn go(mut self, write: bool) -> Result<Outcome> {
self.write = write;
let layout = Layout::new(&self.root);
let mut config = match self.config.take() {
Some(config) => config,
None => Config::load(&self.root)?,
};
if let Some(locale) = &self.source_locale {
config.source_locale.clone_from(locale);
}
let features = match self.features.take() {
Some(features) => features,
None => Features::from_env(),
};
if self.emit_cargo {
println!("cargo::rerun-if-changed={}", layout.locales.display());
let config_file = self.root.join(crate::config::FILE_NAME);
if config_file.is_file() {
println!("cargo::rerun-if-changed={}", config_file.display());
}
}
let outcome = self.corpus(&layout, &config, &features)?;
if self.emit_cargo {
print!("{}", outcome.report.to_cargo_lines());
}
Ok(outcome)
}
fn corpus(&self, layout: &Layout, config: &Config, features: &Features) -> Result<Outcome> {
let mut report = Report::new();
let tags = layout.locales()?;
if !tags.contains(&config.source_locale) {
return Err(Error::Layout(format!(
"the source locale {:?} is not among the locales in {} ({})",
config.source_locale,
layout.locales.display(),
tags.join(", ")
)));
}
let sources = corpus::load(&layout.locales, &tags, config, &mut report)?;
let models: Vec<Vec<Option<Message<'_>>>> = sources
.iter()
.map(|source| corpus::parse(source, &mut report))
.collect();
let indexes: Vec<BTreeMap<&str, usize>> = sources
.iter()
.map(|source| corpus::by_id(source, config, &mut report))
.collect();
let source_index = tags
.iter()
.position(|t| *t == config.source_locale)
.expect("checked above");
let mut functions = BTreeSet::new();
for locale_models in &models {
manifest::functions_of(locale_models, &mut functions);
}
let mut built = manifest::build(
&sources[source_index],
&models[source_index],
&indexes[source_index],
&functions,
);
ids_of_translations(
&sources,
&indexes,
source_index,
&built.manifest.ids,
config,
&mut report,
);
let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
let defined = definitions(&sources[source_index], &indexes[source_index]);
let checked = crate::check::Corpus {
sources: &sources,
models: &models,
indexes: &indexes,
source_index,
manifest: &built,
};
crate::check::corpus(&checked, config, features, &mut report);
let coverage: Vec<crate::check::Coverage> = (0..tags.len())
.map(|locale| crate::check::coverage_of(&checked, locale))
.collect();
if !report.is_clean() {
let used = codegen::uses(&tag_refs, &built.manifest);
return Ok(Outcome {
manifest_hash: built.manifest.hash(),
manifest: built.manifest,
added: Vec::new(),
used,
defined,
report,
coverage,
catalogs: Vec::new(),
locales: Vec::new(),
source_locale: config.source_locale.clone(),
written: Vec::new(),
removed: Vec::new(),
generated: String::new(),
catalogs_module: String::new(),
out_dir: self.out_dir.clone(),
});
}
let added = crate::pseudo::names(&tags, &built.manifest);
for (id, _) in &added {
if let Err(at) = built.manifest.ids.binary_search(id) {
built.manifest.ids.insert(at, id.clone());
built.manifest.slots.insert(at, Vec::new());
built.manifest.markup.insert(at, Vec::new());
built.source_records.insert(at, usize::MAX);
}
}
let by_msg_id: Vec<Vec<Option<&Message<'_>>>> = (0..tags.len())
.map(|locale| {
built
.manifest
.ids
.iter()
.map(|id| {
indexes[locale]
.get(id.as_str())
.and_then(|&record| models[locale].get(record))
.and_then(Option::as_ref)
.or_else(|| added.iter().find(|(a, _)| a == id).map(|(_, m)| m))
})
.collect()
})
.collect();
let filler = Filler::new(&built.manifest.ids, config.catalog.missing);
let mut catalogs = Vec::with_capacity(tags.len());
let mut locales = Vec::with_capacity(tags.len());
for (i, tag) in tags.iter().enumerate() {
let chain_tags = config.chain(tag);
let chain: Vec<&[Option<&Message<'_>>]> = chain_tags
.iter()
.filter_map(|t| tags.iter().position(|x| x == t))
.map(|j| by_msg_id[j].as_slice())
.collect();
let chain_tags: Vec<String> = chain_tags
.into_iter()
.filter(|t| tags.iter().any(|x| x == t))
.collect();
let resolved = catalog::resolve(
&built.manifest.ids,
&by_msg_id[i],
&chain,
config.catalog.missing,
&filler,
);
let flattened: Vec<&Message<'_>> =
resolved.messages.iter().flatten().copied().collect();
let slice = slice::of(&flattened, &config.locale_data, features);
report_slicing(tag, &slice, features, config, &sources[i], &mut report);
let catalog = catalog::write(
tag,
&built.manifest,
&resolved,
&chain_tags,
slice,
config,
match self.emit {
Emit::Both | Emit::Catalogs => self.compress,
_ => catalog::Compress::No,
},
)?;
locales.push(LocaleInfo {
tag: tag.clone(),
dir: catalog::dir_of(tag)?,
hash: catalog.hash.clone(),
file_name: catalog.file_name(),
});
catalogs.push(catalog);
}
let unannotated = catalogs.iter().any(|c| c.slice.unannotated);
let manifest_bytes = built.manifest.write();
let tags: Vec<&str> = locales.iter().map(|l| l.tag.as_str()).collect();
let language_matching = mf2_locale_data::matching::Matching::shipped()?
.cut(&tags)
.encode()?
.rust("__mf2::LanguageMatching");
let markup: Vec<String> = built
.manifest
.markup
.iter()
.flatten()
.cloned()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let names = codegen::named(&tags, &built.manifest);
let used = codegen::uses(&tags, &built.manifest);
if names.contains(&true) && names.contains(&false) {
let missing: Vec<String> = locales
.iter()
.zip(&names)
.filter(|(_, named)| !**named)
.map(|(locale, _)| format!("`language.{}`", locale.tag))
.collect();
let source = &sources[source_index];
let file = source
.loaded
.files
.first()
.map_or_else(|| source.path.clone(), |f| f.path.clone());
Sink::new(&mut report, &config.source_locale).add(
Level::Warn,
None,
&file,
mf2_resource::Position { line: 1, column: 1 },
None,
format!(
"some languages have a name message and some do not ({} missing, each \
with no argument): the locale switcher shows those languages' tags, \
and `Locale::name()` is not generated",
missing.join(", ")
),
);
}
let module = codegen::Module {
facade: &self.facade,
manifest_path: &self.out_dir.join(MANIFEST_FILE),
manifest_hash: built.manifest.hash(),
source_locale: &config.source_locale,
locales: &locales,
language_matching: &language_matching,
functions: &built.manifest.functions,
custom: &config.functions,
features,
unannotated,
messages: built.manifest.ids.len(),
emit: self.emit,
manifest_bytes: if self.inline_manifest {
Some(manifest_bytes.as_slice())
} else {
None
},
markup: &markup,
names: &names,
};
codegen::check(&module)?;
let generated = codegen::write(&module);
let catalogs_module = if self.emit == Emit::Catalogs {
codegen::write_catalogs(&module)
} else {
String::new()
};
let mut outcome = Outcome {
manifest_hash: built.manifest.hash(),
manifest: built.manifest,
added: added.into_iter().map(|(id, _)| id).collect(),
used,
defined,
report,
coverage,
catalogs,
locales,
source_locale: config.source_locale.clone(),
written: Vec::new(),
removed: Vec::new(),
generated,
catalogs_module,
out_dir: self.out_dir.clone(),
};
if self.write && outcome.report.is_clean() {
self.write_outputs(&mut outcome)?;
}
Ok(outcome)
}
fn write_outputs(&self, outcome: &mut Outcome) -> Result<()> {
std::fs::create_dir_all(&self.out_dir)
.map_err(|source| Error::io(self.out_dir.clone(), source))?;
if self.emit != Emit::Catalogs {
let manifest_path = self.out_dir.join(MANIFEST_FILE);
if catalog::write_if_changed(&manifest_path, &outcome.manifest.write())? {
outcome.written.push(manifest_path);
}
let generated_path = self.out_dir.join(GENERATED_FILE);
if catalog::write_if_changed(&generated_path, outcome.generated.as_bytes())? {
outcome.written.push(generated_path);
}
}
if self.emit == Emit::Module {
outcome.removed = catalog::remove_stale(&self.out_dir, &[])?;
return Ok(());
}
if self.emit == Emit::Catalogs {
let path = self.out_dir.join(CATALOGS_FILE);
if catalog::write_if_changed(&path, outcome.catalogs_module.as_bytes())? {
outcome.written.push(path);
}
}
let keep = write_catalogs(&self.out_dir, &outcome.catalogs, &mut outcome.written)?;
outcome.removed = catalog::remove_stale(&self.out_dir, &keep)?;
Ok(())
}
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
fn definitions(
source: &LocaleSource,
index: &BTreeMap<&str, usize>,
) -> BTreeMap<String, (PathBuf, mf2_resource::Position)> {
index
.iter()
.filter_map(|(id, &record)| {
let record = source.loaded.records.get(record)?;
let file = source.loaded.files.get(record.file)?;
Some((
(*id).to_owned(),
(file.path.clone(), file.position(record.id_span.start)),
))
})
.collect()
}
fn ids_of_translations(
sources: &[LocaleSource],
indexes: &[BTreeMap<&str, usize>],
source_index: usize,
ids: &[String],
config: &Config,
report: &mut Report,
) {
let known: BTreeSet<&str> = ids.iter().map(String::as_str).collect();
for (i, source) in sources.iter().enumerate() {
if i == source_index {
continue;
}
let mut sink = Sink::new(report, &source.tag);
for (id, &record) in &indexes[i] {
if known.contains(id) {
continue;
}
let record = &source.loaded.records[record];
let file = &source.loaded.files[record.file];
sink.add(
config.level(Lint::ExtraId),
Some(Lint::ExtraId),
&file.path,
file.position(record.id_span.start),
Some(id),
format!(
"the source locale {:?} has no message with this id",
sources[source_index].tag
),
);
}
}
}
fn report_slicing(
tag: &str,
slice: &slice::Slice,
features: &Features,
config: &Config,
source: &LocaleSource,
report: &mut Report,
) {
let file = source
.loaded
.files
.first()
.map_or_else(|| source.path.clone(), |f| f.path.clone());
let at = mf2_resource::Position { line: 1, column: 1 };
let mut sink = Sink::new(report, tag);
if slice.formats_numbers && !features.fn_number() {
sink.add(
config.level(Lint::NeutralNumbers),
Some(Lint::NeutralNumbers),
&file,
at,
None,
"a placeholder in this locale's messages can receive a number, and \
`fn-number` is off: a number it receives renders without the locale's \
symbols, grouping or numbering system (if such placeholders only ever \
receive text, set `neutral-numbers = \"allow\"` in mf2.toml)",
);
}
let listed = |set: &DataSet| matches!(set, DataSet::Listed(_));
if slice.dynamic_currency && !listed(&config.locale_data.currencies) {
sink.add(
config.level(Lint::DynamicCurrency),
Some(Lint::DynamicCurrency),
&file,
at,
None,
"a `:currency` takes its currency from a variable, so the catalog \
carries every currency CLDR has; listing the codes it can hold \
under [locale_data] currencies carries only those",
);
}
if slice.dynamic_unit && !listed(&config.locale_data.units) {
sink.add(
config.level(Lint::DynamicUnit),
Some(Lint::DynamicUnit),
&file,
at,
None,
"a `:unit` takes its unit from a variable, so the catalog carries \
every unit CLDR has; listing the ids it can hold under \
[locale_data] units carries only those",
);
}
let _ = Level::Warn;
}