Skip to main content

yog_cli/content/source/
mod.rs

1//! The Rust the build generates for a mod's content (`yog-content.g.rs`).
2
3mod blocks;
4mod register;
5
6pub(in crate::content) use register::included;
7
8use yog_ids::code_set;
9
10use crate::content::defs::Defs;
11use crate::content::entry::{BlockProps, Entry};
12use crate::content::files::{BodyFile, BookFile, EntityFile, FontFile};
13use crate::content::outputs::write_tags;
14use crate::content::recipe::registration::Recipe;
15use crate::content::{Compiled, GENERATED_FILE, table};
16use std::collections::HashMap;
17use std::fmt::Write;
18use std::path::Path;
19use yog_ids::Format;
20
21/// [`yog_registry::REGISTRATIONS_FILE`]: what the mod registers at the
22/// game's start, each with a hash of what can't change while it runs — a
23/// block's properties and states, not its model, name or tags (the game
24/// reloads those).
25pub(super) fn registrations(
26    entries: &[Entry],
27    entities: &[EntityFile],
28    dimensions: &[(String, Vec<u8>)],
29    defs: &Defs,
30) -> String {
31    fn hash(text: &str) -> String {
32        // FNV-1a: the same on every build of the CLI.
33        const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
34        const PRIME: u64 = 0x0100_0000_01b3;
35        let h = text
36            .bytes()
37            .fold(OFFSET, |h, b| (h ^ u64::from(b)).wrapping_mul(PRIME));
38        format!("{h:016x}")
39    }
40    let mut lines = Vec::new();
41    for e in entries {
42        let what = match e.kind {
43            Format::Block => {
44                // Its tool tags and level are data (tags), reloaded with the rest.
45                let b = BlockProps {
46                    tools: Vec::new(),
47                    tool_level: None,
48                    ..e.block.clone()
49                };
50                format!("{b:?} {:?} {:?}", e.item, e.requires)
51            }
52            _ => format!("{:?} {:?}", e.item, e.requires),
53        };
54        lines.push(format!("{} {} {}", e.kind.noun(), e.id, hash(&what)));
55    }
56    for e in entities {
57        lines.push(format!(
58            "{} {} {}",
59            yog_ids::kind::ENTITY,
60            e.id,
61            hash(&format!("{:?}", e.data))
62        ));
63    }
64    for (id, data) in dimensions {
65        lines.push(format!(
66            "{} {id} {}",
67            yog_ids::kind::DIMENSION,
68            hash(&format!("{data:?}"))
69        ));
70    }
71    for g in &defs.sound_groups {
72        lines.push(format!(
73            "{} {} {}",
74            yog_ids::kind::SOUND_GROUP,
75            g.id,
76            hash(&format!("{g:?}"))
77        ));
78    }
79    for m in &defs.tool_materials {
80        lines.push(format!(
81            "{} {} {}",
82            yog_ids::kind::TOOL_MATERIAL,
83            m.id,
84            hash(&format!("{m:?}"))
85        ));
86    }
87    for m in &defs.armor_materials {
88        lines.push(format!(
89            "{} {} {}",
90            yog_ids::kind::ARMOR_MATERIAL,
91            m.id,
92            hash(&format!("{m:?}"))
93        ));
94    }
95    lines.sort();
96    lines.join("\n") + "\n"
97}
98
99/// Whether any `src/**/*.rs` has `content!(` — the include of the generated code.
100pub(super) fn mentions_content_macro(dir: &Path) -> bool {
101    let Ok(entries) = std::fs::read_dir(dir) else {
102        return false;
103    };
104    entries.flatten().any(|e| {
105        let p = e.path();
106        if p.is_dir() {
107            mentions_content_macro(&p)
108        } else {
109            p.extension().is_some_and(|x| x == "rs")
110                && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
111        }
112    })
113}
114
115/// `ruby_ore`, `ores/ruby` → `ores_ruby`; a Rust keyword gets `r#`.
116pub(super) fn rust_ident(path: &str) -> String {
117    let mut s: String = path
118        .chars()
119        .map(|c| {
120            if c.is_ascii_alphanumeric() {
121                c.to_ascii_lowercase()
122            } else {
123                '_'
124            }
125        })
126        .collect();
127    if s.starts_with(|c: char| c.is_ascii_digit()) {
128        s.insert(0, '_');
129    }
130    s
131}
132
133pub(super) fn float(n: f64) -> String {
134    let s = format!("{n:?}");
135    if s.contains('.') || s.contains('e') {
136        s
137    } else {
138        format!("{s}.0")
139    }
140}
141
142/// `name`, unique among those `used` so far: `ruby`, then `ruby_2`.
143fn unique_ident(used: &mut HashMap<String, usize>, name: String) -> String {
144    let n = used.entry(name.clone()).or_insert(0);
145    *n += 1;
146    if *n > 1 { format!("{name}_{n}") } else { name }
147}
148
149/// `pub struct <name>` with a `pub const <PATH>: &str = "<id>"` per id —
150/// what a mod's code names its content by. Nothing for no ids.
151fn id_consts<'a>(doc: &str, name: &str, ids: impl Iterator<Item = &'a str>) -> String {
152    let ids: Vec<&str> = ids.collect();
153    if ids.is_empty() {
154        return String::new();
155    }
156    let mut code = format!(
157        "\n/// {doc}\n#[allow(dead_code)]\npub struct {name};\n\n#[allow(dead_code, clippy::all)]\nimpl {name} {{\n"
158    );
159    let mut used: HashMap<String, usize> = HashMap::new();
160    for id in ids {
161        let ident = unique_ident(
162            &mut used,
163            rust_ident(yog_ids::split(id).1).to_ascii_uppercase(),
164        );
165        let _ = writeln!(code, "    pub const {ident}: &'static str = {id:?};");
166    }
167    code.push_str("}\n");
168    code
169}
170
171/// What the generated code is made of: every kind of content the build read.
172pub(super) struct Content<'a> {
173    pub entries: &'a [Entry],
174    pub recipes: &'a [Recipe],
175    pub books: &'a [BookFile],
176    pub bodies: &'a [BodyFile],
177    pub entities: &'a [EntityFile],
178    pub fonts: &'a [FontFile],
179    pub defs: &'a Defs,
180    pub textures: &'a std::collections::BTreeSet<String>,
181}
182
183impl Content<'_> {
184    /// Whether the content registers anything: its code's `register` must run.
185    pub(super) fn registers_anything(&self) -> bool {
186        let d = self.defs;
187        self.entries.iter().any(|e| e.auto)
188            || !self.recipes.is_empty()
189            || !self.books.is_empty()
190            || !self.bodies.is_empty()
191            || !self.entities.is_empty()
192            || !self.fonts.is_empty()
193            || !d.sound_groups.is_empty()
194            || !d.tool_materials.is_empty()
195            || !d.armor_materials.is_empty()
196            || !d.biome_features.is_empty()
197            || !d.dimensions.is_empty()
198            || !d.loot_modifiers.is_empty()
199            || !d.definitions.is_empty()
200    }
201}
202
203/// The content's code, part by part.
204pub(super) fn rust_source(c: &Content) -> Source {
205    let mut parts: Vec<(String, String)> = Vec::new();
206    let mut part = |name: &str, code: String| {
207        if !code.trim().is_empty() {
208            parts.push((name.to_string(), format!("{GENERATED_HEADER}{code}")));
209        }
210    };
211    let doc = "The entity types of `content/` (`.yentity`), by id.";
212    part(
213        "entities",
214        id_consts(
215            doc,
216            code_set::ENTITIES,
217            c.entities.iter().map(|e| e.id.as_str()),
218        ),
219    );
220    let doc = "The advancements of `content/` (`.ydef` kind `advancement`), by id\n/// (`yog_api::advancements::grant`/`done`).";
221    part(
222        "advancements",
223        id_consts(
224            doc,
225            code_set::ADVANCEMENTS,
226            c.defs.advancements.iter().map(String::as_str),
227        ),
228    );
229    for (model, set, what) in [
230        (true, code_set::MODELS, "models (`.ymodel`)"),
231        (false, code_set::ANIMS, "animations (`.yanim`)"),
232    ] {
233        let doc = format!("The {what} of `content/`, by id (`yog_api::body::model`/`clip`).");
234        part(
235            &set.to_ascii_lowercase(),
236            id_consts(
237                &doc,
238                set,
239                c.bodies
240                    .iter()
241                    .filter(|b| b.model == model)
242                    .map(|b| b.id.as_str()),
243            ),
244        );
245    }
246    let doc = "The textures of `resources/` (`.ytexture`), by id — what the mod's code draws.";
247    part(
248        "textures",
249        id_consts(
250            doc,
251            code_set::TEXTURES,
252            c.textures.iter().map(String::as_str),
253        ),
254    );
255    let idents = blocks::entry_idents(c.entries);
256    for (name, code) in blocks::entries_code(c.entries, &idents) {
257        part(&name, code);
258    }
259    part("register", register::register_code(c, &idents));
260    part("tables", c.defs.tables.iter().map(table::rust).collect());
261    let mut root = String::from(GENERATED_HEADER);
262    for (name, _) in &parts {
263        let _ = writeln!(
264            root,
265            "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{GENERATED_PARTS_DIR}/{name}.g.rs\"));"
266        );
267    }
268    Source { root, parts }
269}
270
271/// Writes `compiled.source` to [`GENERATED_FILE`] (only when it changed);
272/// deletes it when the mod has no content files.
273pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
274    write_tags(root, compiled)?;
275    let out = root.join(GENERATED_FILE);
276    let dir = out.parent().unwrap_or(root);
277    let parts_dir = dir.join(GENERATED_PARTS_DIR);
278    let Some(source) = &compiled.source else {
279        let _ = std::fs::remove_file(&out);
280        let _ = std::fs::remove_dir_all(&parts_dir);
281        return Ok(());
282    };
283    std::fs::create_dir_all(&parts_dir).map_err(|e| format!("{}: {e}", parts_dir.display()))?;
284    let wanted: Vec<std::path::PathBuf> = source
285        .parts
286        .iter()
287        .map(|(name, _)| parts_dir.join(format!("{name}.g.rs")))
288        .collect();
289    for ((_, code), file) in source.parts.iter().zip(&wanted) {
290        crate::write_if_changed(file, code)?;
291    }
292    // Parts the content no longer has.
293    for entry in std::fs::read_dir(&parts_dir)
294        .map_err(|e| format!("{}: {e}", parts_dir.display()))?
295        .flatten()
296    {
297        if !wanted.contains(&entry.path()) {
298            let _ = std::fs::remove_file(entry.path());
299        }
300    }
301    crate::write_if_changed(&out, &source.root)?;
302    for (path, bytes) in &compiled.data_files {
303        let file = dir.join(path);
304        if std::fs::read(&file).ok().as_deref() != Some(bytes.as_slice()) {
305            if let Some(parent) = file.parent() {
306                std::fs::create_dir_all(parent)
307                    .map_err(|e| format!("{}: {e}", parent.display()))?;
308            }
309            std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
310        }
311    }
312    Ok(())
313}
314
315/// The generated files' header.
316const GENERATED_HEADER: &str = "// <auto-generated>\n//     Generated by `yog build` from content/** — don't edit, it's rewritten\n//     on every build. Included by `yog_api::content!()`.\n// </auto-generated>\n";
317
318/// Where the content's code goes besides the root file, under
319/// `.yog-build/generated/`: one file a part (`items.g.rs`, `register.g.rs`, …).
320pub const GENERATED_PARTS_DIR: &str = "content";
321
322/// The content's code: the root file ([`GENERATED_FILE`]), which only
323/// includes the parts, and the parts — (name, code) each.
324#[derive(Debug, Clone, Default)]
325pub struct Source {
326    pub root: String,
327    pub parts: Vec<(String, String)>,
328}
329
330impl Source {
331    /// Part `name`'s code (`"items"`).
332    #[cfg(test)]
333    pub fn part(&self, name: &str) -> &str {
334        self.parts
335            .iter()
336            .find(|(n, _)| n == name)
337            .map_or("", |(_, c)| c.as_str())
338    }
339
340    /// Every part's code, for tests that look for a line wherever it is.
341    #[cfg(test)]
342    pub fn all(&self) -> String {
343        self.parts.iter().map(|(_, c)| c.as_str()).collect()
344    }
345}