yog-cli 0.49.0

Yog build tool — `yog build` compiles a mod and packages it as .yog.
Documentation
//! What a mod's resources become besides code: lang files and
//! `sounds.json` (merged with hand-written ones), and the generated
//! sources written out.

use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
use std::path::Path;

use super::{Compiled, GENERATED_DIR, GENERATED_FILE, parse};

/// The lang JSON of `entries` (key, text), order kept.
fn lang_json(entries: &[(String, String)]) -> String {
    let mut out = String::from("{");
    for (n, (k, v)) in entries.iter().enumerate() {
        out.push_str(if n == 0 { "\n  " } else { ",\n  " });
        out.push_str(&serde_json::to_string(k).unwrap_or_default());
        out.push_str(": ");
        out.push_str(&serde_json::to_string(v).unwrap_or_default());
    }
    out.push_str(if entries.is_empty() { "}\n" } else { "\n}\n" });
    out
}

/// Locale `locale`'s lang file of the mod: its `entries` after a
/// hand-written file's, whose keys mustn't be the `.yres` texts' (`origin`:
/// where each of those is).
pub(super) fn lang_file(
    root: &Path,
    mod_id: &str,
    locale: &str,
    entries: Vec<(String, String)>,
    origin: &HashMap<String, String>,
) -> Result<(String, Vec<u8>), Vec<String>> {
    let path = yog_ids::asset_file(mod_id, &yog_ids::lang_file(locale));
    let Ok(text) = std::fs::read_to_string(root.join(&path)) else {
        return Ok((path, lang_json(&entries).into_bytes()));
    };
    let hand =
        parse(&text).map_err(|e| vec![format!("{path}:{}", e.trim_start_matches("line "))])?;
    let twice: Vec<String> = hand
        .iter()
        .filter_map(|h| {
            origin
                .get(&h.name)
                .map(|at| format!("{path}:{}: {:?} is also defined at {at}", h.line, h.name))
        })
        .collect();
    if !twice.is_empty() {
        return Err(twice);
    }
    let mut merged: Vec<(String, String)> = hand
        .iter()
        .map(|h| (h.name.clone(), h.text().to_string()))
        .collect();
    merged.extend(entries);
    Ok((path, lang_json(&merged).into_bytes()))
}

/// The mod's `sounds.json`: a hand-written one's events and the `.yres`
/// sounds', none of them twice.
pub(super) fn sounds_json(
    root: &Path,
    mod_id: &str,
    sounds: BTreeMap<String, serde_json::Value>,
) -> Result<(String, Vec<u8>), Vec<String>> {
    let path = yog_ids::asset_file(mod_id, yog_ids::res::SOUNDS_JSON);
    let mut all = serde_json::Map::new();
    if let Ok(text) = std::fs::read_to_string(root.join(&path)) {
        all = serde_json::from_str(&text).map_err(|e| vec![format!("{path}: {e}")])?;
    }
    let mut errors = Vec::new();
    for (event, def) in sounds {
        if all.insert(event.clone(), def).is_some() {
            errors.push(format!(
                "{path}: sound event {event:?} is also defined by a .yres sound"
            ));
        }
    }
    if !errors.is_empty() {
        return Err(errors);
    }
    let json = serde_json::to_string_pretty(&serde_json::Value::Object(all)).unwrap_or_default();
    Ok((path, format!("{json}\n").into_bytes()))
}

const BANNER: &str = "// <auto-generated>\n//     Generated by `yog build` from {source} — don't edit,\n//     it's rewritten on every build.\n// </auto-generated>\n\n";
/// What a generated source's file name ends with.
const SOURCE_EXT: &str = ".g.rs";

/// Writes `.yog-build/generated/resources/<Set>.g.rs` per set and
/// `.yog-build/generated/resources.g.rs` including them all (what
/// `yog_api::resources!()` pulls in as `crate::res`); deletes those of sets
/// that are gone.
pub fn write_sources(root: &Path, compiled: &Compiled) -> Result<(), String> {
    let dir = root.join(GENERATED_DIR);
    if let Ok(entries) = std::fs::read_dir(&dir) {
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().into_owned();
            if name
                .strip_suffix(SOURCE_EXT)
                .is_some_and(|set| !compiled.sources.contains_key(set))
            {
                let _ = std::fs::remove_file(entry.path());
            }
        }
    }
    let mut index = BANNER.replace("{source}", "resources/**/*.yres");
    index.push_str("// Included by `yog_api::resources!()` as `crate::res`.\n");
    for (set, code) in &compiled.sources {
        let source = code
            .lines()
            .next()
            .and_then(|l| l.split('`').nth(1))
            .unwrap_or("resources/");
        let mut file = BANNER.replace("{source}", source);
        file.push_str(code);
        crate::write_if_changed(&dir.join(format!("{set}{SOURCE_EXT}")), &file)?;
        let _ = writeln!(
            index,
            "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/resources/{set}{SOURCE_EXT}\"));"
        );
    }
    crate::write_if_changed(&root.join(GENERATED_FILE), &index)
}