Skip to main content

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