Skip to main content

shuyi_assets/
lang.rs

1//! Languages: the source language's screen text by key (`en/ui.ron`), and
2//! the packs that overlay it (`<tag>/`): their screen text (`ui.ron`), the
3//! game's data text (`data.ron`: names, and whatever else the game hands
4//! over by key) and the dialogue's lines and captions (`dialogue/*.ron`).
5//!
6//! **The source** is the language the game is written in ([`SourceLang`]):
7//! English unless the game declares another
8//! ([`Lang::with_source_lang`]), its folder holding its screen text only. A
9//! pack entry carries the source text it was made from; when that is no
10//! longer today's the entry is **stale**, and a key with no entry is
11//! **missing**. Neither fails anything.
12//!
13//! **Tags.** A language is named by its BCP 47 tag ([`LangCode`]: `ja`,
14//! `pt-BR`, `zh-Hans`), and its folder by that tag as BCP 47 writes it, so
15//! `pt-br` never quietly becomes a second language on a file system that
16//! ignores case. The engine reads the subset of tags it takes itself rather
17//! than with `icu_locale_core`: checking a tag is a short function, and the
18//! engine wants none of CLDR's data.
19//!
20//! **The fallback chain.** A key a pack lacks, or has stale, falls back
21//! along [`Lang::chain`]: the pack of each shorter tag the game has
22//! (`pt-BR`, then `pt`), then the source. So a small `pt-BR` pack can sit
23//! over a full `pt` one. Which language a game starts in is
24//! [`best_match`] of the system's languages among the source and the
25//! packs: a tag, its shorter tags, Chinese's script from its region, then
26//! the one pack of the same language.
27//!
28//! The game names its folder and says what else its text must keep to
29//! ([`PackRules`]); the source text of its data is the [`SourceText`] it
30//! builds. As a format (ADR-0007), one pack is read from its `lang.ron`
31//! ([`LangPackRon`]). [`Lang::texts`] lists every text of every language,
32//! each with its part, key and file ([`TextAt`]). The source's screen text
33//! can say where each key's text is drawn ([`Place`]: its width in cells
34//! and a note for translators), and [`Lang::overflow`] lists a pack's
35//! entries too wide for it.
36use crate::prelude::*;
37
38mod formats;
39mod lines;
40mod matching;
41mod places;
42mod source;
43mod tag;
44mod texts;
45
46use alloc::collections::{BTreeMap, BTreeSet};
47use core::fmt;
48
49use serde::de::{Deserializer, MapAccess, Visitor};
50use serde::{Deserialize, Serialize};
51
52use crate::dialogue::{DialogueLimits, Tokens};
53use crate::error::{ContentError, codes};
54use crate::font::CellWidths;
55use crate::format::AssetKind;
56use crate::import_settings::is_settings_file;
57use crate::names::Names;
58use crate::ron::{parse_ron, parse_ron_implicit_some};
59use crate::tip::placeholders;
60use places::UiValue;
61
62pub use formats::LangPackRon;
63pub use lines::{LineEntry, LineText, Orphan};
64pub use matching::best_match;
65pub use places::{Overflow, Place, Places};
66pub use source::{LineKind, SourceLine, SourceText, names_key};
67pub use tag::{LangCode, TagError};
68pub use texts::{TextAt, TextPart};
69
70/// English's tag, the source language when a game declares none.
71#[deprecated(note = "a game's source language is `Lang::source_lang()`")]
72pub const ENGLISH: &str = tag::DEFAULT_SOURCE;
73
74/// File name of a language's screen text.
75const UI_FILE: &str = "ui.ron";
76
77/// File name of a pack's description.
78pub(crate) const INFO_FILE: &str = "lang.ron";
79
80/// File name of a pack's data text: names, tips, titles and notes.
81const DATA_FILE: &str = "data.ron";
82
83/// Directory of a pack's dialogue text, one file per `.dlg` file.
84const DIALOGUE_DIR: &str = "dialogue/";
85
86/// Extension of a pack's dialogue files.
87const DIALOGUE_EXTENSION: &str = ".ron";
88
89/// How many cells of the screen `text` takes: one per character, two for a
90/// character `widths` has wide (a Japanese or Chinese one, from the font
91/// atlas's wide glyphs). Every length limit on text and every layout that
92/// measures it counts with this.
93pub fn text_width(text: &str, widths: &CellWidths) -> usize {
94    text.chars().map(|c| widths.of_char(c)).sum()
95}
96
97/// The language the game is written in: its screen text is in its folder
98/// (`<code>/ui.ron`), and its data and dialogue in the game's own files.
99/// Every pack is checked against it, and every lookup ends with it.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub struct SourceLang {
103    /// Its tag, the name of its folder.
104    pub code: LangCode,
105    /// Its own name for itself, which a language menu shows.
106    pub name: String,
107    /// The font atlas's wide set it draws from first, as a pack's
108    /// [`LangInfo::wide_font`] names one: `None` for none of its own.
109    pub wide_font: Option<String>,
110    /// Whether [`WIDE_PUNCTUATION`](crate::font::WIDE_PUNCTUATION) is wide
111    /// in it, as a pack's [`LangInfo::wide_punctuation`] says; narrow
112    /// (`false`) by default.
113    pub wide_punctuation: bool,
114}
115
116impl SourceLang {
117    /// The source language `code`, named `name` in itself, with no wide set
118    /// of its own and its punctuation narrow.
119    pub fn new(code: LangCode, name: impl Into<String>) -> Self {
120        Self {
121            code,
122            name: name.into(),
123            wide_font: None,
124            wide_punctuation: false,
125        }
126    }
127
128    /// This language, drawing from the font atlas's wide set `set` first
129    /// ([`wide_font`](Self::wide_font)).
130    #[must_use]
131    pub fn with_wide_font(self, set: impl Into<String>) -> Self {
132        Self {
133            wide_font: Some(set.into()),
134            ..self
135        }
136    }
137
138    /// This language, its punctuation wide (`on`) or narrow
139    /// ([`wide_punctuation`](Self::wide_punctuation)).
140    #[must_use]
141    pub fn with_wide_punctuation(self, on: bool) -> Self {
142        Self {
143            wide_punctuation: on,
144            ..self
145        }
146    }
147
148    /// The width table of this language over `base`, the atlas's
149    /// ([`CellWidths::of`]), as [`LangInfo::widths`] makes a pack's.
150    pub fn widths(&self, base: &CellWidths) -> CellWidths {
151        base.clone()
152            .in_lang(self.wide_font.as_deref(), self.wide_punctuation)
153    }
154}
155
156impl Default for SourceLang {
157    /// English, named "English".
158    fn default() -> Self {
159        Self::new(LangCode::default(), "English")
160    }
161}
162
163/// Who made a pack's text.
164#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
165pub enum MadeBy {
166    /// Machine-translated.
167    Machine,
168    /// Translated by a person or team: who to credit.
169    Human(String),
170}
171
172/// A pack's `lang.ron`.
173#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
174#[serde(deny_unknown_fields)]
175pub struct LangInfo {
176    /// The language's own name for itself.
177    pub name: String,
178    /// Who made the text.
179    pub made_by: MadeBy,
180    /// The font atlas's wide set the language draws from first (its own
181    /// word, as `font-atlas --wide <set>=<font.bdf>` names it), so a
182    /// character Japanese and Chinese share has this language's shape:
183    /// `None` for none of its own. Its wide characters must be in that set
184    /// ([`undrawn`](crate::font::undrawn)).
185    #[serde(default)]
186    pub wide_font: Option<String>,
187    /// Whether [`WIDE_PUNCTUATION`](crate::font::WIDE_PUNCTUATION)
188    /// (`“ ” ‘ ’ … —`) is wide in this language and drawn from its wide
189    /// set, as Chinese writes it; narrow (`false`) by default.
190    #[serde(default)]
191    pub wide_punctuation: bool,
192}
193
194impl LangInfo {
195    /// The width table of this language over `base`, the atlas's
196    /// ([`CellWidths::of`]): its wide set and its punctuation's width
197    /// ([`CellWidths::in_lang`]).
198    pub fn widths(&self, base: &CellWidths) -> CellWidths {
199        base.clone()
200            .in_lang(self.wide_font.as_deref(), self.wide_punctuation)
201    }
202}
203
204/// One piece of a pack's text.
205#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
206#[serde(deny_unknown_fields)]
207pub struct Entry {
208    /// Which text this is.
209    pub key: String,
210    /// The source text `text` was made from.
211    pub source: String,
212    /// The text in the pack's language.
213    pub text: String,
214}
215
216/// One language other than the source.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct LangPack {
219    /// Its name and who made it.
220    pub info: LangInfo,
221    /// Its screen text, by key.
222    ui: BTreeMap<String, Entry>,
223    /// Its data text (names, and the game's other data text), by key.
224    data: BTreeMap<String, Entry>,
225    /// Its dialogue lines and captions, by line id or caption key.
226    lines: BTreeMap<String, LineEntry>,
227}
228
229impl LangPack {
230    /// The pack's screen-text or data-text entry for `key`, if it has one.
231    pub fn entry(&self, key: &str) -> Option<&Entry> {
232        self.ui.get(key).or_else(|| self.data.get(key))
233    }
234}
235
236/// What a pack lacks ([`Lang::status`]): each list has the screen text's
237/// keys, then the data's, then the dialogue's, each in key order.
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
239pub struct LangStatus {
240    /// Keys the pack has no entry for.
241    pub missing: Vec<String>,
242    /// Keys whose entry was made from source text that has since changed.
243    pub stale: Vec<String>,
244    /// Dialogue entries whose line the source no longer has (it was
245    /// reworded or cut), each with the line it most likely became.
246    pub orphans: Vec<Orphan>,
247}
248
249/// A game's English screen text by key, as one file (`en.ron`) holds it:
250/// a kind ([`AssetKind`]) read with `Ron::<English>::new()`
251/// ([`Ron`](crate::ron::Ron)). [`Lang::new`] takes its map.
252#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(transparent)]
254pub struct English(pub BTreeMap<String, String>);
255
256impl AssetKind for English {
257    const NAME: &'static str = "english";
258}
259
260/// The source language's text and every pack.
261#[derive(Debug, Clone, Default, PartialEq, Eq)]
262pub struct Lang {
263    /// Which language the game is written in ([`Lang::with_source_lang`]).
264    written_in: SourceLang,
265    /// The source's screen text, by key.
266    ui: BTreeMap<String, String>,
267    /// The source's data and dialogue text, by key.
268    source: SourceText,
269    /// The other languages, by tag.
270    packs: BTreeMap<LangCode, LangPack>,
271    /// Where the source's screen text is drawn, by key
272    /// ([`Lang::with_places`]).
273    places: Places,
274}
275
276impl Lang {
277    /// The source's screen text `ui` with `packs` over it, and no data or
278    /// dialogue. The source is English until
279    /// [`with_source_lang`](Self::with_source_lang) says otherwise.
280    pub fn new(ui: BTreeMap<String, String>, packs: BTreeMap<LangCode, LangPack>) -> Self {
281        Self::with_source(ui, SourceText::default(), packs)
282    }
283
284    /// The source's screen text `ui` and `source` (the data's and the
285    /// dialogue's source text), with `packs` over them. The source is
286    /// English until [`with_source_lang`](Self::with_source_lang) says
287    /// otherwise.
288    pub fn with_source(
289        ui: BTreeMap<String, String>,
290        source: SourceText,
291        packs: BTreeMap<LangCode, LangPack>,
292    ) -> Self {
293        Self {
294            written_in: SourceLang::default(),
295            ui,
296            source,
297            packs,
298            places: BTreeMap::new(),
299        }
300    }
301
302    /// These languages, the game written in `source`.
303    #[must_use]
304    pub fn with_source_lang(self, source: SourceLang) -> Self {
305        Self {
306            written_in: source,
307            ..self
308        }
309    }
310
311    /// The language the game is written in.
312    pub fn source_lang(&self) -> &SourceLang {
313        &self.written_in
314    }
315
316    /// The source text of the data and the dialogue, by key.
317    pub fn source(&self) -> &SourceText {
318        &self.source
319    }
320
321    /// Whether the source's screen text has `key`.
322    pub fn has(&self, key: &str) -> bool {
323        self.ui.contains_key(key)
324    }
325
326    /// Today's source text for `key`, if there is such a key: a screen
327    /// text's, a data text's, or a dialogue line's or caption's.
328    pub fn source_text(&self, key: &str) -> Option<&str> {
329        let line = || self.source.lines.get(key).map(|line| &line.text);
330        self.ui
331            .get(key)
332            .or_else(|| self.source.data.get(key))
333            .or_else(line)
334            .map(String::as_str)
335    }
336
337    /// Today's source text for `key`: [`source_text`](Self::source_text).
338    #[deprecated(note = "renamed `source_text`: the source language needn't be English")]
339    pub fn english(&self, key: &str) -> Option<&str> {
340        self.source_text(key)
341    }
342
343    /// The pack for `code`; `None` for the source or an unknown tag.
344    pub fn pack(&self, code: &LangCode) -> Option<&LangPack> {
345        self.packs.get(code)
346    }
347
348    /// The width table of language `code` over `base`, the atlas's
349    /// ([`CellWidths::of`]): its pack's ([`LangInfo::widths`]), the
350    /// source's ([`SourceLang::widths`]), else `base` itself.
351    pub fn widths(&self, code: &LangCode, base: &CellWidths) -> CellWidths {
352        if let Some(pack) = self.pack(code) {
353            pack.info.widths(base)
354        } else if *code == self.written_in.code {
355            self.written_in.widths(base)
356        } else {
357            base.clone()
358        }
359    }
360
361    /// The tags of the packs, in order (the source isn't one).
362    pub fn codes(&self) -> impl Iterator<Item = &LangCode> {
363        self.packs.keys()
364    }
365
366    /// The packs a lookup in `code` reads, nearest first: `code`'s, then
367    /// each of its parents' that the game has; never the source's. Walks
368    /// `code`'s text without allocating.
369    fn packs_along<'a>(
370        &'a self,
371        code: &LangCode,
372    ) -> impl Iterator<Item = (&'a LangCode, &'a LangPack)> {
373        let source = self.written_in.code.as_str();
374        tag::ancestry(code.as_str())
375            .filter(move |tag| *tag != source)
376            .filter_map(|tag| self.packs.get_key_value(tag))
377    }
378
379    /// Where a lookup in `code` looks, in order: `code`, then each of its
380    /// [`parent`](LangCode::parent)s, keeping the tags that have a pack,
381    /// then the source, which always ends it. With packs `pt` and `pt-BR`,
382    /// `pt-BR`'s chain is `pt-BR`, `pt`, `en`. Each lookup takes the first
383    /// entry along it made from today's source text.
384    pub fn chain(&self, code: &LangCode) -> Vec<LangCode> {
385        let mut chain: Vec<LangCode> = self.packs_along(code).map(|(c, _)| c.clone()).collect();
386        chain.push(self.written_in.code.clone());
387        chain
388    }
389
390    /// The text for `key` in language `code`: the first entry along
391    /// [`chain`](Self::chain) made from today's source text, else the
392    /// source's. A key the source lacks comes back as it is.
393    pub fn text<'a>(&'a self, code: &LangCode, key: &'a str) -> &'a str {
394        let Some(source) = self.ui.get(key) else {
395            return key;
396        };
397        self.packs_along(code)
398            .find_map(|(_, pack)| pack.ui.get(key).filter(|entry| entry.source == *source))
399            .map_or(source, |entry| &entry.text)
400    }
401
402    /// What `code`'s pack lacks, itself, without its chain. The source
403    /// lacks nothing; `None` for a tag with no pack.
404    pub fn status(&self, code: &LangCode) -> Option<LangStatus> {
405        if *code == self.written_in.code {
406            return Some(LangStatus::default());
407        }
408        let pack = self.packs.get(code)?;
409        let mut status = LangStatus::default();
410        let mut check = |key: &String, english: &String, source: Option<&String>| match source {
411            None => status.missing.push(key.clone()),
412            Some(source) if source != english => status.stale.push(key.clone()),
413            Some(_) => {}
414        };
415        for (key, english) in &self.ui {
416            check(key, english, pack.ui.get(key).map(|e| &e.source));
417        }
418        for (key, english) in &self.source.data {
419            check(key, english, pack.data.get(key).map(|e| &e.source));
420        }
421        for (key, line) in &self.source.lines {
422            check(key, &line.text, pack.lines.get(key).map(|e| &e.source));
423        }
424        status.orphans = pack.orphans(&self.source);
425        Some(status)
426    }
427}
428
429/// A RON map read as its pairs in file order, so a key written twice is
430/// seen twice.
431struct Pairs<V>(Vec<(String, V)>);
432
433impl<'de, V: Deserialize<'de>> Deserialize<'de> for Pairs<V> {
434    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
435        struct PairsVisitor<V>(core::marker::PhantomData<V>);
436
437        impl<'de, V: Deserialize<'de>> Visitor<'de> for PairsVisitor<V> {
438            type Value = Pairs<V>;
439
440            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441                f.write_str("a map of key to text")
442            }
443
444            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Pairs<V>, A::Error> {
445                let mut pairs = Vec::new();
446                while let Some(pair) = map.next_entry()? {
447                    pairs.push(pair);
448                }
449                Ok(Pairs(pairs))
450            }
451        }
452
453        deserializer.deserialize_map(PairsVisitor(core::marker::PhantomData))
454    }
455}
456
457/// Whether `key` is made of lowercase letters, digits, `_` and `.` only
458/// (and isn't empty).
459fn key_ok(key: &str) -> bool {
460    !key.is_empty()
461        && key
462            .bytes()
463            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'.')
464}
465
466/// Parses and validates the source language's screen text `source` (a map
467/// of key to text), whatever the language, attributing errors to `file`.
468/// Reports every problem found. A key's places
469/// ([`english_and_places_from_source`]) are read and left out.
470pub fn english_from_source(
471    file: &str,
472    source: &str,
473) -> Result<BTreeMap<String, String>, Vec<ContentError>> {
474    english_and_places_from_source(file, source).map(|(english, _)| english)
475}
476
477/// The source's screen text and where it is drawn, by key: what
478/// [`english_from_source`] reads, with each key's [`Place`] where its value
479/// is written as `(text: …, max_cells: …, note: …)` rather than as text
480/// (both optional). A `max_cells` is at least 1, and a note isn't empty.
481pub fn english_and_places_from_source(
482    file: &str,
483    source: &str,
484) -> Result<(BTreeMap<String, String>, Places), Vec<ContentError>> {
485    let Pairs(pairs) =
486        parse_ron_implicit_some::<Pairs<UiValue>>(file, source).map_err(|e| vec![e])?;
487    let mut errors = Vec::new();
488    let mut english = BTreeMap::new();
489    let mut places = BTreeMap::new();
490    for (key, UiValue { text, place }) in pairs {
491        let at = |message: &str| ContentError::new(file, format!("\"{key}\": {message}"));
492        if !key_ok(&key) {
493            errors.push(at("a key must be lowercase letters, digits, `_` and `.`"));
494        }
495        if english.contains_key(&key) {
496            errors.push(at("the key is used twice").with_code(codes::DUPLICATE));
497        }
498        if place.max_cells == Some(0) {
499            errors.push(at("max_cells must be at least 1"));
500        }
501        if place.note.as_ref().is_some_and(|n| n.trim().is_empty()) {
502            errors.push(at("a note must not be empty"));
503        }
504        if place != Place::default() {
505            places.insert(key.clone(), place);
506        }
507        english.insert(key, text);
508    }
509    if errors.is_empty() {
510        Ok((english, places))
511    } else {
512        Err(errors)
513    }
514}
515
516/// What a game's packs keep to besides matching English: the rules of its
517/// data text, its tokens, its dialogue's limits and the variants a line
518/// may have.
519#[derive(Clone, Copy)]
520pub struct PackRules<'a> {
521    /// What is wrong with a pack's text `text` for the data key `key`, if
522    /// anything (a tip over its box, say): the game's rules for the data
523    /// text it hands over. Names (`names.<id>`) keep the engine's rules
524    /// first.
525    pub data_problem: fn(key: &str, text: &str) -> Option<String>,
526    /// The game's own `{tokens}`: a pack's dialogue text may use them.
527    pub tokens: &'a dyn Tokens,
528    /// How much text the dialogue box and the reply menu hold.
529    pub limits: DialogueLimits,
530    /// How many cells each character takes, which the limits count: the
531    /// font atlas's ([`CellWidths::of`]), or [`CellWidths::NARROW`].
532    pub widths: &'a CellWidths,
533    /// The names of the variants a dialogue entry may have instead of one
534    /// `text` (`text_<variant>` fields, every one of them): one per hero
535    /// a line can be said of, say. Empty for none.
536    pub variants: &'a [&'a str],
537}
538
539impl fmt::Debug for PackRules<'_> {
540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541        f.debug_struct("PackRules")
542            .field("limits", &self.limits)
543            .field("widths", &self.widths)
544            .field("variants", &self.variants)
545            .finish_non_exhaustive()
546    }
547}
548
549/// What is wrong with a pack's text `text` for the data key `key`, if
550/// anything: a name is not empty and holds no brace (names can't hold
551/// tokens); any other text keeps to the game's `data_problem`.
552fn data_text_problem(
553    key: &str,
554    text: &str,
555    data_problem: fn(&str, &str) -> Option<String>,
556) -> Option<String> {
557    if !key.starts_with(source::NAMES_PREFIX) {
558        return data_problem(key, text);
559    }
560    if text.trim().is_empty() {
561        return Some("a name must not be empty".to_owned());
562    }
563    text.contains(['{', '}'])
564        .then(|| "a name can't hold a brace".to_owned())
565}
566
567/// A file of a pack: the name it is reported under, and its text.
568pub type Source<'a> = (&'a str, &'a str);
569
570/// The files of one pack.
571#[derive(Debug, Clone, Default)]
572pub struct PackSources<'a> {
573    /// Its `lang.ron`.
574    pub info: Source<'a>,
575    /// Its `ui.ron`.
576    pub ui: Source<'a>,
577    /// Its `data.ron`, if it has one.
578    pub data: Option<Source<'a>>,
579    /// Its `dialogue/*.ron` files.
580    pub dialogue: Vec<Source<'a>>,
581}
582
583/// Parses the entries of the pack file `file` (`source`) and checks each
584/// against `english` (key to today's English), adding every problem to
585/// `errors`. `no_key` is what an entry English has no key for is told.
586///
587/// An entry's key must be one of English's and be there once. Its text
588/// must have the same placeholders as its `source`: the English it was
589/// made from, not today's, so that changing English can make an entry
590/// stale but never an error. And it keeps to the rules of data text.
591fn entries(
592    (file, source): Source<'_>,
593    english: &BTreeMap<String, String>,
594    no_key: &str,
595    data_problem: fn(&str, &str) -> Option<String>,
596    errors: &mut Vec<ContentError>,
597) -> BTreeMap<String, Entry> {
598    let entries = parse_ron::<Vec<Entry>>(file, source)
599        .map_err(|e| errors.push(e))
600        .unwrap_or_default();
601    let mut by_key = BTreeMap::new();
602    for entry in entries {
603        let at = |message: &str| ContentError::new(file, format!("\"{}\": {message}", entry.key));
604        if !english.contains_key(&entry.key) {
605            errors.push(at(no_key));
606        }
607        if by_key.contains_key(&entry.key) {
608            errors.push(at("the key is used twice").with_code(codes::DUPLICATE));
609        }
610        let names = |text| placeholders(text).into_iter().collect::<BTreeSet<_>>();
611        if names(&entry.text) != names(&entry.source) {
612            errors.push(at(
613                "the text and its source must have the same placeholders",
614            ));
615        }
616        if let Some(problem) = data_text_problem(&entry.key, &entry.text, data_problem) {
617            errors.push(at(&problem));
618        }
619        by_key.insert(entry.key.clone(), entry);
620    }
621    by_key
622}
623
624/// Parses and validates a pack with screen text only: its `lang.ron`
625/// (`info`) and its `ui.ron` (`ui`), checked against `english`. See
626/// [`pack_from_files`].
627pub fn pack_from_sources(
628    info: Source<'_>,
629    ui: Source<'_>,
630    english: &BTreeMap<String, String>,
631    rules: &PackRules<'_>,
632) -> Result<LangPack, Vec<ContentError>> {
633    let sources = PackSources {
634        info,
635        ui,
636        ..PackSources::default()
637    };
638    pack_from_files(&sources, english, None, rules)
639}
640
641/// Parses and validates the pack made of `sources`, its screen text
642/// checked against `english` (the source's screen text, whatever the
643/// language) and its data and dialogue text against `data` (left out
644/// without it: the data files failed to load). Reports every problem
645/// found; the messages call the source `en`.
646///
647/// A `ui.ron` or `data.ron` entry's key must be one of the source's and be
648/// there once, and its text must have the same placeholders as its
649/// `source`; a name, and the game's data text, also keep to the rules of
650/// the source's (`rules`). A dialogue entry is checked as [`LineEntry`]
651/// says: one whose line the source no longer has is an orphan, not an
652/// error.
653pub fn pack_from_files(
654    sources: &PackSources<'_>,
655    english: &BTreeMap<String, String>,
656    data: Option<&SourceText>,
657    rules: &PackRules<'_>,
658) -> Result<LangPack, Vec<ContentError>> {
659    pack_from_files_in(&LangCode::default(), sources, english, data, rules)
660}
661
662/// [`pack_from_files`], its messages naming the source by its tag `source`.
663fn pack_from_files_in(
664    source: &LangCode,
665    sources: &PackSources<'_>,
666    english: &BTreeMap<String, String>,
667    data: Option<&SourceText>,
668    rules: &PackRules<'_>,
669) -> Result<LangPack, Vec<ContentError>> {
670    let mut errors = Vec::new();
671    let (info_file, info_source) = sources.info;
672    let info = parse_ron::<LangInfo>(info_file, info_source)
673        .map_err(|e| errors.push(e))
674        .ok();
675    if info.as_ref().is_some_and(|i| i.name.trim().is_empty()) {
676        errors.push(ContentError::new(info_file, "the name must not be empty"));
677    }
678    if info
679        .as_ref()
680        .and_then(|i| i.wide_font.as_deref())
681        .is_some_and(|set| set.trim().is_empty())
682    {
683        errors.push(ContentError::new(
684            info_file,
685            "wide_font must not be empty: leave it out for no wide set of its own",
686        ));
687    }
688    // The pack's limits count with its own widths (its punctuation's).
689    let widths = info
690        .as_ref()
691        .map_or_else(|| rules.widths.clone(), |i| i.widths(rules.widths));
692    let rules = &PackRules {
693        widths: &widths,
694        ..*rules
695    };
696    let problem = rules.data_problem;
697    let no_key = format!("no such key in the source language ({source})");
698    let ui = entries(sources.ui, english, &no_key, problem, &mut errors);
699    let mut pack_data = BTreeMap::new();
700    let mut pack_lines = BTreeMap::new();
701    if let Some(data) = data {
702        if let Some(file) = sources.data {
703            let no_key = "no such key in the data";
704            pack_data = entries(file, &data.data, no_key, problem, &mut errors);
705        }
706        // The pack's names: what a name token in its lines is filled from.
707        let name = |(id, english): (&String, &String)| {
708            let entry = pack_data.get(&names_key(id));
709            let name = entry.filter(|e| e.source == *english);
710            (id.clone(), name.map_or(english, |e| &e.text).clone())
711        };
712        let names = Names {
713            names: data.names.names.iter().map(name).collect(),
714        };
715        for &file in &sources.dialogue {
716            lines::add_lines(file, data, &names, rules, &mut pack_lines, &mut errors);
717        }
718    }
719    match info {
720        Some(info) if errors.is_empty() => Ok(LangPack {
721            info,
722            ui,
723            data: pack_data,
724            lines: pack_lines,
725        }),
726        _ => Err(errors),
727    }
728}
729
730/// One file of the languages' directory.
731#[derive(Debug, Clone, Copy)]
732pub struct LangFile<'a> {
733    /// Its path under the languages' directory (`en/ui.ron`).
734    pub path: &'a str,
735    /// Its text; `None` if it isn't UTF-8.
736    pub source: Option<&'a str>,
737}
738
739/// Whether `name` (a path inside a pack's directory) is one of a pack's
740/// dialogue files: `dialogue/<file>.ron`.
741fn is_dialogue_file(name: &str) -> bool {
742    name.strip_prefix(DIALOGUE_DIR)
743        .and_then(|file| file.strip_suffix(DIALOGUE_EXTENSION))
744        .is_some_and(|stem| !stem.is_empty() && !stem.contains('/'))
745}
746
747/// The tag a pack's folder `dir` names, or what its error says: a folder is
748/// named by a tag as BCP 47 writes it, so `pt-br` is refused rather than
749/// becoming a second language where file names ignore case.
750fn pack_code(dir: &str) -> Result<LangCode, String> {
751    match LangCode::parse(dir) {
752        Ok(code) if code.as_str() == dir => Ok(code),
753        Ok(code) => Err(format!(
754            "name a language's folder by its tag as BCP 47 writes it: {code}"
755        )),
756        Err(e) => Err(format!(
757            "name a language's folder by a BCP 47 tag (en, pt-BR, zh-Hans): {e}"
758        )),
759    }
760}
761
762/// [`from_files_in`] for a game written in English.
763pub fn from_files(
764    files: &[LangFile<'_>],
765    data: Option<SourceText>,
766    display: impl Fn(&str) -> String,
767    rules: &PackRules<'_>,
768) -> Result<Lang, Vec<ContentError>> {
769    from_files_in(&SourceLang::default(), files, data, display, rules)
770}
771
772/// Validates the languages made of `files`, for a game written in
773/// `source`: the source's screen text, `<code>/ui.ron` (with its keys'
774/// places, [`english_and_places_from_source`]), and a pack for every other
775/// folder, named by its tag, which needs its `lang.ron` and `ui.ron` and
776/// may have a `data.ron` and `dialogue/<file>.ron` files (checked against
777/// `data`; left out without it). Import settings files are skipped
778/// (ADR-0057); any other file is an error, a `lang.ron` in the source's
779/// folder too. `display` gives the name a path is reported under. Reports
780/// every problem found; with the source's screen text broken the packs are
781/// not checked (each entry would be an unknown key).
782pub fn from_files_in(
783    source: &SourceLang,
784    files: &[LangFile<'_>],
785    data: Option<SourceText>,
786    display: impl Fn(&str) -> String,
787    rules: &PackRules<'_>,
788) -> Result<Lang, Vec<ContentError>> {
789    let mut errors = Vec::new();
790    let find = |path: &str| files.iter().find(|f| f.path == path);
791    let read = |path: &str, errors: &mut Vec<ContentError>| {
792        let source = find(path).and_then(|f| f.source);
793        if source.is_none() {
794            errors.push(ContentError::not_found(display(path)));
795        }
796        source
797    };
798    let mut dirs = BTreeSet::new();
799    for file in files {
800        if is_settings_file(file.path) {
801            continue;
802        }
803        let known = match file.path.split_once('/') {
804            Some((dir, name)) if dir == source.code.as_str() => name == UI_FILE,
805            Some((dir, name)) => {
806                dirs.insert(dir);
807                [UI_FILE, INFO_FILE, DATA_FILE].contains(&name) || is_dialogue_file(name)
808            }
809            None => false,
810        };
811        if !known {
812            errors.push(ContentError::new(
813                display(file.path),
814                "not a file a language has",
815            ));
816        }
817    }
818    let english_path = format!("{}/{UI_FILE}", source.code);
819    let english = read(&english_path, &mut errors)
820        .map(|source| english_and_places_from_source(&display(&english_path), source));
821    let (english, places) = match english {
822        Some(Ok((english, places))) => (Some(english), places),
823        Some(Err(e)) => {
824            errors.extend(e);
825            (None, BTreeMap::new())
826        }
827        None => (None, BTreeMap::new()),
828    };
829    let mut packs = BTreeMap::new();
830    for dir in dirs {
831        let code = match pack_code(dir) {
832            Ok(code) => code,
833            Err(message) => {
834                errors.push(ContentError::new(display(dir), message));
835                continue;
836            }
837        };
838        let info_path = format!("{dir}/{INFO_FILE}");
839        let ui_path = format!("{dir}/{UI_FILE}");
840        let data_path = format!("{dir}/{DATA_FILE}");
841        let info = read(&info_path, &mut errors);
842        let ui = read(&ui_path, &mut errors);
843        // The optional files: only read (and reported, if not text) when
844        // the pack has them.
845        let data_source = find(&data_path).and_then(|_| read(&data_path, &mut errors));
846        let dialogue_dir = format!("{dir}/{DIALOGUE_DIR}");
847        let dialogue: Vec<(String, &str)> = files
848            .iter()
849            .filter(|f| f.path.starts_with(&dialogue_dir))
850            .filter_map(|f| Some((display(f.path), read(f.path, &mut errors)?)))
851            .collect();
852        let (Some(info), Some(ui), Some(english)) = (info, ui, &english) else {
853            continue;
854        };
855        let (info_name, ui_name, data_name) =
856            (display(&info_path), display(&ui_path), display(&data_path));
857        let sources = PackSources {
858            info: (&info_name, info),
859            ui: (&ui_name, ui),
860            data: data_source.map(|source| (data_name.as_str(), source)),
861            dialogue: dialogue.iter().map(|(n, s)| (n.as_str(), *s)).collect(),
862        };
863        match pack_from_files_in(&source.code, &sources, english, data.as_ref(), rules) {
864            Ok(pack) => {
865                packs.insert(code, pack);
866            }
867            Err(e) => errors.extend(e),
868        }
869    }
870    match english {
871        Some(english) if errors.is_empty() => {
872            Ok(Lang::with_source(english, data.unwrap_or_default(), packs)
873                .with_places(places)
874                .with_source_lang(source.clone()))
875        }
876        _ => Err(errors),
877    }
878}
879
880#[cfg(test)]
881mod chain_tests;
882#[cfg(test)]
883mod pack_tests;
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888
889    #[test]
890    fn english_is_read_through_the_registry() {
891        use crate::bundle::{Bundle, Layer};
892        use crate::format::Formats;
893        use crate::ron::Ron;
894
895        static FILES: &[(&str, &[u8])] = &[
896            ("bad.ron", b"{ \"a\": 1 }"),
897            ("en.ron", b"{ \"title.name\": \"Demo\" }"),
898        ];
899        let bundle = Bundle::new(vec![Layer::table("files", FILES)]);
900        let mut formats = Formats::standard();
901        formats.register(Ron::<English>::new()).unwrap();
902        let english = formats.load::<English>(&bundle, "en.ron").unwrap();
903        assert_eq!(
904            english.0.get("title.name").map(String::as_str),
905            Some("Demo")
906        );
907        let errors = formats.load::<English>(&bundle, "bad.ron").unwrap_err();
908        assert!(
909            errors[0].to_string().starts_with("files/bad.ron:1:"),
910            "{errors:?}"
911        );
912    }
913
914    const ENGLISH_SOURCE: &str = r#"{
915        "a.one": "One",
916        "a.two": "Two {count}",
917        "a.three": "Three",
918    }"#;
919
920    const INFO: &str = r#"(name: "Test", made_by: Machine)"#;
921
922    /// No data text rules of a game's, no tokens, no variants.
923    const RULES: PackRules<'static> = PackRules {
924        data_problem: |_, _| None,
925        tokens: &crate::dialogue::NoTokens,
926        limits: crate::dialogue::tests::LIMITS,
927        widths: &CellWidths::NARROW,
928        variants: &[],
929    };
930
931    fn english() -> BTreeMap<String, String> {
932        english_from_source("en/ui.ron", ENGLISH_SOURCE).unwrap()
933    }
934
935    fn pack(ui: &str) -> Result<LangPack, Vec<String>> {
936        pack_from_sources(("xx/lang.ron", INFO), ("xx/ui.ron", ui), &english(), &RULES)
937            .map_err(|errors| errors.iter().map(ToString::to_string).collect())
938    }
939
940    fn code(code: &str) -> LangCode {
941        LangCode::new(code).unwrap()
942    }
943
944    /// English with one pack `xx`: `a.one` translated, `a.two` stale,
945    /// `a.three` missing.
946    fn lang() -> Lang {
947        let ui = r#"[
948            (key: "a.one", source: "One", text: "Un"),
949            (key: "a.two", source: "Two of {count}", text: "{count} deux"),
950        ]"#;
951        let packs = BTreeMap::from([(code("xx"), pack(ui).unwrap())]);
952        Lang::new(english(), packs)
953    }
954
955    #[test]
956    fn each_language_has_its_own_widths_the_source_too() {
957        let base = CellWidths::wide(['直']);
958        let lang = lang();
959        // Neither field: the source's are the atlas's, as before.
960        assert_eq!(SourceLang::default().widths(&base), base);
961        assert_eq!(lang.widths(&LangCode::default(), &base), base);
962        let source = SourceLang::new(code("zh-Hans"), "中文")
963            .with_wide_font("zh-Hans")
964            .with_wide_punctuation(true);
965        assert_eq!(source.wide_font.as_deref(), Some("zh-Hans"));
966        assert!(source.wide_punctuation);
967        let lang = lang.with_source_lang(source);
968        let chinese = lang.widths(&code("zh-Hans"), &base);
969        assert_eq!(chinese, base.clone().in_lang(Some("zh-Hans"), true));
970        assert!(chinese.is_wide('“') && chinese.is_wide('直'));
971        // A pack's are its lang.ron's; an unknown tag's the atlas's.
972        assert_eq!(lang.widths(&code("xx"), &base), base);
973        assert_eq!(lang.widths(&code("en"), &base), base);
974    }
975
976    #[test]
977    #[expect(deprecated)]
978    fn english_is_the_default_sources_tag() {
979        assert_eq!(ENGLISH, LangCode::default().as_str());
980        assert_eq!(ENGLISH, SourceLang::default().code.as_str());
981    }
982
983    #[test]
984    #[expect(deprecated)]
985    fn english_of_a_key_is_its_source_text() {
986        let lang = lang();
987        for key in ["a.one", "a.nope"] {
988            assert_eq!(lang.english(key), lang.source_text(key), "{key}");
989        }
990    }
991
992    #[test]
993    fn english_loads_as_a_map() {
994        let english = english();
995        assert_eq!(english.len(), 3);
996        assert_eq!(english["a.two"], "Two {count}");
997    }
998
999    #[test]
1000    fn bad_english_keys_are_reported_with_file_and_key() {
1001        let source = r#"{"A.b": "x", "": "y", "a b": "z", "ok_1.x": "fine", "ok_1.x": "again"}"#;
1002        let errors = english_from_source("en/ui.ron", source).unwrap_err();
1003        let errors: Vec<String> = errors.iter().map(ToString::to_string).collect();
1004        let must = "a key must be lowercase letters, digits, `_` and `.`";
1005        assert_eq!(
1006            errors,
1007            [
1008                format!("en/ui.ron: \"A.b\": {must}"),
1009                format!("en/ui.ron: \"\": {must}"),
1010                format!("en/ui.ron: \"a b\": {must}"),
1011                "en/ui.ron: \"ok_1.x\": the key is used twice".to_owned(),
1012            ]
1013        );
1014        let codes: Vec<_> = english_from_source("en/ui.ron", source)
1015            .unwrap_err()
1016            .iter()
1017            .map(|e| e.code)
1018            .collect();
1019        assert_eq!(codes, [None, None, None, Some(codes::DUPLICATE)]);
1020    }
1021
1022    #[test]
1023    fn english_that_is_not_a_map_is_a_syntax_error() {
1024        let errors = english_from_source("en/ui.ron", "[1, 2]").unwrap_err();
1025        assert_eq!(errors.len(), 1);
1026        assert_eq!(errors[0].line, Some(1));
1027        let errors = english_from_source("en/ui.ron", "{\"a\": \"b\", \"c\": 3}").unwrap_err();
1028        assert_eq!(errors.len(), 1);
1029    }
1030
1031    /// What a wrong value is told the file should be.
1032    #[test]
1033    fn english_that_is_not_a_map_says_what_was_expected() {
1034        use serde::de::IntoDeserializer;
1035        use serde::de::value::{BoolDeserializer, Error};
1036        let wrong: BoolDeserializer<Error> = true.into_deserializer();
1037        let error = Pairs::<UiValue>::deserialize(wrong).err().unwrap();
1038        assert_eq!(
1039            error.to_string(),
1040            "invalid type: boolean `true`, expected a map of key to text"
1041        );
1042    }
1043
1044    #[test]
1045    fn a_pack_loads_its_info_and_entries() {
1046        let p = pack(r#"[(key: "a.one", source: "One", text: "Un")]"#).unwrap();
1047        assert_eq!(p.info.name, "Test");
1048        assert_eq!(p.info.made_by, MadeBy::Machine);
1049        assert_eq!(p.entry("a.one").unwrap().text, "Un");
1050        assert_eq!(p.entry("a.two"), None);
1051        let human = r#"(name: "Test", made_by: Human("A. Translator"))"#;
1052        let p = pack_from_sources(("l", human), ("u", "[]"), &english(), &RULES).unwrap();
1053        assert_eq!(p.info.made_by, MadeBy::Human("A. Translator".to_owned()));
1054    }
1055
1056    #[test]
1057    fn an_unknown_key_fails_with_file_and_key() {
1058        let errors = pack(r#"[(key: "a.nope", source: "x", text: "y")]"#).unwrap_err();
1059        assert_eq!(
1060            errors,
1061            ["xx/ui.ron: \"a.nope\": no such key in the source language (en)"]
1062        );
1063    }
1064
1065    #[test]
1066    fn different_placeholders_fail_with_file_and_key() {
1067        let message = "the text and its source must have the same placeholders";
1068        for text in ["deux", "{n} deux", "{count} {n}"] {
1069            let ui = format!(r#"[(key: "a.two", source: "Two {{count}}", text: "{text}")]"#);
1070            assert_eq!(
1071                pack(&ui).unwrap_err(),
1072                [format!("xx/ui.ron: \"a.two\": {message}")],
1073                "{text}"
1074            );
1075        }
1076        // The same set, in any order and any number of times, is fine.
1077        let ui = r#"[(key: "a.two", source: "{a} {b}", text: "{b} {a} {b}")]"#;
1078        assert!(pack(ui).is_ok());
1079    }
1080
1081    #[test]
1082    fn placeholders_are_checked_against_the_source_not_todays_english() {
1083        // English gained `{count}` since: the entry is stale, not an error.
1084        let ui = r#"[(key: "a.two", source: "Two", text: "Deux")]"#;
1085        assert!(pack(ui).is_ok());
1086    }
1087
1088    #[test]
1089    fn a_key_used_twice_in_a_pack_fails() {
1090        let ui = r#"[
1091            (key: "a.one", source: "One", text: "Un"),
1092            (key: "a.one", source: "One", text: "Une"),
1093        ]"#;
1094        assert_eq!(
1095            pack(ui).unwrap_err(),
1096            ["xx/ui.ron: \"a.one\": the key is used twice"]
1097        );
1098        let errors =
1099            pack_from_sources(("xx/lang.ron", INFO), ("xx/ui.ron", ui), &english(), &RULES)
1100                .unwrap_err();
1101        assert_eq!(errors[0].code, Some(codes::DUPLICATE));
1102    }
1103
1104    #[test]
1105    fn every_problem_in_a_pack_is_reported() {
1106        let errors = pack_from_sources(
1107            ("xx/lang.ron", r#"(name: " ", made_by: Machine)"#),
1108            (
1109                "xx/ui.ron",
1110                r#"[(key: "a.nope", source: "{a}", text: "b")]"#,
1111            ),
1112            &english(),
1113            &RULES,
1114        )
1115        .unwrap_err();
1116        let errors: Vec<String> = errors.iter().map(ToString::to_string).collect();
1117        assert_eq!(errors.len(), 3, "{errors:?}");
1118        assert_eq!(errors[0], "xx/lang.ron: the name must not be empty");
1119        // A file that doesn't parse is one error, attributed to that file.
1120        let errors =
1121            pack_from_sources(("l.ron", "("), ("u.ron", "["), &english(), &RULES).unwrap_err();
1122        let files: Vec<&str> = errors.iter().map(|e| e.file.as_str()).collect();
1123        assert_eq!(files, ["l.ron", "u.ron"]);
1124        // A good `ui.ron` doesn't make up for a bad `lang.ron`.
1125        assert!(pack_from_sources(("l.ron", "("), ("u.ron", "[]"), &english(), &RULES).is_err());
1126    }
1127
1128    #[test]
1129    fn lookup_uses_the_pack_and_falls_back_to_english() {
1130        let lang = lang();
1131        let xx = code("xx");
1132        assert_eq!(lang.text(&xx, "a.one"), "Un");
1133        // Stale and missing entries show English.
1134        assert_eq!(lang.text(&xx, "a.two"), "Two {count}");
1135        assert_eq!(lang.text(&xx, "a.three"), "Three");
1136        // English, and a code with no pack, are English.
1137        assert_eq!(lang.text(&LangCode::default(), "a.one"), "One");
1138        assert_eq!(lang.text(&code("zz"), "a.one"), "One");
1139        // An unknown key comes back as it is.
1140        assert_eq!(lang.text(&xx, "a.nope"), "a.nope");
1141        assert!(lang.has("a.one"));
1142        assert!(!lang.has("a.nope"));
1143        assert_eq!(lang.source_text("a.one"), Some("One"));
1144        assert_eq!(lang.source_text("a.nope"), None);
1145    }
1146
1147    #[test]
1148    fn status_lists_missing_and_stale_keys() {
1149        let lang = lang();
1150        let status = lang.status(&code("xx")).unwrap();
1151        assert_eq!(status.missing, ["a.three"]);
1152        assert_eq!(status.stale, ["a.two"]);
1153        assert_eq!(
1154            lang.status(&LangCode::default()),
1155            Some(LangStatus::default())
1156        );
1157        assert_eq!(lang.status(&code("zz")), None);
1158        assert_eq!(lang.codes().collect::<Vec<_>>(), [&code("xx")]);
1159        assert!(lang.pack(&code("xx")).is_some());
1160        assert!(lang.pack(&LangCode::default()).is_none());
1161    }
1162
1163    /// The languages made of `files` (path, text), or the errors as text.
1164    fn files(files: &[(&str, &str)]) -> Result<Lang, Vec<String>> {
1165        let files: Vec<LangFile<'_>> = files
1166            .iter()
1167            .map(|&(path, source)| LangFile {
1168                path,
1169                source: Some(source),
1170            })
1171            .collect();
1172        from_files(&files, None, |path| format!("lang/{path}"), &RULES)
1173            .map_err(|errors| errors.iter().map(ToString::to_string).collect())
1174    }
1175
1176    const UI: &str = r#"[(key: "a.one", source: "One", text: "Un")]"#;
1177
1178    #[test]
1179    fn files_make_english_and_a_pack_per_directory() {
1180        let lang = files(&[
1181            ("en/ui.ron", ENGLISH_SOURCE),
1182            ("xx/lang.ron", INFO),
1183            ("xx/ui.ron", UI),
1184            ("yy/ui.ron", "[]"),
1185            ("yy/lang.ron", INFO),
1186        ])
1187        .unwrap();
1188        assert_eq!(lang.codes().collect::<Vec<_>>(), [&code("xx"), &code("yy")]);
1189        assert_eq!(lang.text(&code("xx"), "a.one"), "Un");
1190        assert_eq!(lang.text(&code("yy"), "a.one"), "One");
1191        // English alone is enough.
1192        let lang = files(&[("en/ui.ron", ENGLISH_SOURCE)]).unwrap();
1193        assert_eq!(lang.codes().count(), 0);
1194        assert!(lang.has("a.one"));
1195    }
1196
1197    #[test]
1198    fn a_missing_file_is_reported() {
1199        assert_eq!(
1200            files(&[]).unwrap_err(),
1201            ["lang/en/ui.ron: file not found in asset bundle"]
1202        );
1203        assert_eq!(
1204            files(&[("en/ui.ron", ENGLISH_SOURCE), ("xx/ui.ron", UI)]).unwrap_err(),
1205            ["lang/xx/lang.ron: file not found in asset bundle"]
1206        );
1207        assert_eq!(
1208            files(&[("en/ui.ron", ENGLISH_SOURCE), ("xx/lang.ron", INFO)]).unwrap_err(),
1209            ["lang/xx/ui.ron: file not found in asset bundle"]
1210        );
1211        // A file that isn't text counts as missing.
1212        let binary = [LangFile {
1213            path: "en/ui.ron",
1214            source: None,
1215        }];
1216        let errors = from_files(&binary, None, str::to_owned, &RULES).unwrap_err();
1217        assert_eq!(errors.len(), 1);
1218        assert_eq!(errors[0].file, "en/ui.ron");
1219    }
1220
1221    #[test]
1222    fn a_settings_file_among_the_languages_is_no_error() {
1223        let read = files(&[
1224            ("_folder.meta", "()"),
1225            ("en/ui.ron", ENGLISH_SOURCE),
1226            ("en/ui.ron.meta", "()"),
1227            ("xx/_folder.meta", "()"),
1228            ("xx/lang.ron", INFO),
1229            ("xx/ui.ron", UI),
1230        ]);
1231        assert!(read.is_ok(), "{read:?}");
1232    }
1233
1234    #[test]
1235    fn a_file_no_language_has_is_reported() {
1236        let stray = "not a file a language has";
1237        assert_eq!(
1238            files(&[
1239                ("en/ui.ron", ENGLISH_SOURCE),
1240                ("en/lang.ron", INFO),
1241                ("notes.txt", ""),
1242                ("xx/lang.ron", INFO),
1243                ("xx/ui.ron", UI),
1244                // A pack may have these two kinds of file...
1245                ("xx/data.ron", "[]"),
1246                ("xx/dialogue/ch01.ron", "[]"),
1247                // ...but English can't, and a pack has no others.
1248                ("en/data.ron", "[]"),
1249                ("xx/dialogue/ch01.dlg", ""),
1250                ("xx/dialogue/.ron", "[]"),
1251                ("xx/dialogue/a/b.ron", "[]"),
1252                ("xx/notes/ch01.ron", "[]"),
1253            ])
1254            .unwrap_err(),
1255            [
1256                format!("lang/en/lang.ron: {stray}"),
1257                format!("lang/notes.txt: {stray}"),
1258                format!("lang/en/data.ron: {stray}"),
1259                format!("lang/xx/dialogue/ch01.dlg: {stray}"),
1260                format!("lang/xx/dialogue/.ron: {stray}"),
1261                format!("lang/xx/dialogue/a/b.ron: {stray}"),
1262                format!("lang/xx/notes/ch01.ron: {stray}"),
1263            ]
1264        );
1265    }
1266
1267    #[test]
1268    fn a_badly_named_directory_is_reported() {
1269        assert_eq!(
1270            files(&[
1271                ("en/ui.ron", ENGLISH_SOURCE),
1272                ("JA/lang.ron", INFO),
1273                ("JA/ui.ron", UI),
1274            ])
1275            .unwrap_err(),
1276            ["lang/JA: name a language's folder by its tag as BCP 47 writes it: ja"]
1277        );
1278    }
1279
1280    #[test]
1281    fn pack_errors_are_reported_for_every_pack() {
1282        let bad = r#"[(key: "a.nope", source: "x", text: "y")]"#;
1283        assert_eq!(
1284            files(&[
1285                ("en/ui.ron", ENGLISH_SOURCE),
1286                ("xx/lang.ron", INFO),
1287                ("xx/ui.ron", bad),
1288                ("yy/lang.ron", INFO),
1289                ("yy/ui.ron", bad),
1290            ])
1291            .unwrap_err(),
1292            [
1293                "lang/xx/ui.ron: \"a.nope\": no such key in the source language (en)",
1294                "lang/yy/ui.ron: \"a.nope\": no such key in the source language (en)",
1295            ]
1296        );
1297    }
1298
1299    #[test]
1300    fn with_english_broken_the_packs_are_not_checked() {
1301        let errors = files(&[
1302            ("en/ui.ron", r#"{"Bad Key": "x"}"#),
1303            ("xx/lang.ron", INFO),
1304            ("xx/ui.ron", UI),
1305        ])
1306        .unwrap_err();
1307        assert_eq!(errors.len(), 1, "{errors:?}");
1308        assert!(errors[0].starts_with("lang/en/ui.ron: \"Bad Key\""));
1309    }
1310
1311    #[test]
1312    fn width_counts_characters_and_a_wide_one_twice() {
1313        let narrow = &CellWidths::NARROW;
1314        assert_eq!(text_width("", narrow), 0);
1315        assert_eq!(text_width("abc", narrow), 3);
1316        assert_eq!(text_width("né!", narrow), 3);
1317        assert_eq!(text_width("あA", narrow), 2);
1318        let wide = &CellWidths::wide(['あ', 'い']);
1319        assert_eq!(text_width("あA", wide), 3);
1320        assert_eq!(text_width("あいう", wide), 5);
1321    }
1322}