mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
1
Commits
main
...
split/crate-lang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a190dbbc0d |
No files matched your search
@@ -74,11 +74,70 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
|
||||
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
|
||||
fn bundled_code(code: &str) -> (&str, bool) {
|
||||
match code {
|
||||
"ko" => ("ko_kr", false),
|
||||
"hr" => ("hr_hr", false),
|
||||
"de_at" => ("de_de", false),
|
||||
"de_ch" => ("de_de", true),
|
||||
"fr_ca" | "fr_ch" => ("fr_fr", false),
|
||||
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
|
||||
code => (code, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
|
||||
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
|
||||
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
|
||||
/// counts.
|
||||
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
|
||||
let _ = CRATE_FILES.set(files);
|
||||
}
|
||||
|
||||
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
|
||||
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
|
||||
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let mut own = HashMap::new();
|
||||
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
|
||||
read_lang_file(&bytes, &mut own);
|
||||
}
|
||||
for (c, json) in files.iter().flat_map(|f| f.iter()) {
|
||||
if *c != file {
|
||||
continue;
|
||||
}
|
||||
let mut theirs = HashMap::new();
|
||||
if swiss {
|
||||
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
|
||||
} else {
|
||||
read_lang_file(json.as_bytes(), &mut theirs);
|
||||
}
|
||||
for (key, value) in theirs {
|
||||
if !own.contains_key(&key) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
let code = path
|
||||
.strip_prefix("assets/horizon/lang/")?
|
||||
.strip_suffix(".json")?;
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let path = format!("assets/horizon/lang/{file}.json");
|
||||
let body = match path.as_str() {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
|
||||
"assets/horizon/lang/ko_kr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
|
||||
}
|
||||
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
|
||||
"assets/horizon/lang/hr_hr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
|
||||
}
|
||||
"assets/horizon/lang/pt_pt.json" => {
|
||||
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/vec_it.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
"assets/horizon/lang/de_de.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
"assets/horizon/lang/fr_fr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
"assets/horizon/lang/es_es.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if swiss {
|
||||
return Some(body.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
@@ -438,6 +487,16 @@ impl Language {
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
|
||||
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
|
||||
Self::load_with(sources, code, crate_files)
|
||||
}
|
||||
|
||||
/// [`Language::load`] with these crates' lang files.
|
||||
fn load_with(
|
||||
sources: &[&dyn ResourceSource],
|
||||
code: &str,
|
||||
crate_files: &[&[(&str, &str)]],
|
||||
) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut codes = vec![DEFAULT];
|
||||
if code != DEFAULT {
|
||||
@@ -453,12 +512,14 @@ impl Language {
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
for namespace in &namespaces {
|
||||
for source in sources {
|
||||
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
read_lang_file(&bytes, &mut map);
|
||||
for (i, source) in sources.iter().enumerate() {
|
||||
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
// The crates' text right after the engine's own (`Bundled`, the first source).
|
||||
if i == 0 && namespace == "horizon" {
|
||||
read_crate_files(crate_files, c, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +749,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crates_text_loads_after_the_engines_and_before_packs() {
|
||||
static CRATE: &[(&str, &str)] = &[
|
||||
(
|
||||
"en_us",
|
||||
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
|
||||
),
|
||||
(
|
||||
"de_de",
|
||||
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
|
||||
),
|
||||
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
|
||||
];
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/horizon/lang/en_us.json".to_owned(),
|
||||
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
|
||||
)]));
|
||||
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
|
||||
let en = load("en_us");
|
||||
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
|
||||
// The engine's own file wins on a key both have; a pack wins over both.
|
||||
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
|
||||
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
|
||||
let de = load("de_de");
|
||||
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
// Regional variants read their language's file, as the engine's do.
|
||||
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
|
||||
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
|
||||
// A language the crate lacks falls back to its English.
|
||||
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
|
||||
// Without crate files, nothing changes.
|
||||
let plain = Language::load_with(&[&pack], "de_de", &[]);
|
||||
assert_eq!(plain.get("test.crate.hello"), None);
|
||||
assert_eq!(plain.len() + 1, de.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
@@ -739,6 +837,20 @@ mod tests {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
|
||||
/// registered through `horizon_hooks::lang`), with that folder.
|
||||
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
|
||||
.map(|(name, path)| (name, path.join("assets/lang")))
|
||||
.filter(|(_, dir)| dir.join("en_us.json").is_file())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
@@ -776,11 +888,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let engine = check_lang_dir(&bundled_dir(), true);
|
||||
assert!(
|
||||
engine.1 >= 7,
|
||||
"found {} bundled lang files in {}",
|
||||
engine.1,
|
||||
bundled_dir().display()
|
||||
);
|
||||
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
|
||||
// files also have, the engine's text would win and hide the crate's.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
let (en, _) = check_lang_dir(&dir, false);
|
||||
for key in en.keys() {
|
||||
assert!(
|
||||
!engine.0.contains_key(key),
|
||||
"{name}: {key} is also the engine's"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
|
||||
/// same placeholders as English, no empty text. Its en_us and how many files it has.
|
||||
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
@@ -789,7 +923,7 @@ mod tests {
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
!bundled || Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
@@ -815,11 +949,7 @@ mod tests {
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
(en, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -834,10 +964,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let english = |dir: &std::path::Path| -> HashMap<String, String> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
|
||||
};
|
||||
let mut en = english(&bundled_dir());
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
@@ -847,6 +977,11 @@ mod tests {
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
// A crate that ships lang files names its keys in its own sources.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
en.extend(english(&dir));
|
||||
rust_files(&crates.join(name).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
|
||||
@@ -27,6 +27,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
|
||||
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Lang files a crate above the engine ships with its text (docs/layers.md, "Platform
|
||||
//! registries"). A crate [`register`]s its `(code, JSON)` pairs, usually `include_str!`s of its
|
||||
//! `assets/lang/<code>.json`, before the game starts. The game loads them as part of its own
|
||||
//! `horizon` namespace, right after its bundled files for the same language, so resource packs
|
||||
//! still override them; on a key both have, the engine's file wins. A regional variant reads its
|
||||
//! language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de` without `ß`).
|
||||
use crate::{TooLate, registry::Registry};
|
||||
|
||||
/// One crate's lang files: a language code (`en_us`, `fr_fr`) and that file's JSON text.
|
||||
pub type Files = [(&'static str, &'static str)];
|
||||
|
||||
static FILES: Registry<Files> = Registry::new();
|
||||
|
||||
/// Adds a crate's lang files. Before the game starts, as [`crate::register`]; a later call is
|
||||
/// refused.
|
||||
pub fn register(files: &'static Files) -> Result<(), TooLate> {
|
||||
FILES.register(files)
|
||||
}
|
||||
|
||||
pub(crate) fn all() -> &'static [&'static Files] {
|
||||
FILES.all()
|
||||
}
|
||||
|
||||
/// The engine's side. Only the engine calls it.
|
||||
pub mod engine {
|
||||
/// Every crate's files, in registration order, sealing the list on the first call.
|
||||
pub fn all() -> &'static [&'static super::Files] {
|
||||
super::all()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn files_registered_before_the_game_starts_are_kept_in_order() {
|
||||
static A: &Files = &[("en_us", r#"{"a.b.c": "A"}"#)];
|
||||
static B: &Files = &[("en_us", r#"{"d.e.f": "B"}"#), ("fr_fr", "{}")];
|
||||
assert_eq!(register(A), Ok(()));
|
||||
assert_eq!(register(B), Ok(()));
|
||||
let all = engine::all();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[1][1].0, "fr_fr");
|
||||
assert_eq!(register(A), Err(TooLate));
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,11 @@
|
||||
//!
|
||||
//! The platform registries (docs/layers.md, "Platform registries") have the same shape:
|
||||
//! [`settings`] for the settings each crate keeps in its own lines, [`pages`] for its settings
|
||||
//! pages, [`hud`] for what it draws on the HUD.
|
||||
//! pages, [`hud`] for what it draws on the HUD, [`lang`] for the lang files with its text.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod hud;
|
||||
pub mod lang;
|
||||
pub mod pages;
|
||||
pub mod players;
|
||||
mod registry;
|
||||
@@ -155,6 +156,7 @@ pub mod engine {
|
||||
crate::settings::all();
|
||||
crate::pages::engine::all();
|
||||
crate::hud::engine::all();
|
||||
crate::lang::all();
|
||||
}
|
||||
|
||||
/// [`Hooks::started`].
|
||||
|
||||
@@ -68,6 +68,12 @@ pub mod pages {
|
||||
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
|
||||
}
|
||||
|
||||
/// The lang files with a crate's text, loaded with the game's own (docs/layers.md, "Platform
|
||||
/// registries").
|
||||
pub mod lang {
|
||||
pub use horizon_hooks::lang::{Files, register};
|
||||
}
|
||||
|
||||
/// Drawing on the HUD (docs/layers.md, "Platform registries").
|
||||
pub mod hud {
|
||||
pub use horizon_hooks::hud::{Canvas, Corner, Layer, register};
|
||||
|
||||
+26
-2
@@ -322,8 +322,8 @@ hook (#14081), `seat()` (#14096).
|
||||
only calls it.
|
||||
- With nothing registered, the engine does no work: a load of an empty slice where it would dispatch. It makes
|
||||
no allocation, no draw call and no file. A vanilla build (horizon-client, horizon-plain) is unchanged.
|
||||
- Visible text is a translation key the registrant ships in its lang files, never English in code. Values a
|
||||
player typed (a name, a path) are the exception.
|
||||
- Visible text is a translation key the registrant ships in its lang files ([Lang files](#lang-files-horizon_platformlang)),
|
||||
never English in code. Values a player typed (a name, a path) are the exception.
|
||||
- Splitscreen players are one process, one thread, each `Game` in turn (`game/split.rs`), so a crate's statics
|
||||
are shared by them. Every call (stores, pages, layers, hooks) happens on one player's turn, and
|
||||
`horizon_platform::hooks::seat()` (0 for P1) says whose: a crate keeps per-player state indexed by it.
|
||||
@@ -500,6 +500,30 @@ fn container_draw(&self, c: &dyn Container, canvas: &mut dyn hud::Canvas); //
|
||||
- `order` is the only text: Sorting ranks by it. The rest is ids and numbers, so the API names no engine type
|
||||
(`ItemStack` stays in the engine).
|
||||
|
||||
### Lang files (`horizon_platform::lang`)
|
||||
|
||||
A crate keeps the text for its keys in its own folder, `crates/<name>/assets/lang/<code>.json`, with the same
|
||||
shape as the engine's `assets/horizon/lang` files (flat `"key": "text"` lines, `en_us.json` the reference).
|
||||
It registers them as `(code, JSON)` pairs, usually `include_str!`s:
|
||||
|
||||
```rust
|
||||
static LANG: &horizon_platform::lang::Files = &[
|
||||
("en_us", include_str!("../assets/lang/en_us.json")),
|
||||
("fr_fr", include_str!("../assets/lang/fr_fr.json")),
|
||||
];
|
||||
horizon_platform::lang::register(LANG)?;
|
||||
```
|
||||
|
||||
- The game loads them into its `horizon` namespace right after its own bundled file for the same language, so a
|
||||
resource pack still overrides them, and a language a crate lacks falls back to its English.
|
||||
- A key belongs to one folder. On a key the engine's file also has, the engine's text wins (and the
|
||||
`lang.rs` test fails).
|
||||
- Regional variants read their language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de`
|
||||
without `ß`).
|
||||
- `tools/lang_coverage.py` reports each crate's folder beside the engine's. A person's review of a crate's entry
|
||||
is kept in `tools/lang_review/<name>/<code>.json` ([translations.md](translations.md)).
|
||||
- With nothing registered, a language loads exactly as before.
|
||||
|
||||
## Extras that may leave the game
|
||||
|
||||
Horizon's extras are Horizon-layer code. Each one is a candidate to become an optional native mod that players add
|
||||
|
||||
@@ -11,8 +11,9 @@ no difference: the mark lives in a file the game and the launcher never read.
|
||||
| | Files | Keys | Read by |
|
||||
|---|---|---|---|
|
||||
| Game | `crates/horizon-client/assets/horizon/lang/<code>.json` (`fr_fr`, `de_de`...) | `horizon.options.foo` style; `en_us.json` is the reference | `crates/horizon-client-core/src/lang.rs` (`Text::tr`, `lang::get`); a missing key falls back to English |
|
||||
| A crate's own text | `crates/<name>/assets/lang/<code>.json`, the same shape | the crate's own keys, never one of the engine's | the same, once the crate registers them ([layers.md](layers.md#lang-files-horizon_platformlang)) |
|
||||
| Launcher | `launcher/lang/<code>.json` (`fr`, `de`, `pt-br`...) | the English sentence itself | `launcher/src/i18n.rs` (`tr("…")`, `trf("… {arg} …")`) |
|
||||
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
|
||||
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/<crate name>/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
|
||||
|
||||
An entry is **reviewed** when the review record holds its key with exactly its present text. Anything else is
|
||||
unreviewed: an entry the record does not hold, and one whose text changed since a person checked it (so a later
|
||||
|
||||
@@ -215,8 +215,49 @@ class ReviewTests(unittest.TestCase):
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue((Path(tmp) / "review" / "launcher" / "xx.json").exists())
|
||||
|
||||
def test_a_crates_own_folder_is_reported_and_its_reviews_kept_apart(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
game, crates, review = Path(tmp) / "game", Path(tmp) / "crates", Path(tmp) / "review"
|
||||
friends = crates / "horizon-friends" / "assets" / "lang"
|
||||
game.mkdir()
|
||||
friends.mkdir(parents=True)
|
||||
(crates / "horizon-plain" / "src").mkdir(parents=True) # no lang folder: not reported
|
||||
write(game, "en_us.json", '{\n "a": "Apple"\n}\n')
|
||||
write(game, "xx_yy.json", '{\n "a": "Manzana"\n}\n')
|
||||
write(friends, "en_us.json", '{\n "f.one": "One",\n "f.two": "Two"\n}\n')
|
||||
write(friends, "xx_yy.json", '{\n "f.one": "Uno"\n}\n')
|
||||
common = ("--game-dir", str(game), "--crates-dir", str(crates), "--review-dir", str(review),
|
||||
"--game-only")
|
||||
code, out, _ = run(*common, "--summary")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("GAME xx_yy 1/1 keys, missing 0", out)
|
||||
self.assertIn("GAME horizon-friends xx_yy 1/2 keys, missing 1", out)
|
||||
self.assertNotIn("horizon-plain", out)
|
||||
code, _, err = run(*common, "--fail-under", "90")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("xx_yy: 50.0%", err)
|
||||
# One command marks the keys of both folders; each lands in its folder's record.
|
||||
code, out, _ = run(*common, "--lang", "xx_yy", "--mark-reviewed", "a", "f.one")
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(json.loads((review / "game" / "xx_yy.json").read_text(encoding="utf-8")),
|
||||
{"a": "Manzana"})
|
||||
self.assertEqual(json.loads((review / "horizon-friends" / "xx_yy.json").read_text(encoding="utf-8")),
|
||||
{"f.one": "Uno"})
|
||||
# Marking the game's folder again keeps the crate's record.
|
||||
code, _, _ = run(*common, "--lang", "xx_yy", "--mark-reviewed", "a")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue((review / "horizon-friends" / "xx_yy.json").exists())
|
||||
code, _, err = run(*common, "--lang", "xx_yy", "--mark-reviewed", "nosuch")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("no key 'nosuch'", err)
|
||||
# A scratch --game-dir alone leaves the repository's crates out.
|
||||
code, out, _ = run("--game-dir", str(game), "--review-dir", str(review), "--game-only", "--summary")
|
||||
self.assertNotIn("horizon-friends", out)
|
||||
|
||||
def test_every_repository_record_belongs_to_a_lang_file_and_reads(self):
|
||||
for kind, folder in (("game", lang_coverage.GAME), ("launcher", lang_coverage.LAUNCHER)):
|
||||
folders = [("game", lang_coverage.GAME), ("launcher", lang_coverage.LAUNCHER),
|
||||
*lang_coverage.crate_dirs(lang_coverage.CRATES)]
|
||||
for kind, folder in folders:
|
||||
for record in sorted((lang_coverage.REVIEW / kind).glob("*.json")):
|
||||
with self.subTest(record=record.name, kind=kind):
|
||||
self.assertTrue((folder / record.name).exists(), f"{record} names no lang file")
|
||||
|
||||
+75
-23
@@ -8,6 +8,10 @@ a gap is not an error — it is an English sentence on a translated screen. That
|
||||
tests catch stale keys, repeated keys, empty values and changed placeholders, but
|
||||
deliberately not gaps, because a language is allowed to lag. This tool reports them.
|
||||
|
||||
A crate that ships its own text keeps it in `crates/<name>/assets/lang/<code>.json`, the same
|
||||
shape with its own `en_us.json` reference (docs/layers.md, "Lang files"); each such folder is
|
||||
reported beside the game's, and its review record is `tools/lang_review/<name>/`.
|
||||
|
||||
`launcher/lang/<code>.json` is the launcher's: its keys are the English sentence itself,
|
||||
so a file is complete by construction (`launcher/src/i18n/tests.rs` checks every `tr("…")`
|
||||
the code asks for). What it can still hold is a value identical to its English key, and a
|
||||
@@ -43,6 +47,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GAME = ROOT / "crates/horizon-client/assets/horizon/lang"
|
||||
CRATES = ROOT / "crates"
|
||||
LAUNCHER = ROOT / "launcher/lang"
|
||||
REVIEW = ROOT / "tools/lang_review"
|
||||
REFERENCE = "en_us.json"
|
||||
@@ -192,6 +197,12 @@ def game_rows(directory: Path, codes: set[str],
|
||||
return reference, rows
|
||||
|
||||
|
||||
def crate_dirs(crates: Path) -> list[tuple[str, Path]]:
|
||||
"""The crates that ship their own lang files: (crate name, its `assets/lang`), by name."""
|
||||
return sorted((path.parents[2].name, path.parent)
|
||||
for path in crates.glob("*/assets/lang/" + REFERENCE))
|
||||
|
||||
|
||||
def launcher_rows(directory: Path, codes: set[str], review: Path | None = None) -> list[dict]:
|
||||
"""Every launcher language: the values still identical to their English key, the
|
||||
`{holes}` a value does not match it on, and with `review` (a `lang_review/launcher` folder)
|
||||
@@ -228,8 +239,8 @@ def print_problems(rows: list[dict]) -> None:
|
||||
print(f" ! {row['code']}: {row['problem']}")
|
||||
|
||||
|
||||
def print_game(reference: LangFile, directory: Path, rows: list[dict]) -> None:
|
||||
print(f"GAME {shown(directory)} ({reference.code}: {len(reference.entries)} keys, the reference)")
|
||||
def print_game(reference: LangFile, directory: Path, rows: list[dict], kind: str = "GAME") -> None:
|
||||
print(f"{kind} {shown(directory)} ({reference.code}: {len(reference.entries)} keys, the reference)")
|
||||
print()
|
||||
print(f" {'code':<10}{'keys':>6}{'cover':>8}{'missing':>9}{'extra':>7}{'empty':>7}{'same':>6}"
|
||||
f"{'unreviewed':>12}")
|
||||
@@ -278,9 +289,9 @@ def print_launcher_keys(rows: list[dict]) -> None:
|
||||
print(f" {side}: {', '.join(bad[side])}")
|
||||
|
||||
|
||||
def print_game_summary(reference: LangFile, rows: list[dict]) -> None:
|
||||
def print_game_summary(reference: LangFile, rows: list[dict], kind: str = "GAME") -> None:
|
||||
for row in rows:
|
||||
line = (f"GAME {row['code']} {row['keys']}/{len(reference.entries)} keys, "
|
||||
line = (f"{kind} {row['code']} {row['keys']}/{len(reference.entries)} keys, "
|
||||
f"missing {len(row['missing'])}, extra {len(row['extra'])}, "
|
||||
f"placeholders {len(row['placeholders'])}, empty {len(row['empty'])}, "
|
||||
f"same {len(row['same'])}, unreviewed {len(row['unreviewed'])}")
|
||||
@@ -336,6 +347,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
which.add_argument("--launcher-only", action="store_true", help="only the launcher's lang")
|
||||
parser.add_argument("--game-dir", type=Path, default=GAME,
|
||||
help="the game's lang folder (default: %(default)s)")
|
||||
parser.add_argument("--crates-dir", type=Path,
|
||||
help="report every <dir>/<crate>/assets/lang too (default: the repository's crates, "
|
||||
"unless --game-dir names another folder)")
|
||||
parser.add_argument("--launcher-dir", type=Path, default=LAUNCHER,
|
||||
help="the launcher's lang folder (default: %(default)s)")
|
||||
parser.add_argument("--lang", nargs="+", metavar="CODE",
|
||||
@@ -359,6 +373,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
codes = set(args.lang or ())
|
||||
if args.crates_dir is None and args.game_dir == GAME:
|
||||
args.crates_dir = CRATES
|
||||
if args.mark_reviewed is not None:
|
||||
return mark_command(args, codes)
|
||||
if args.fail_under is not None and args.launcher_only:
|
||||
@@ -377,6 +393,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
result["game"] = {"reference": reference.code, "keys": len(reference.entries), "files": game}
|
||||
# (crate name, its folder, its reference, its rows): the text crates ship.
|
||||
crates: list[tuple[str, Path, LangFile, list[dict]]] = []
|
||||
if not args.launcher_only and args.crates_dir is not None:
|
||||
for name, folder in crate_dirs(args.crates_dir):
|
||||
try:
|
||||
crates.append((name, folder, *game_rows(folder, codes, args.review_dir / name)))
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
if crates:
|
||||
result["crates"] = {name: {"reference": ref.code, "keys": len(ref.entries), "files": rows}
|
||||
for name, _, ref, rows in crates}
|
||||
launcher: list[dict] = []
|
||||
if not args.game_only:
|
||||
if not args.launcher_dir.is_dir():
|
||||
@@ -388,7 +416,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
result["launcher"] = {"files": launcher}
|
||||
if codes and not game and not launcher:
|
||||
if codes and not game and not launcher and not any(rows for *_, rows in crates):
|
||||
print(f"no lang file is named {' or '.join(sorted(codes))}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@@ -397,14 +425,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||
elif args.unreviewed:
|
||||
if reference:
|
||||
print_unreviewed("GAME", game, reference.entries)
|
||||
for name, _, ref, rows in crates:
|
||||
print_unreviewed(f"GAME {name}", rows, ref.entries)
|
||||
print_unreviewed("LAUNCHER", launcher, None)
|
||||
elif args.summary:
|
||||
if reference:
|
||||
print_game_summary(reference, game)
|
||||
for name, _, ref, rows in crates:
|
||||
print_game_summary(ref, rows, f"GAME {name}")
|
||||
print_launcher_summary(launcher)
|
||||
else:
|
||||
if reference:
|
||||
print_game(reference, args.game_dir, game)
|
||||
for name, folder, ref, rows in crates:
|
||||
print()
|
||||
print_game(ref, folder, rows, f"GAME {name}")
|
||||
if not args.game_only:
|
||||
if reference:
|
||||
print()
|
||||
@@ -412,11 +447,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.show_keys:
|
||||
if reference:
|
||||
print_game_keys(game)
|
||||
for *_, rows in crates:
|
||||
print_game_keys(rows)
|
||||
if launcher:
|
||||
print_launcher_keys(launcher)
|
||||
|
||||
if args.fail_under is not None:
|
||||
under = [row for row in game if 100 * row["coverage"] + 1e-9 < args.fail_under]
|
||||
every = game + [row for *_, rows in crates for row in rows]
|
||||
under = [row for row in every if 100 * row["coverage"] + 1e-9 < args.fail_under]
|
||||
for row in under:
|
||||
print(f"{row['code']}: {100 * row['coverage']:.1f}% < {args.fail_under:g}%", file=sys.stderr)
|
||||
if under:
|
||||
@@ -425,8 +463,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
|
||||
def mark_command(args, codes: set[str]) -> int:
|
||||
"""`--mark-reviewed`: one file, named by `--lang`; --game-only or --launcher-only picks the
|
||||
folder when the code is in both."""
|
||||
"""`--mark-reviewed`: one language, named by `--lang`; --game-only or --launcher-only picks the
|
||||
folder when the code is in both. The game's text may be split between its own folder and the
|
||||
crates' (`--crates-dir`): each key is recorded for the folder that holds it."""
|
||||
if len(codes) != 1:
|
||||
print("--mark-reviewed needs exactly one --lang code", file=sys.stderr)
|
||||
return 2
|
||||
@@ -434,31 +473,44 @@ def mark_command(args, codes: set[str]) -> int:
|
||||
if code == "en_us":
|
||||
print("en_us is the English reference, not a translation", file=sys.stderr)
|
||||
return 2
|
||||
found = [(kind, folder / f"{code}.json") for kind, folder, skip in (
|
||||
("game", args.game_dir, args.launcher_only), ("launcher", args.launcher_dir, args.game_only))
|
||||
if not skip and (folder / f"{code}.json").exists()]
|
||||
if not found:
|
||||
game = [("game", args.game_dir)]
|
||||
if args.crates_dir is not None:
|
||||
game += crate_dirs(args.crates_dir)
|
||||
sides = [[(kind, folder / f"{code}.json") for kind, folder in folders
|
||||
if (folder / f"{code}.json").exists()]
|
||||
for folders, skip in ((game, args.launcher_only), ([("launcher", args.launcher_dir)], args.game_only))
|
||||
if not skip]
|
||||
sides = [side for side in sides if side]
|
||||
if not sides:
|
||||
print(f"no lang file is named {code}", file=sys.stderr)
|
||||
return 2
|
||||
if len(found) > 1:
|
||||
if len(sides) > 1:
|
||||
print(f"{code} is a game and a launcher file: add --game-only or --launcher-only", file=sys.stderr)
|
||||
return 2
|
||||
kind, lang = found[0]
|
||||
found = sides[0]
|
||||
keys = list(args.mark_reviewed)
|
||||
if args.keys_file:
|
||||
keys += [line for line in args.keys_file.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
if not keys:
|
||||
print("--mark-reviewed needs keys, on the command line or in --keys-file", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
unknown = mark_reviewed(lang, args.review_dir / kind / lang.name, keys)
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
for key in unknown:
|
||||
print(f"{shown(lang)} has no key {key!r}", file=sys.stderr)
|
||||
print(f"{len(keys) - len(unknown)} entries of {shown(lang)} recorded as reviewed")
|
||||
return 1 if unknown else 0
|
||||
# Each key goes to the first folder that has it; a key none has is reported against them all.
|
||||
left = list(keys)
|
||||
for kind, lang in found:
|
||||
entries = LangFile(lang).entries
|
||||
mine = [key for key in left if key in entries]
|
||||
left = [key for key in left if key not in entries]
|
||||
if not mine and len(found) > 1:
|
||||
continue
|
||||
try:
|
||||
mark_reviewed(lang, args.review_dir / kind / lang.name, mine)
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
print(f"{len(mine)} entries of {shown(lang)} recorded as reviewed")
|
||||
for key in left:
|
||||
print(f"{' or '.join(shown(lang) for _, lang in found)} has no key {key!r}", file=sys.stderr)
|
||||
return 1 if left else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user