Skip to main content

mf2_build/
build.rs

1//! The entry point: everything a `build.rs` (or `mf2-cli`) does, in order.
2//!
3//! ```no_run
4//! # fn main() -> Result<(), mf2_build::Error> {
5//! // `emit_cargo` prints the report as cargo warnings and errors, and
6//! // `into_result` turns an error count into a failed build: a corpus with
7//! // errors in it writes nothing, so a build script that does not check
8//! // would go on compiling against the *previous* build's output.
9//! mf2_build::Build::new()?
10//!     .source_locale("en")
11//!     .emit_cargo(true)
12//!     .run()?
13//!     .into_result()?;
14//! # Ok(()) }
15//! ```
16
17use 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
34/// The manifest's file name in `OUT_DIR` (`plans/02-catalog-format.md` §5).
35pub(crate) const MANIFEST_FILE: &str = "manifest.mf2m";
36/// The generated module's file name in `OUT_DIR`.
37pub(crate) const GENERATED_FILE: &str = "mf2_generated.rs";
38/// The catalog table's file name, when the two are emitted apart.
39pub(crate) const CATALOGS_FILE: &str = "mf2_catalogs.rs";
40/// The catalog index's file name in a published site ([`Outcome::publish`]):
41/// what a client-only application reads to learn each locale's hashed URL
42/// (`plans/04-leptos-integration.md` §8).
43pub(crate) const INDEX_FILE: &str = "index.json";
44
45/// What a build writes (`plans/05-tooling.md` §4; owner question 1 of
46/// `plans/12-phase-5a-work-order.md`).
47///
48/// Every locale change rewrites a catalog, and a catalog is named by its
49/// content hash, so a build that writes both puts a new name in the
50/// generated module — and cargo recompiles the i18n crate and everything
51/// that depends on it, in both of cargo-leptos' builds.
52///
53/// Splitting the two takes that cost away from the client: the i18n crate
54/// emits [`Emit::Module`], which editing a translation's text never changes,
55/// and a crate only the server binary depends on emits [`Emit::Catalogs`].
56/// (A translation that introduces a *function* the source does not use still
57/// moves the manifest hash, and must — see [`crate::manifest`].)
58#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
59#[non_exhaustive]
60pub enum Emit {
61    /// The manifest, the catalogs and a module that names them (the
62    /// default: one crate, one build script).
63    #[default]
64    Both,
65    /// The manifest and a module with no catalog name, hash or byte in it.
66    Module,
67    /// The catalogs and the table that embeds them, for a server-only crate.
68    Catalogs,
69    /// For a native application (`mf2::native`): the manifest, the catalogs,
70    /// and a module that formats through the native host, with nothing
71    /// behind `ssr` and one `CORPUS` value that embeds the catalogs.
72    Native,
73    /// As [`Emit::Native`], but the catalogs are only written to the output
74    /// directory — the application ships them and loads them from a
75    /// directory — and `CORPUS` names their files without embedding them.
76    NativeFiles,
77}
78
79/// A build, configured.
80#[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    /// How a web server's catalogs are compressed: at a fast level in a
93    /// debug build's script (plans/19 §11), else at the best.
94    compress: catalog::Compress,
95}
96
97/// One locale in the built corpus.
98#[derive(Clone, Debug)]
99#[non_exhaustive]
100pub struct LocaleInfo {
101    /// The BCP 47 tag.
102    pub tag: String,
103    /// Its base direction.
104    pub dir: Dir,
105    /// The content hash of its catalog.
106    pub hash: String,
107    /// The file name the server publishes.
108    pub file_name: String,
109}
110
111/// What a build produced.
112#[derive(Debug)]
113#[non_exhaustive]
114pub struct Outcome {
115    /// The manifest, from the source locale.
116    #[doc(hidden)]
117    pub manifest: Manifest,
118    /// The ids of `manifest` that the build added and no file holds: the
119    /// pseudo-locales' `language.<tag>` names.
120    #[doc(hidden)]
121    pub added: Vec<String>,
122    /// The ids the generated module names itself (the languages' names),
123    /// which `unused-id` counts as used.
124    #[doc(hidden)]
125    pub used: Vec<String>,
126    /// Where the source locale defines each of its ids, for `unused-id`.
127    #[doc(hidden)]
128    pub defined: BTreeMap<String, (PathBuf, mf2_resource::Position)>,
129    /// Its hash — what the wasm and every catalog agree on.
130    pub manifest_hash: u64,
131    /// Everything the build has to say.
132    pub report: Report,
133    /// Per locale, in tag order: how many of the messages that need
134    /// translating it has, which `mf2 stats` reports.
135    #[doc(hidden)]
136    pub coverage: Vec<crate::check::Coverage>,
137    /// One catalog per locale, in tag order.
138    #[doc(hidden)]
139    pub catalogs: Vec<Catalog>,
140    /// The locale table the generated module carries.
141    pub locales: Vec<LocaleInfo>,
142    /// The source locale.
143    pub source_locale: String,
144    /// The files whose bytes changed (and were written).
145    pub written: Vec<PathBuf>,
146    /// Old catalogs removed.
147    pub removed: Vec<PathBuf>,
148    /// The generated Rust module's source.
149    pub generated: String,
150    /// The catalog table's source, when the build emits it apart
151    /// ([`Emit::Catalogs`]); empty otherwise.
152    pub catalogs_module: String,
153    /// Where the outputs went.
154    pub out_dir: PathBuf,
155}
156
157impl Outcome {
158    /// The catalog of `tag`.
159    #[doc(hidden)]
160    pub fn catalog(&self, tag: &str) -> Option<&Catalog> {
161        self.catalogs.iter().find(|c| c.tag == tag)
162    }
163
164    /// Whether the corpus had no errors.
165    pub fn is_clean(&self) -> bool {
166        self.report.is_clean()
167    }
168
169    /// The outcome if the corpus was clean, else the error a build script
170    /// fails with — the report is printed before it, as warnings and errors.
171    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    /// Writes what a static host serves into `dir`: every catalog under its
186    /// content-hashed name (with its `.br` and `.gz`, when the build
187    /// compressed them) and `index.json`, `{"<tag>": "<file name>", …}`,
188    /// which a client-only application reads to find them. Nothing else —
189    /// not the manifest, not the generated module — so the whole directory
190    /// can be published as it is. Catalogs an earlier publish left in `dir`
191    /// are removed; each file is written only when its bytes change.
192    ///
193    /// The catalogs are immutable and may be cached forever; the index is
194    /// not, and a host should serve it `no-cache`.
195    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/// What [`Outcome::publish`] did.
219#[derive(Debug, Default)]
220#[non_exhaustive]
221pub struct Published {
222    /// The files whose bytes changed (and were written).
223    pub written: Vec<PathBuf>,
224    /// Old catalogs removed.
225    pub removed: Vec<PathBuf>,
226}
227
228/// Writes each catalog under its content-hashed name, with the compressed
229/// variants the build made, into `dir`; returns every path that belongs
230/// there, for [`catalog::remove_stale`].
231fn 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            // An `Emit::Module` build does not compress.
245            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    /// A build of the crate cargo is compiling: `CARGO_MANIFEST_DIR` for the
259    /// corpus, `OUT_DIR` for the outputs.
260    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        // A debug build compresses at a fast level: maximum-quality brotli
276        // cost seconds per translation edit in an unoptimized build script.
277        // Cargo says `release` for `release` and the profiles that inherit it.
278        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    /// A build of the corpus at `root`, writing to `out_dir`.
285    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    /// Overrides `mf2.toml`'s `source_locale`.
302    #[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    /// Uses this configuration instead of reading `mf2.toml`.
309    #[must_use]
310    pub fn config(mut self, config: Config) -> Build {
311        self.config = Some(config);
312        self
313    }
314
315    /// Uses this feature set instead of reading `CARGO_FEATURE_*`.
316    #[must_use]
317    pub fn features(mut self, features: Features) -> Build {
318        self.features = Some(features);
319        self
320    }
321
322    /// The crate path the generated module re-exports as `__mf2`
323    /// (`::mf2`). A test with a stub facade names its own.
324    #[must_use]
325    pub fn facade(mut self, path: impl Into<String>) -> Build {
326        self.facade = path.into();
327        self
328    }
329
330    /// What to write (the default is [`Emit::Both`]).
331    #[must_use]
332    pub fn emit(mut self, emit: Emit) -> Build {
333        self.emit = emit;
334        self
335    }
336
337    /// Prints `cargo::rerun-if-changed` and `cargo::warning` lines.
338    #[must_use]
339    pub fn emit_cargo(mut self, emit: bool) -> Build {
340        self.emit_cargo = emit;
341        self
342    }
343
344    /// Bakes the manifest's **bytes** into the `tr!` wrapper instead of its
345    /// path (`plans/05-tooling.md` §4): every expansion is then independent
346    /// of where the target directory lives, which is what remote execution
347    /// and a relocated CI cache need. It costs macro time and manifest size
348    /// × call sites in the generated module, so it is opt-in (P0.9:
349    /// +0.5 s per 2,000 sites).
350    #[must_use]
351    pub fn manifest_inline(mut self, inline: bool) -> Build {
352        self.inline_manifest = inline;
353        self
354    }
355
356    /// Runs the whole pipeline and writes the outputs.
357    pub fn run(self) -> Result<Outcome> {
358        self.go(true)
359    }
360
361    /// Runs everything but writes nothing — `mf2 check`.
362    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            // Only a file that exists: cargo reruns a script whose watched
383            // file is missing on every build, and `mf2.toml` is optional.
384            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        // A corpus with errors is not written: the catalog writer would
454        // refuse half of what the lints just reported (a translation using a
455        // variable with no slot, say) and the reader would see the writer's
456        // words instead of the lint's.
457        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        // The pseudo-locales' names, once the lints have run: they are the
479        // build's, not a translator's.
480        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                // No source record holds it.
487                built.source_records.insert(at, usize::MAX);
488            }
489        }
490
491        // Per locale, the models in `MsgId` order, then the fallback chain.
492        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                // Only a web server serves them compressed.
542                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        // The part of CLDR's language-matching data these locales need
559        // (plans/19-native-and-terminal.md §9): all a browser's client
560        // carries of it. It depends on the tags alone, so a translation
561        // edit leaves it, and the module, as they are.
562        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        // What `markup::*` and `Locale::name()` are made of: the source's
568        // markup names, and whether every locale has an argument-free
569        // `language.<tag>` message. Both come from the manifest, which a
570        // translation edit leaves as it is.
571        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        // Some languages named and not all: the switcher shows the others'
583        // tags, and `Locale::name()` is not generated.
584        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        // A corpus with errors comes back with its report, not as an
657        // `Err`: the caller prints it.
658        if self.write && outcome.report.is_clean() {
659            self.write_outputs(&mut outcome)?;
660        }
661        Ok(outcome)
662    }
663
664    /// Writes the manifest and the catalogs, each only when its bytes change.
665    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            // The catalogs are another crate's; nothing here writes one, so
680            // a translation never touches this crate's outputs. Any that an
681            // earlier `Emit::Both` or `Emit::Catalogs` build left behind are
682            // still this directory's to prune — `remove_stale` only ever
683            // touches `*.mf2b*`, never the manifest or the module.
684            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
705/// Where `source` defines each of its ids: the file and the id's position.
706fn 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
723/// An id a translation has and the source locale does not would be dropped
724/// without a word, so it is an error (`plans/05-tooling.md` §5).
725fn 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
760/// What slicing noticed: numbers without `fn-number`, and a currency or unit
761/// set the build could not narrow.
762fn 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    // An explicit list says which codes the variable can hold, so the
791    // catalog carries only those and there is nothing to warn about.
792    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}