1use std::collections::{BTreeMap, BTreeSet};
18use std::path::{Path, PathBuf};
19
20use mf2_catalog::{Dir, Manifest};
21use mf2_model::Message;
22
23use crate::catalog::{self, Catalog, Filler};
24use crate::codegen;
25use crate::config::{Config, DataSet, Layout};
26use crate::corpus::{self, LocaleSource};
27use crate::error::{Error, Result};
28use crate::features::Features;
29use crate::lint::{Level, Lint};
30use crate::manifest;
31use crate::report::{Report, Sink};
32use crate::slice;
33
34pub(crate) const MANIFEST_FILE: &str = "manifest.mf2m";
36pub(crate) const GENERATED_FILE: &str = "mf2_generated.rs";
38pub(crate) const CATALOGS_FILE: &str = "mf2_catalogs.rs";
40pub(crate) const INDEX_FILE: &str = "index.json";
44
45#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
59#[non_exhaustive]
60pub enum Emit {
61 #[default]
64 Both,
65 Module,
67 Catalogs,
69 Native,
73 NativeFiles,
77}
78
79#[derive(Debug)]
81pub struct Build {
82 root: PathBuf,
83 out_dir: PathBuf,
84 config: Option<Config>,
85 features: Option<Features>,
86 source_locale: Option<String>,
87 facade: String,
88 emit: Emit,
89 write: bool,
90 emit_cargo: bool,
91 inline_manifest: bool,
92 compress: catalog::Compress,
95}
96
97#[derive(Clone, Debug)]
99#[non_exhaustive]
100pub struct LocaleInfo {
101 pub tag: String,
103 pub dir: Dir,
105 pub hash: String,
107 pub file_name: String,
109}
110
111#[derive(Debug)]
113#[non_exhaustive]
114pub struct Outcome {
115 #[doc(hidden)]
117 pub manifest: Manifest,
118 #[doc(hidden)]
121 pub added: Vec<String>,
122 #[doc(hidden)]
125 pub used: Vec<String>,
126 #[doc(hidden)]
128 pub defined: BTreeMap<String, (PathBuf, mf2_resource::Position)>,
129 pub manifest_hash: u64,
131 pub report: Report,
133 #[doc(hidden)]
136 pub coverage: Vec<crate::check::Coverage>,
137 #[doc(hidden)]
139 pub catalogs: Vec<Catalog>,
140 pub locales: Vec<LocaleInfo>,
142 pub source_locale: String,
144 pub written: Vec<PathBuf>,
146 pub removed: Vec<PathBuf>,
148 pub generated: String,
150 pub catalogs_module: String,
153 pub out_dir: PathBuf,
155}
156
157impl Outcome {
158 #[doc(hidden)]
160 pub fn catalog(&self, tag: &str) -> Option<&Catalog> {
161 self.catalogs.iter().find(|c| c.tag == tag)
162 }
163
164 pub fn is_clean(&self) -> bool {
166 self.report.is_clean()
167 }
168
169 pub fn into_result(self) -> Result<Outcome> {
172 if self.report.is_clean() {
173 return Ok(self);
174 }
175 Err(self.corpus_error())
176 }
177
178 fn corpus_error(&self) -> Error {
179 Error::Corpus {
180 errors: self.report.errors(),
181 locales: self.report.failing_locales().len(),
182 }
183 }
184
185 pub fn publish(&self, dir: &Path) -> Result<Published> {
196 if !self.report.is_clean() {
197 return Err(self.corpus_error());
198 }
199 std::fs::create_dir_all(dir).map_err(|source| Error::io(dir.to_path_buf(), source))?;
200 let mut published = Published::default();
201 let keep = write_catalogs(dir, &self.catalogs, &mut published.written)?;
202 let index: serde_json::Map<String, serde_json::Value> = self
203 .catalogs
204 .iter()
205 .map(|c| (c.tag.clone(), serde_json::Value::String(c.file_name())))
206 .collect();
207 let mut json = serde_json::Value::Object(index).to_string();
208 json.push('\n');
209 let index_path = dir.join(INDEX_FILE);
210 if catalog::write_if_changed(&index_path, json.as_bytes())? {
211 published.written.push(index_path);
212 }
213 published.removed = catalog::remove_stale(dir, &keep)?;
214 Ok(published)
215 }
216}
217
218#[derive(Debug, Default)]
220#[non_exhaustive]
221pub struct Published {
222 pub written: Vec<PathBuf>,
224 pub removed: Vec<PathBuf>,
226}
227
228fn write_catalogs(
232 dir: &Path,
233 catalogs: &[Catalog],
234 written: &mut Vec<PathBuf>,
235) -> Result<Vec<PathBuf>> {
236 let mut keep = Vec::new();
237 for catalog in catalogs {
238 let base = dir.join(catalog.file_name());
239 for (path, bytes) in [
240 (base.clone(), &catalog.bytes),
241 (with_suffix(&base, ".br"), &catalog.br),
242 (with_suffix(&base, ".gz"), &catalog.gz),
243 ] {
244 if bytes.is_empty() {
246 continue;
247 }
248 if catalog::write_if_changed(&path, bytes)? {
249 written.push(path.clone());
250 }
251 keep.push(path);
252 }
253 }
254 Ok(keep)
255}
256
257impl Build {
258 pub fn new() -> Result<Build> {
261 let root = std::env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
262 Error::Layout(
263 "CARGO_MANIFEST_DIR is not set: outside a build script, use \
264 Build::at(root, out_dir)"
265 .to_owned(),
266 )
267 })?;
268 let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| {
269 Error::Layout(
270 "OUT_DIR is not set: outside a build script, use Build::at(root, out_dir)"
271 .to_owned(),
272 )
273 })?;
274 let mut build = Build::at(root, out_dir);
275 if std::env::var_os("PROFILE").is_some_and(|p| p != "release") {
279 build.compress = catalog::Compress::Fast;
280 }
281 Ok(build)
282 }
283
284 pub fn at(root: impl Into<PathBuf>, out_dir: impl Into<PathBuf>) -> Build {
286 Build {
287 root: root.into(),
288 out_dir: out_dir.into(),
289 config: None,
290 features: None,
291 source_locale: None,
292 facade: "::mf2".to_owned(),
293 emit: Emit::Both,
294 write: true,
295 emit_cargo: false,
296 inline_manifest: false,
297 compress: catalog::Compress::Best,
298 }
299 }
300
301 #[must_use]
303 pub fn source_locale(mut self, locale: impl Into<String>) -> Build {
304 self.source_locale = Some(locale.into());
305 self
306 }
307
308 #[must_use]
310 pub fn config(mut self, config: Config) -> Build {
311 self.config = Some(config);
312 self
313 }
314
315 #[must_use]
317 pub fn features(mut self, features: Features) -> Build {
318 self.features = Some(features);
319 self
320 }
321
322 #[must_use]
325 pub fn facade(mut self, path: impl Into<String>) -> Build {
326 self.facade = path.into();
327 self
328 }
329
330 #[must_use]
332 pub fn emit(mut self, emit: Emit) -> Build {
333 self.emit = emit;
334 self
335 }
336
337 #[must_use]
339 pub fn emit_cargo(mut self, emit: bool) -> Build {
340 self.emit_cargo = emit;
341 self
342 }
343
344 #[must_use]
351 pub fn manifest_inline(mut self, inline: bool) -> Build {
352 self.inline_manifest = inline;
353 self
354 }
355
356 pub fn run(self) -> Result<Outcome> {
358 self.go(true)
359 }
360
361 pub fn check(self) -> Result<Outcome> {
363 self.go(false)
364 }
365
366 fn go(mut self, write: bool) -> Result<Outcome> {
367 self.write = write;
368 let layout = Layout::new(&self.root);
369 let mut config = match self.config.take() {
370 Some(config) => config,
371 None => Config::load(&self.root)?,
372 };
373 if let Some(locale) = &self.source_locale {
374 config.source_locale.clone_from(locale);
375 }
376 let features = match self.features.take() {
377 Some(features) => features,
378 None => Features::from_env(),
379 };
380 if self.emit_cargo {
381 println!("cargo::rerun-if-changed={}", layout.locales.display());
382 let config_file = self.root.join(crate::config::FILE_NAME);
385 if config_file.is_file() {
386 println!("cargo::rerun-if-changed={}", config_file.display());
387 }
388 }
389 let outcome = self.corpus(&layout, &config, &features)?;
390 if self.emit_cargo {
391 print!("{}", outcome.report.to_cargo_lines());
392 }
393 Ok(outcome)
394 }
395
396 fn corpus(&self, layout: &Layout, config: &Config, features: &Features) -> Result<Outcome> {
397 let mut report = Report::new();
398 let tags = layout.locales()?;
399 if !tags.contains(&config.source_locale) {
400 return Err(Error::Layout(format!(
401 "the source locale {:?} is not among the locales in {} ({})",
402 config.source_locale,
403 layout.locales.display(),
404 tags.join(", ")
405 )));
406 }
407 let sources = corpus::load(&layout.locales, &tags, config, &mut report)?;
408 let models: Vec<Vec<Option<Message<'_>>>> = sources
409 .iter()
410 .map(|source| corpus::parse(source, &mut report))
411 .collect();
412 let indexes: Vec<BTreeMap<&str, usize>> = sources
413 .iter()
414 .map(|source| corpus::by_id(source, config, &mut report))
415 .collect();
416
417 let source_index = tags
418 .iter()
419 .position(|t| *t == config.source_locale)
420 .expect("checked above");
421 let mut functions = BTreeSet::new();
422 for locale_models in &models {
423 manifest::functions_of(locale_models, &mut functions);
424 }
425 let mut built = manifest::build(
426 &sources[source_index],
427 &models[source_index],
428 &indexes[source_index],
429 &functions,
430 );
431 ids_of_translations(
432 &sources,
433 &indexes,
434 source_index,
435 &built.manifest.ids,
436 config,
437 &mut report,
438 );
439 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
440 let defined = definitions(&sources[source_index], &indexes[source_index]);
441 let checked = crate::check::Corpus {
442 sources: &sources,
443 models: &models,
444 indexes: &indexes,
445 source_index,
446 manifest: &built,
447 };
448 crate::check::corpus(&checked, config, features, &mut report);
449 let coverage: Vec<crate::check::Coverage> = (0..tags.len())
450 .map(|locale| crate::check::coverage_of(&checked, locale))
451 .collect();
452
453 if !report.is_clean() {
458 let used = codegen::uses(&tag_refs, &built.manifest);
459 return Ok(Outcome {
460 manifest_hash: built.manifest.hash(),
461 manifest: built.manifest,
462 added: Vec::new(),
463 used,
464 defined,
465 report,
466 coverage,
467 catalogs: Vec::new(),
468 locales: Vec::new(),
469 source_locale: config.source_locale.clone(),
470 written: Vec::new(),
471 removed: Vec::new(),
472 generated: String::new(),
473 catalogs_module: String::new(),
474 out_dir: self.out_dir.clone(),
475 });
476 }
477
478 let added = crate::pseudo::names(&tags, &built.manifest);
481 for (id, _) in &added {
482 if let Err(at) = built.manifest.ids.binary_search(id) {
483 built.manifest.ids.insert(at, id.clone());
484 built.manifest.slots.insert(at, Vec::new());
485 built.manifest.markup.insert(at, Vec::new());
486 built.source_records.insert(at, usize::MAX);
488 }
489 }
490
491 let by_msg_id: Vec<Vec<Option<&Message<'_>>>> = (0..tags.len())
493 .map(|locale| {
494 built
495 .manifest
496 .ids
497 .iter()
498 .map(|id| {
499 indexes[locale]
500 .get(id.as_str())
501 .and_then(|&record| models[locale].get(record))
502 .and_then(Option::as_ref)
503 .or_else(|| added.iter().find(|(a, _)| a == id).map(|(_, m)| m))
504 })
505 .collect()
506 })
507 .collect();
508
509 let filler = Filler::new(&built.manifest.ids, config.catalog.missing);
510 let mut catalogs = Vec::with_capacity(tags.len());
511 let mut locales = Vec::with_capacity(tags.len());
512 for (i, tag) in tags.iter().enumerate() {
513 let chain_tags = config.chain(tag);
514 let chain: Vec<&[Option<&Message<'_>>]> = chain_tags
515 .iter()
516 .filter_map(|t| tags.iter().position(|x| x == t))
517 .map(|j| by_msg_id[j].as_slice())
518 .collect();
519 let chain_tags: Vec<String> = chain_tags
520 .into_iter()
521 .filter(|t| tags.iter().any(|x| x == t))
522 .collect();
523 let resolved = catalog::resolve(
524 &built.manifest.ids,
525 &by_msg_id[i],
526 &chain,
527 config.catalog.missing,
528 &filler,
529 );
530 let flattened: Vec<&Message<'_>> =
531 resolved.messages.iter().flatten().copied().collect();
532 let slice = slice::of(&flattened, &config.locale_data, features);
533 report_slicing(tag, &slice, features, config, &sources[i], &mut report);
534 let catalog = catalog::write(
535 tag,
536 &built.manifest,
537 &resolved,
538 &chain_tags,
539 slice,
540 config,
541 match self.emit {
543 Emit::Both | Emit::Catalogs => self.compress,
544 _ => catalog::Compress::No,
545 },
546 )?;
547 locales.push(LocaleInfo {
548 tag: tag.clone(),
549 dir: catalog::dir_of(tag)?,
550 hash: catalog.hash.clone(),
551 file_name: catalog.file_name(),
552 });
553 catalogs.push(catalog);
554 }
555
556 let unannotated = catalogs.iter().any(|c| c.slice.unannotated);
557 let manifest_bytes = built.manifest.write();
558 let tags: Vec<&str> = locales.iter().map(|l| l.tag.as_str()).collect();
563 let language_matching = mf2_locale_data::matching::Matching::shipped()?
564 .cut(&tags)
565 .encode()?
566 .rust("__mf2::LanguageMatching");
567 let markup: Vec<String> = built
572 .manifest
573 .markup
574 .iter()
575 .flatten()
576 .cloned()
577 .collect::<std::collections::BTreeSet<_>>()
578 .into_iter()
579 .collect();
580 let names = codegen::named(&tags, &built.manifest);
581 let used = codegen::uses(&tags, &built.manifest);
582 if names.contains(&true) && names.contains(&false) {
585 let missing: Vec<String> = locales
586 .iter()
587 .zip(&names)
588 .filter(|(_, named)| !**named)
589 .map(|(locale, _)| format!("`language.{}`", locale.tag))
590 .collect();
591 let source = &sources[source_index];
592 let file = source
593 .loaded
594 .files
595 .first()
596 .map_or_else(|| source.path.clone(), |f| f.path.clone());
597 Sink::new(&mut report, &config.source_locale).add(
598 Level::Warn,
599 None,
600 &file,
601 mf2_resource::Position { line: 1, column: 1 },
602 None,
603 format!(
604 "some languages have a name message and some do not ({} missing, each \
605 with no argument): the locale switcher shows those languages' tags, \
606 and `Locale::name()` is not generated",
607 missing.join(", ")
608 ),
609 );
610 }
611 let module = codegen::Module {
612 facade: &self.facade,
613 manifest_path: &self.out_dir.join(MANIFEST_FILE),
614 manifest_hash: built.manifest.hash(),
615 source_locale: &config.source_locale,
616 locales: &locales,
617 language_matching: &language_matching,
618 functions: &built.manifest.functions,
619 custom: &config.functions,
620 features,
621 unannotated,
622 messages: built.manifest.ids.len(),
623 emit: self.emit,
624 manifest_bytes: if self.inline_manifest {
625 Some(manifest_bytes.as_slice())
626 } else {
627 None
628 },
629 markup: &markup,
630 names: &names,
631 };
632 codegen::check(&module)?;
633 let generated = codegen::write(&module);
634 let catalogs_module = if self.emit == Emit::Catalogs {
635 codegen::write_catalogs(&module)
636 } else {
637 String::new()
638 };
639 let mut outcome = Outcome {
640 manifest_hash: built.manifest.hash(),
641 manifest: built.manifest,
642 added: added.into_iter().map(|(id, _)| id).collect(),
643 used,
644 defined,
645 report,
646 coverage,
647 catalogs,
648 locales,
649 source_locale: config.source_locale.clone(),
650 written: Vec::new(),
651 removed: Vec::new(),
652 generated,
653 catalogs_module,
654 out_dir: self.out_dir.clone(),
655 };
656 if self.write && outcome.report.is_clean() {
659 self.write_outputs(&mut outcome)?;
660 }
661 Ok(outcome)
662 }
663
664 fn write_outputs(&self, outcome: &mut Outcome) -> Result<()> {
666 std::fs::create_dir_all(&self.out_dir)
667 .map_err(|source| Error::io(self.out_dir.clone(), source))?;
668 if self.emit != Emit::Catalogs {
669 let manifest_path = self.out_dir.join(MANIFEST_FILE);
670 if catalog::write_if_changed(&manifest_path, &outcome.manifest.write())? {
671 outcome.written.push(manifest_path);
672 }
673 let generated_path = self.out_dir.join(GENERATED_FILE);
674 if catalog::write_if_changed(&generated_path, outcome.generated.as_bytes())? {
675 outcome.written.push(generated_path);
676 }
677 }
678 if self.emit == Emit::Module {
679 outcome.removed = catalog::remove_stale(&self.out_dir, &[])?;
685 return Ok(());
686 }
687 if self.emit == Emit::Catalogs {
688 let path = self.out_dir.join(CATALOGS_FILE);
689 if catalog::write_if_changed(&path, outcome.catalogs_module.as_bytes())? {
690 outcome.written.push(path);
691 }
692 }
693 let keep = write_catalogs(&self.out_dir, &outcome.catalogs, &mut outcome.written)?;
694 outcome.removed = catalog::remove_stale(&self.out_dir, &keep)?;
695 Ok(())
696 }
697}
698
699fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
700 let mut name = path.as_os_str().to_os_string();
701 name.push(suffix);
702 PathBuf::from(name)
703}
704
705fn definitions(
707 source: &LocaleSource,
708 index: &BTreeMap<&str, usize>,
709) -> BTreeMap<String, (PathBuf, mf2_resource::Position)> {
710 index
711 .iter()
712 .filter_map(|(id, &record)| {
713 let record = source.loaded.records.get(record)?;
714 let file = source.loaded.files.get(record.file)?;
715 Some((
716 (*id).to_owned(),
717 (file.path.clone(), file.position(record.id_span.start)),
718 ))
719 })
720 .collect()
721}
722
723fn ids_of_translations(
726 sources: &[LocaleSource],
727 indexes: &[BTreeMap<&str, usize>],
728 source_index: usize,
729 ids: &[String],
730 config: &Config,
731 report: &mut Report,
732) {
733 let known: BTreeSet<&str> = ids.iter().map(String::as_str).collect();
734 for (i, source) in sources.iter().enumerate() {
735 if i == source_index {
736 continue;
737 }
738 let mut sink = Sink::new(report, &source.tag);
739 for (id, &record) in &indexes[i] {
740 if known.contains(id) {
741 continue;
742 }
743 let record = &source.loaded.records[record];
744 let file = &source.loaded.files[record.file];
745 sink.add(
746 config.level(Lint::ExtraId),
747 Some(Lint::ExtraId),
748 &file.path,
749 file.position(record.id_span.start),
750 Some(id),
751 format!(
752 "the source locale {:?} has no message with this id",
753 sources[source_index].tag
754 ),
755 );
756 }
757 }
758}
759
760fn report_slicing(
763 tag: &str,
764 slice: &slice::Slice,
765 features: &Features,
766 config: &Config,
767 source: &LocaleSource,
768 report: &mut Report,
769) {
770 let file = source
771 .loaded
772 .files
773 .first()
774 .map_or_else(|| source.path.clone(), |f| f.path.clone());
775 let at = mf2_resource::Position { line: 1, column: 1 };
776 let mut sink = Sink::new(report, tag);
777 if slice.formats_numbers && !features.fn_number() {
778 sink.add(
779 config.level(Lint::NeutralNumbers),
780 Some(Lint::NeutralNumbers),
781 &file,
782 at,
783 None,
784 "a placeholder in this locale's messages can receive a number, and \
785 `fn-number` is off: a number it receives renders without the locale's \
786 symbols, grouping or numbering system (if such placeholders only ever \
787 receive text, set `neutral-numbers = \"allow\"` in mf2.toml)",
788 );
789 }
790 let listed = |set: &DataSet| matches!(set, DataSet::Listed(_));
793 if slice.dynamic_currency && !listed(&config.locale_data.currencies) {
794 sink.add(
795 config.level(Lint::DynamicCurrency),
796 Some(Lint::DynamicCurrency),
797 &file,
798 at,
799 None,
800 "a `:currency` takes its currency from a variable, so the catalog \
801 carries every currency CLDR has; listing the codes it can hold \
802 under [locale_data] currencies carries only those",
803 );
804 }
805 if slice.dynamic_unit && !listed(&config.locale_data.units) {
806 sink.add(
807 config.level(Lint::DynamicUnit),
808 Some(Lint::DynamicUnit),
809 &file,
810 at,
811 None,
812 "a `:unit` takes its unit from a variable, so the catalog carries \
813 every unit CLDR has; listing the ids it can hold under \
814 [locale_data] units carries only those",
815 );
816 }
817 let _ = Level::Warn;
818}