Skip to main content

yog_cli/design/
refs.rs

1//! What a content reference (`@<kind>:<id>`) can name, for the designers'
2//! pickers — the same three sources `yog build` checks references against:
3//! the mod's own content (`content/`), the other mods' ids it declares in
4//! `yog.toml` (`[external.<namespace>]`) and the game's (read from the
5//! client jar on this machine, when there is one). And the mod's own
6//! `.ydef`s of a kind with what they say (an advancement's parent, an
7//! effect's color), for the designers that draw several at once.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use yog_ids::Format;
12use yog_sync::{Mutex, OnceLock};
13
14use yog_data::Value;
15
16use crate::design::assets::{Resources, Vanilla};
17use crate::design::project::{self, mod_root};
18
19/// Kinds of content defined by files of their own, by extension; any other
20/// kind is the `.ydef`s of that `kind`.
21const FILE_KINDS: [(&str, &[Format]); 4] = [
22    (yog_ids::kind::ITEM, &[Format::Item, Format::Block]),
23    (yog_ids::kind::BLOCK, &[Format::Block]),
24    (yog_ids::kind::ENTITY, &[Format::Entity]),
25    (yog_ids::kind::RECIPE, &[Format::Recipe]),
26];
27
28/// Body files (`.ymodel`, `.yanim`): named by their path under `content/`
29/// without the extension (`body/wings`), as an entity's `model` names them.
30const BODY_FORMATS: [Format; 2] = [Format::Model, Format::Anim];
31
32/// Resources defined by files of their own under `resources/`, named by
33/// their path there without the extension (`block/glass`).
34const RESOURCE_KINDS: [(&str, Format); 2] = [
35    (yog_ids::kind::TEXTURE, Format::Texture),
36    (yog_ids::kind::MATERIAL, Format::Material),
37];
38
39/// Where the game's ids of a kind are in its client jar: translation keys
40/// of its lang (`entity.minecraft.pig` → `pig`; `dotted`: the rest may hold
41/// dots, `generic.max_health`), files of its data folders (by kind), or the
42/// keys of one of its assets (`sounds.json`: sound events).
43enum GameIds {
44    /// Keys starting with the game's `<kind>.minecraft.` of each of these kinds.
45    Lang {
46        kinds: &'static [&'static str],
47        dotted: bool,
48    },
49    /// A prefix of its own (`attribute.name.`).
50    LangPrefix(&'static str),
51    Data,
52    Keys(&'static str),
53}
54
55/// The game's sound events: the keys of its `sounds.json`.
56const SOUNDS_ASSET: &str = yog_ids::res::SOUNDS_JSON;
57/// Attributes' translation keys: `attribute.name.generic.max_health`.
58const ATTRIBUTE_LANG_PREFIX: &str = "attribute.name.";
59/// The game's potions' item names: `item.minecraft.potion.effect.<name>`, a Yog potion alias.
60const POTION_LANG_PREFIX: &str = "potion.effect.";
61
62const GAME_IDS: [(&str, GameIds); 12] = [
63    (
64        yog_ids::kind::ITEM,
65        GameIds::Lang {
66            kinds: &[yog_ids::kind::ITEM, yog_ids::kind::BLOCK],
67            dotted: false,
68        },
69    ),
70    (
71        yog_ids::kind::BLOCK,
72        GameIds::Lang {
73            kinds: &[yog_ids::kind::BLOCK],
74            dotted: false,
75        },
76    ),
77    (
78        yog_ids::kind::ENTITY,
79        GameIds::Lang {
80            kinds: &[yog_ids::kind::ENTITY],
81            dotted: false,
82        },
83    ),
84    (
85        yog_ids::kind::EFFECT,
86        GameIds::Lang {
87            kinds: &[yog_ids::kind::EFFECT],
88            dotted: false,
89        },
90    ),
91    (
92        yog_ids::kind::ATTRIBUTE,
93        GameIds::LangPrefix(ATTRIBUTE_LANG_PREFIX),
94    ),
95    (
96        yog_ids::kind::BIOME,
97        GameIds::Lang {
98            kinds: &[yog_ids::kind::BIOME],
99            dotted: false,
100        },
101    ),
102    (
103        ENCHANTMENT,
104        GameIds::Lang {
105            kinds: &[ENCHANTMENT],
106            dotted: false,
107        },
108    ),
109    (yog_ids::kind::LOOT_TABLE, GameIds::Data),
110    (yog_ids::kind::ADVANCEMENT, GameIds::Data),
111    (yog_ids::kind::DAMAGE_TYPE, GameIds::Data),
112    (yog_ids::kind::RECIPE, GameIds::Data),
113    (SOUND, GameIds::Keys(SOUNDS_ASSET)),
114];
115
116/// Kinds of the game's content a reference may name that no Yog file defines.
117const ENCHANTMENT: &str = "enchantment";
118const SOUND: &str = "sound";
119
120/// The kinds whose game tags a reference may name (`tag:<kind>`).
121const GAME_TAG_KINDS: [&str; 6] = [
122    yog_ids::kind::ITEM,
123    yog_ids::kind::BLOCK,
124    yog_ids::kind::ENTITY,
125    yog_ids::kind::FLUID,
126    yog_ids::kind::BIOME,
127    yog_ids::kind::DAMAGE_TYPE,
128];
129
130/// One of the mod's own: its id and the file that defines it.
131#[derive(Debug, Clone, PartialEq, yog_data::Schema)]
132pub struct OwnRef {
133    pub id: String,
134    pub file: String,
135}
136
137/// What a reference of one kind may name, ids with their namespace.
138#[derive(Debug, Default, yog_data::Schema)]
139pub struct RefChoices {
140    pub own: Vec<OwnRef>,
141    /// Declared in `yog.toml`, another mod's.
142    pub external: Vec<String>,
143    /// The game's (`minecraft:…`); empty without a client jar.
144    pub game: Vec<String>,
145}
146
147/// One of the mod's `.ydef`s: its id, its file and what it says.
148#[derive(Debug, yog_data::Schema)]
149pub struct OwnDef {
150    pub id: String,
151    pub file: String,
152    pub value: Value,
153}
154
155fn files(root: &Path, format: Format) -> Vec<PathBuf> {
156    crate::files::of(&root.join(yog_ids::project::CONTENT), format)
157}
158
159/// A content file that's one object.
160fn read(file: &Path) -> Option<Value> {
161    yog_data::text::parse_value(&crate::files::read_source(file).ok()?)
162        .ok()
163        .filter(|v| matches!(v, Value::Map(_)))
164}
165
166/// The choices for `@<kind>:` in the file at `file` (`kind` may also be a
167/// body format, `ymodel`/`yanim`, or `tag:<kind>` — the tags of a kind:
168/// the mod's own `.ydef` tags of it, the game's).
169pub fn refs(file: &Path, kind: &str) -> RefChoices {
170    let mut out = RefChoices::default();
171    if let Some(root) = mod_root(file) {
172        out.own = own(&root, kind);
173        out.external = external(&root, kind);
174    }
175    if body_format(kind).is_none() {
176        out.game = game(mod_root(file).as_deref(), kind);
177    }
178    out
179}
180
181/// `kind` as a body format (`ymodel`, `yanim`).
182fn body_format(kind: &str) -> Option<Format> {
183    Format::of(kind).filter(|f| BODY_FORMATS.contains(f))
184}
185
186fn own(root: &Path, kind: &str) -> Vec<OwnRef> {
187    let ns = crate::design::project::mod_id(root).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
188    let mut out = Vec::new();
189    let tag_of = format!("{}{}", yog_ids::typed::TAG, yog_ids::sep::NAMESPACE);
190    if let Some(of) = kind.strip_prefix(tag_of.as_str()) {
191        for d in defs(root, yog_ids::kind::TAG) {
192            if d.value
193                .get(yog_ids::content_field::OF)
194                .and_then(Value::as_str)
195                == Some(of)
196            {
197                out.push(OwnRef {
198                    id: d.id,
199                    file: d.file,
200                });
201            }
202        }
203        return out;
204    }
205    // Files named by their path: body files under content/, resources under resources/.
206    let by_path = body_format(kind)
207        .map(|f| (yog_ids::project::CONTENT, f))
208        .or_else(|| {
209            RESOURCE_KINDS
210                .iter()
211                .find(|(k, _)| *k == kind)
212                .map(|(_, f)| (yog_ids::project::RESOURCES, *f))
213        });
214    if let Some((dir, format)) = by_path {
215        let dir = root.join(dir);
216        for f in crate::files::of(&dir, format) {
217            let rel = f.strip_prefix(&dir).unwrap_or(&f).with_extension("");
218            out.push(OwnRef {
219                id: yog_ids::join(
220                    &ns,
221                    &rel.to_string_lossy().replace('\\', yog_ids::sep::PATH),
222                ),
223                file: f.to_string_lossy().into_owned(),
224            });
225        }
226        return out;
227    }
228    if let Some((_, exts)) = FILE_KINDS.iter().find(|(k, _)| *k == kind) {
229        for format in *exts {
230            for f in files(root, *format) {
231                let given = read(&f).and_then(|d| {
232                    d.get(yog_ids::content_field::ID)
233                        .and_then(Value::as_str)
234                        .map(str::to_string)
235                });
236                let id = given.unwrap_or_else(|| {
237                    f.file_stem()
238                        .unwrap_or_default()
239                        .to_string_lossy()
240                        .into_owned()
241                });
242                out.push(OwnRef {
243                    id: yog_ids::qualify_in(&ns, &id),
244                    file: f.to_string_lossy().into_owned(),
245                });
246            }
247        }
248    } else {
249        out = defs(root, kind)
250            .into_iter()
251            .map(|d| OwnRef {
252                id: d.id,
253                file: d.file,
254            })
255            .collect();
256    }
257    out.sort_by(|a, b| a.id.cmp(&b.id));
258    out.dedup_by(|a, b| a.id == b.id);
259    out
260}
261
262/// The mod's `.ydef`s of `kind`, in file order.
263fn defs(root: &Path, kind: &str) -> Vec<OwnDef> {
264    let ns = crate::design::project::mod_id(root).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
265    let mut out = Vec::new();
266    for f in files(root, Format::Def) {
267        let Some(doc) = read(&f) else { continue };
268        if doc
269            .get(yog_ids::content_field::KIND)
270            .and_then(Value::as_str)
271            != Some(kind)
272        {
273            continue;
274        }
275        let Some(id) = doc.get(yog_ids::content_field::ID).and_then(Value::as_str) else {
276            continue;
277        };
278        out.push(OwnDef {
279            id: yog_ids::qualify_in(&ns, id),
280            file: f.to_string_lossy().into_owned(),
281            value: doc,
282        });
283    }
284    out
285}
286
287/// The mod's `.ydef`s of `kind` beside the file at `file`.
288pub fn own_defs(file: &Path, kind: &str) -> Vec<OwnDef> {
289    mod_root(file).map(|r| defs(&r, kind)).unwrap_or_default()
290}
291
292fn external(root: &Path, kind: &str) -> Vec<String> {
293    let Some(kind) = crate::content::refs::RefKind::from_word(kind) else {
294        return Vec::new();
295    };
296    let Ok(manifest) = crate::manifest::YogToml::read(&project::path(root)) else {
297        return Vec::new();
298    };
299    let mut out = Vec::new();
300    for external in &manifest.externals {
301        for (_, ids) in external.ids.iter().filter(|(k, _)| *k == kind) {
302            out.extend(
303                ids.iter()
304                    .map(|id| yog_ids::qualify_in(&external.namespace, id)),
305            );
306        }
307    }
308    out
309}
310
311/// The game's ids of `kind`, from the client jar (cached per jar).
312fn game(root: Option<&Path>, kind: &str) -> Vec<String> {
313    let tag_of = format!("{}{}", yog_ids::typed::TAG, yog_ids::sep::NAMESPACE);
314    let tags = kind
315        .strip_prefix(tag_of.as_str())
316        .filter(|of| GAME_TAG_KINDS.contains(of))
317        .map(yog_ids::registry_dirs);
318    let how = GAME_IDS.iter().find(|(k, _)| *k == kind).map(|(_, h)| h);
319    if tags.is_none() && how.is_none() {
320        return Vec::new();
321    }
322    let Some(vanilla) = Resources::for_mod(root).vanilla else {
323        return Vec::new();
324    };
325    type Cache = Mutex<HashMap<(PathBuf, String), Vec<String>>>;
326    static CACHE: OnceLock<Cache> = OnceLock::new();
327    let key = (vanilla.jar.clone(), kind.to_string());
328    if let Some(hit) = CACHE
329        .get_or_init(Default::default)
330        .lock()
331        .get(&key)
332        .cloned()
333    {
334        return hit;
335    }
336    let mut out: Vec<String> = match (tags, how) {
337        (Some(dirs), _) => data_ids(
338            &vanilla,
339            dirs.iter()
340                .map(|d| format!("{}/{d}", yog_ids::pack::TAGS))
341                .collect(),
342        ),
343        (None, None) => Vec::new(),
344        (None, Some(GameIds::Lang { kinds, dotted })) => {
345            let prefixes = kinds
346                .iter()
347                .map(|k| yog_ids::lang_prefix(k, yog_ids::ns::MINECRAFT))
348                .collect();
349            lang_ids(&vanilla, kind, prefixes, *dotted)
350        }
351        (None, Some(GameIds::LangPrefix(prefix))) => {
352            lang_ids(&vanilla, kind, vec![prefix.to_string()], true)
353        }
354        (None, Some(GameIds::Data)) => data_ids(&vanilla, yog_ids::registry_dirs(kind)),
355        (None, Some(GameIds::Keys(asset))) => {
356            asset_keys(&vanilla, &format!("{}/{asset}", yog_ids::ns::MINECRAFT))
357                .keys()
358                .map(|k| game_id(k))
359                .collect()
360        }
361    };
362    out.sort();
363    out.dedup();
364    CACHE
365        .get_or_init(Default::default)
366        .lock()
367        .insert(key, out.clone());
368    out
369}
370
371/// `path` as the game's id.
372fn game_id(path: &str) -> String {
373    yog_ids::join(yog_ids::ns::MINECRAFT, path)
374}
375
376/// The keys of the jar's JSON asset `asset` (the first of the jar's copies that reads).
377fn asset_keys(vanilla: &Vanilla, asset: &str) -> serde_json::Map<String, serde_json::Value> {
378    vanilla
379        .read_all(asset)
380        .into_iter()
381        .find_map(|b| serde_json::from_slice::<serde_json::Map<String, serde_json::Value>>(&b).ok())
382        .unwrap_or_default()
383}
384
385/// The `.json` files under the game's `data/minecraft/<dir>/`, as ids.
386fn data_ids(vanilla: &Vanilla, dirs: Vec<String>) -> Vec<String> {
387    dirs.iter()
388        .flat_map(|d| {
389            vanilla.names_under(&format!(
390                "{}/{}/{d}/",
391                yog_ids::pack::DATA,
392                yog_ids::ns::MINECRAFT
393            ))
394        })
395        .filter_map(|n| n.strip_suffix(yog_ids::res::JSON).map(game_id))
396        .collect()
397}
398
399/// The ids the game's lang keys under `prefixes` name (`dotted`: the rest
400/// may hold dots); for items, a potion's key is Yog's alias of it.
401fn lang_ids(vanilla: &Vanilla, kind: &str, prefixes: Vec<String>, dotted: bool) -> Vec<String> {
402    let lang = format!(
403        "{}/{}",
404        yog_ids::ns::MINECRAFT,
405        yog_ids::lang_file(yog_ids::game::LOCALE)
406    );
407    asset_keys(vanilla, &lang)
408        .keys()
409        .filter_map(|k| prefixes.iter().find_map(|p| k.strip_prefix(p.as_str())))
410        .filter(|rest| rest.starts_with(POTION_LANG_PREFIX) || dotted || !rest.contains('.'))
411        .map(|rest| {
412            match rest
413                .strip_prefix(POTION_LANG_PREFIX)
414                .filter(|_| kind == yog_ids::kind::ITEM)
415            {
416                // `item.minecraft.potion.effect.swiftness` → `yog:potion/swiftness`.
417                Some(name) => format!("{}{name}", yog_ids::yog::POTION_ALIAS),
418                None => game_id(rest),
419            }
420        })
421        .collect()
422}
423
424/// The game's kinds of block (`kind: "stairs"` on a `.yblock`): their
425/// names, texture slots, the `block` fields they take and their defaults —
426/// as `yog-formats` has them.
427pub fn block_kinds() -> Value {
428    use yog_data::Schema;
429    Value::List(
430        yog_formats::content::BlockKind::built_ins()
431            .iter()
432            .map(Schema::write)
433            .collect(),
434    )
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    fn write(root: &Path, rel: &str, text: &str) {
442        let p = root.join(rel);
443        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
444        std::fs::write(p, text).unwrap();
445    }
446
447    #[test]
448    fn a_mods_own_content_external_ids_and_defs() {
449        let dir = std::env::temp_dir().join(format!("yog-idle-refs-{}", std::process::id()));
450        let _ = std::fs::remove_dir_all(&dir);
451        write(
452            &dir,
453            "yog.toml",
454            "[mod]\nid = \"mymod\"\n\n[external.create]\nentities = [\"seat\"]\n",
455        );
456        write(&dir, "content/items/wand.yitem", "{ name: \"Wand\" }");
457        write(
458            &dir,
459            "content/items/potion.yitem",
460            "{ item: { potion: { effects: [] } } }",
461        );
462        write(
463            &dir,
464            "content/blocks/lamp.yblock",
465            "// a lamp\n{ id: \"glow_lamp\" }",
466        );
467        write(
468            &dir,
469            "content/mobs/wisp.yentity",
470            "{ kind: \"mob\", width: 0.5, height: 0.5 }",
471        );
472        write(
473            &dir,
474            "content/body/wisp.ymodel",
475            "{ texture: \"\", bones: [] }",
476        );
477        write(
478            &dir,
479            "content/fx/glow.ydef",
480            "{ kind: \"effect\", id: \"glow\", category: \"beneficial\", color: 255 }",
481        );
482        write(
483            &dir,
484            "content/fx/dim.ydef",
485            "{ kind: \"attribute\", id: \"dim\", default: 1, min: 0, max: 2 }",
486        );
487        write(
488            &dir,
489            "content/tags/plants.ydef",
490            "{ kind: \"tag\", of: \"block\", id: \"plants\", values: [] }",
491        );
492        let probe = dir.join("content/items/wand.yitem");
493        let ids = |kind: &str| {
494            refs(&probe, kind)
495                .own
496                .into_iter()
497                .map(|r| r.id)
498                .collect::<Vec<_>>()
499        };
500        assert_eq!(
501            ids("item"),
502            ["mymod:glow_lamp", "mymod:potion", "mymod:wand"],
503            "items include potion-only .yitems and a block's item form"
504        );
505        assert_eq!(ids("block"), ["mymod:glow_lamp"]);
506        assert_eq!(ids("entity"), ["mymod:wisp"]);
507        assert_eq!(ids("ymodel"), ["mymod:body/wisp"]);
508        assert_eq!(ids("effect"), ["mymod:glow"]);
509        assert_eq!(refs(&probe, "entity").external, ["create:seat"]);
510        assert_eq!(ids("tag:block"), ["mymod:plants"]);
511        assert!(ids("tag:item").is_empty());
512        let effects = own_defs(&probe, "effect");
513        assert_eq!(effects.len(), 1);
514        assert_eq!(effects[0].value.get("color"), Some(&Value::Int(255)));
515        std::fs::remove_dir_all(&dir).unwrap();
516    }
517
518    #[test]
519    fn the_games_block_kinds_are_listed() {
520        let kinds = block_kinds();
521        let names: Vec<&str> = kinds
522            .as_list()
523            .unwrap()
524            .iter()
525            .filter_map(|k| k.get("name").and_then(Value::as_str))
526            .collect();
527        assert!(
528            names.contains(&"stairs") && names.contains(&"pressure_plate"),
529            "{names:?}"
530        );
531    }
532}