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 yog_ids::Format;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Mutex, OnceLock};
13
14use serde::Serialize;
15use serde_json::Value;
16
17use crate::design::assets::Resources;
18use crate::design::project::{self, mod_root};
19
20/// Kinds of content defined by files of their own, by extension; any other
21/// kind is the `.ydef`s of that `kind`.
22const FILE_KINDS: [(&str, &[Format]); 4] = [
23    (yog_ids::kind::ITEM, &[Format::Item, Format::Block]),
24    (yog_ids::kind::BLOCK, &[Format::Block]),
25    (yog_ids::kind::ENTITY, &[Format::Entity]),
26    (yog_ids::kind::RECIPE, &[Format::Recipe]),
27];
28
29/// Body files (`.ymodel`, `.yanim`): named by their path under `content/`
30/// without the extension (`body/wings`), as an entity's `model` names them.
31const BODY_FORMATS: [Format; 2] = [Format::Model, Format::Anim];
32
33/// Resources defined by files of their own under `resources/`, named by
34/// their path there without the extension (`block/glass`).
35const RESOURCE_KINDS: [(&str, Format); 2] = [(yog_ids::kind::TEXTURE, Format::Texture), (yog_ids::kind::MATERIAL, Format::Material)];
36
37/// What `[external.<namespace>]` lists each kind under.
38const EXTERNAL_KEYS: [(&str, &str); 4] = [
39    (yog_ids::kind::ITEM, yog_ids::external::ITEMS),
40    (yog_ids::kind::BLOCK, yog_ids::external::BLOCKS),
41    (yog_ids::kind::ENTITY, yog_ids::external::ENTITIES),
42    (yog_ids::kind::RECIPE, yog_ids::external::RECIPES),
43];
44
45/// Where the game's ids of a kind are in its client jar: translation keys
46/// of its lang (`entity.minecraft.pig` → `pig`; `dotted`: the rest may hold
47/// dots, `generic.max_health`), files of its data folders (by kind), or the
48/// keys of one of its assets (`sounds.json`: sound events).
49enum GameIds {
50    /// Keys starting with the game's `<kind>.minecraft.` of each of these kinds.
51    Lang { kinds: &'static [&'static str], dotted: bool },
52    /// A prefix of its own (`attribute.name.`).
53    LangPrefix(&'static str),
54    Data,
55    Keys(&'static str),
56}
57
58/// The game's sound events: the keys of its `sounds.json`.
59const SOUNDS_ASSET: &str = "sounds.json";
60/// Attributes' translation keys: `attribute.name.generic.max_health`.
61const ATTRIBUTE_LANG_PREFIX: &str = "attribute.name.";
62/// The game's potions' item names: `item.minecraft.potion.effect.<name>`, a Yog potion alias.
63const POTION_LANG_PREFIX: &str = "potion.effect.";
64
65const GAME_IDS: [(&str, GameIds); 12] = [
66    (yog_ids::kind::ITEM, GameIds::Lang { kinds: &[yog_ids::kind::ITEM, yog_ids::kind::BLOCK], dotted: false }),
67    (yog_ids::kind::BLOCK, GameIds::Lang { kinds: &[yog_ids::kind::BLOCK], dotted: false }),
68    (yog_ids::kind::ENTITY, GameIds::Lang { kinds: &[yog_ids::kind::ENTITY], dotted: false }),
69    (yog_ids::kind::EFFECT, GameIds::Lang { kinds: &[yog_ids::kind::EFFECT], dotted: false }),
70    (yog_ids::kind::ATTRIBUTE, GameIds::LangPrefix(ATTRIBUTE_LANG_PREFIX)),
71    (yog_ids::kind::BIOME, GameIds::Lang { kinds: &[yog_ids::kind::BIOME], dotted: false }),
72    (ENCHANTMENT, GameIds::Lang { kinds: &[ENCHANTMENT], dotted: false }),
73    (yog_ids::kind::LOOT_TABLE, GameIds::Data),
74    (yog_ids::kind::ADVANCEMENT, GameIds::Data),
75    (yog_ids::kind::DAMAGE_TYPE, GameIds::Data),
76    (yog_ids::kind::RECIPE, GameIds::Data),
77    (SOUND, GameIds::Keys(SOUNDS_ASSET)),
78];
79
80/// Kinds of the game's content a reference may name that no Yog file defines.
81const ENCHANTMENT: &str = "enchantment";
82const SOUND: &str = "sound";
83
84/// The kinds whose game tags a reference may name (`tag:<kind>`).
85const GAME_TAG_KINDS: [&str; 6] = [yog_ids::kind::ITEM, yog_ids::kind::BLOCK, yog_ids::kind::ENTITY, yog_ids::kind::FLUID, yog_ids::kind::BIOME, yog_ids::kind::DAMAGE_TYPE];
86
87/// One of the mod's own: its id and the file that defines it.
88#[derive(Serialize, Debug, Clone, PartialEq)]
89pub struct OwnRef {
90    pub id: String,
91    pub file: String,
92}
93
94/// What a reference of one kind may name, ids with their namespace.
95#[derive(Serialize, Debug, Default)]
96pub struct RefChoices {
97    pub own: Vec<OwnRef>,
98    /// Declared in `yog.toml`, another mod's.
99    pub external: Vec<String>,
100    /// The game's (`minecraft:…`); empty without a client jar.
101    pub game: Vec<String>,
102}
103
104/// One of the mod's `.ydef`s: its id, its file and what it says.
105#[derive(Serialize, Debug)]
106pub struct OwnDef {
107    pub id: String,
108    pub file: String,
109    pub value: Value,
110}
111
112fn files(root: &Path, format: Format) -> Vec<PathBuf> {
113    crate::design::files::of(&root.join("content"), format)
114}
115
116fn read(file: &Path) -> Option<serde_json::Map<String, Value>> {
117    crate::design::data::read_object(&std::fs::read_to_string(file).ok()?)
118}
119
120/// The choices for `@<kind>:` in the file at `file` (`kind` may also be a
121/// body format, `ymodel`/`yanim`, or `tag:<kind>` — the tags of a kind:
122/// the mod's own `.ydef` tags of it, the game's).
123pub fn refs(file: &Path, kind: &str) -> RefChoices {
124    let mut out = RefChoices::default();
125    if let Some(root) = mod_root(file) {
126        out.own = own(&root, kind);
127        out.external = external(&root, kind);
128    }
129    if body_format(kind).is_none() {
130        out.game = game(mod_root(file).as_deref(), kind);
131    }
132    out
133}
134
135/// `kind` as a body format (`ymodel`, `yanim`).
136fn body_format(kind: &str) -> Option<Format> {
137    Format::of(kind).filter(|f| BODY_FORMATS.contains(f))
138}
139
140fn own(root: &Path, kind: &str) -> Vec<OwnRef> {
141    let ns = crate::design::project::mod_id(root).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
142    let mut out = Vec::new();
143    let tag_of = format!("{}{}", yog_ids::typed::TAG, yog_ids::sep::NAMESPACE);
144    if let Some(of) = kind.strip_prefix(tag_of.as_str()) {
145        for d in defs(root, yog_ids::kind::TAG) {
146            if d.value.get(yog_ids::content_field::OF).and_then(Value::as_str) == Some(of) {
147                out.push(OwnRef { id: d.id, file: d.file });
148            }
149        }
150        return out;
151    }
152    // Files named by their path: body files under content/, resources under resources/.
153    let by_path = body_format(kind).map(|f| (yog_ids::project::CONTENT, f)).or_else(|| {
154        RESOURCE_KINDS.iter().find(|(k, _)| *k == kind).map(|(_, f)| (yog_ids::project::RESOURCES, *f))
155    });
156    if let Some((dir, format)) = by_path {
157        let dir = root.join(dir);
158        for f in crate::design::files::of(&dir, format) {
159            let rel = f.strip_prefix(&dir).unwrap_or(&f).with_extension("");
160            out.push(OwnRef { id: yog_ids::join(&ns, &rel.to_string_lossy().replace('\\', yog_ids::sep::PATH)), file: f.to_string_lossy().into_owned() });
161        }
162        return out;
163    }
164    if let Some((_, exts)) = FILE_KINDS.iter().find(|(k, _)| *k == kind) {
165        for format in *exts {
166            for f in files(root, *format) {
167                let given = read(&f).and_then(|d| d.get(yog_ids::content_field::ID).and_then(Value::as_str).map(str::to_string));
168                let id = given.unwrap_or_else(|| f.file_stem().unwrap_or_default().to_string_lossy().into_owned());
169                out.push(OwnRef { id: yog_ids::qualify_in(&ns, &id), file: f.to_string_lossy().into_owned() });
170            }
171        }
172    } else {
173        out = defs(root, kind).into_iter().map(|d| OwnRef { id: d.id, file: d.file }).collect();
174    }
175    out.sort_by(|a, b| a.id.cmp(&b.id));
176    out.dedup_by(|a, b| a.id == b.id);
177    out
178}
179
180/// The mod's `.ydef`s of `kind`, in file order.
181fn defs(root: &Path, kind: &str) -> Vec<OwnDef> {
182    let ns = crate::design::project::mod_id(root).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
183    let mut out = Vec::new();
184    for f in files(root, Format::Def) {
185        let Some(doc) = read(&f) else { continue };
186        if doc.get(yog_ids::content_field::KIND).and_then(Value::as_str) != Some(kind) {
187            continue;
188        }
189        let Some(id) = doc.get("id").and_then(Value::as_str) else { continue };
190        out.push(OwnDef { id: yog_ids::qualify_in(&ns, id), file: f.to_string_lossy().into_owned(), value: Value::Object(doc) });
191    }
192    out
193}
194
195/// The mod's `.ydef`s of `kind` beside the file at `file`.
196pub fn own_defs(file: &Path, kind: &str) -> Vec<OwnDef> {
197    mod_root(file).map(|r| defs(&r, kind)).unwrap_or_default()
198}
199
200fn external(root: &Path, kind: &str) -> Vec<String> {
201    let Some(key) = EXTERNAL_KEYS.iter().find(|(k, _)| *k == kind).map(|(_, k)| *k) else { return Vec::new() };
202    let Ok(manifest) = crate::manifest::YogToml::read(&project::path(root)) else { return Vec::new() };
203    let mut out = Vec::new();
204    for external in &manifest.externals {
205        for (_, ids) in external.ids.iter().filter(|(k, _)| k == key) {
206            out.extend(ids.iter().map(|id| yog_ids::qualify_in(&external.namespace, id)));
207        }
208    }
209    out
210}
211
212/// The game's ids of `kind`, from the client jar (cached per jar).
213fn game(root: Option<&Path>, kind: &str) -> Vec<String> {
214    let tag_of = format!("{}{}", yog_ids::typed::TAG, yog_ids::sep::NAMESPACE);
215    let tags = kind.strip_prefix(tag_of.as_str()).filter(|of| GAME_TAG_KINDS.contains(of)).map(yog_ids::registry_dirs);
216    let how = GAME_IDS.iter().find(|(k, _)| *k == kind).map(|(_, h)| h);
217    if tags.is_none() && how.is_none() {
218        return Vec::new();
219    }
220    let Some(vanilla) = Resources::for_mod(root).vanilla else { return Vec::new() };
221    type Cache = Mutex<HashMap<(PathBuf, String), Vec<String>>>;
222    static CACHE: OnceLock<Cache> = OnceLock::new();
223    let key = (vanilla.jar.clone(), kind.to_string());
224    if let Some(hit) = CACHE.get_or_init(Default::default).lock().ok().and_then(|c| c.get(&key).cloned()) {
225        return hit;
226    }
227    let keys = |asset: &str| {
228        vanilla
229            .read_all(asset)
230            .into_iter()
231            .find_map(|b| serde_json::from_slice::<serde_json::Map<String, Value>>(&b).ok())
232            .unwrap_or_default()
233    };
234    let game = |path: &str| yog_ids::join(yog_ids::ns::MINECRAFT, path);
235    // The `.json` files under `data/minecraft/<dir>/`, as ids.
236    let data_ids = |dirs: Vec<String>| -> Vec<String> {
237        dirs.iter()
238            .flat_map(|d| vanilla.names_under(&format!("{}/{}/{d}/", yog_ids::pack::DATA, yog_ids::ns::MINECRAFT)))
239            .filter_map(|n| n.strip_suffix(yog_ids::res::JSON).map(game))
240            .collect()
241    };
242    let lang_ids = |prefixes: Vec<String>, dotted: bool| -> Vec<String> {
243        keys(&format!("{}/{}", yog_ids::ns::MINECRAFT, yog_ids::lang_file(yog_ids::game::LOCALE)))
244            .keys()
245            .filter_map(|k| prefixes.iter().find_map(|p| k.strip_prefix(p.as_str())))
246            .filter(|rest| rest.starts_with(POTION_LANG_PREFIX) || dotted || !rest.contains('.'))
247            .map(|rest| match rest.strip_prefix(POTION_LANG_PREFIX).filter(|_| kind == yog_ids::kind::ITEM) {
248                // `item.minecraft.potion.effect.swiftness` → `yog:potion/swiftness`.
249                Some(name) => format!("{}{name}", yog_ids::yog::POTION_ALIAS),
250                None => game(rest),
251            })
252            .collect()
253    };
254    let mut out: Vec<String> = match (tags, how) {
255        (Some(dirs), _) => data_ids(dirs.iter().map(|d| format!("{}/{d}", yog_ids::pack::TAGS)).collect()),
256        (None, None) => Vec::new(),
257        (None, Some(GameIds::Lang { kinds, dotted })) => lang_ids(kinds.iter().map(|k| yog_ids::lang_prefix(k, yog_ids::ns::MINECRAFT)).collect(), *dotted),
258        (None, Some(GameIds::LangPrefix(prefix))) => lang_ids(vec![prefix.to_string()], true),
259        (None, Some(GameIds::Data)) => data_ids(yog_ids::registry_dirs(kind)),
260        (None, Some(GameIds::Keys(asset))) => keys(&format!("{}/{asset}", yog_ids::ns::MINECRAFT)).keys().map(|k| game(k)).collect(),
261    };
262    out.sort();
263    out.dedup();
264    if let Ok(mut c) = CACHE.get_or_init(Default::default).lock() {
265        c.insert(key, out.clone());
266    }
267    out
268}
269
270/// The game's kinds of block (`kind: "stairs"` on a `.yblock`): their
271/// names, texture slots, the `block` fields they take and their defaults —
272/// as `yog-formats` has them.
273pub fn block_kinds() -> Value {
274    use yog_data::Schema;
275    Value::Array(yog_formats::content::BlockKind::built_ins().iter().map(|k| crate::design::data::to_json(&k.write())).collect())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn write(root: &Path, rel: &str, text: &str) {
283        let p = root.join(rel);
284        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
285        std::fs::write(p, text).unwrap();
286    }
287
288    #[test]
289    fn a_mods_own_content_external_ids_and_defs() {
290        let dir = std::env::temp_dir().join(format!("yog-idle-refs-{}", std::process::id()));
291        let _ = std::fs::remove_dir_all(&dir);
292        write(&dir, "yog.toml", "[mod]\nid = \"mymod\"\n\n[external.create]\nentities = [\"seat\"]\n");
293        write(&dir, "content/items/wand.yitem", "{ name: \"Wand\" }");
294        write(&dir, "content/items/potion.yitem", "{ item: { potion: { effects: [] } } }");
295        write(&dir, "content/blocks/lamp.yblock", "// a lamp\n{ id: \"glow_lamp\" }");
296        write(&dir, "content/mobs/wisp.yentity", "{ kind: \"mob\", width: 0.5, height: 0.5 }");
297        write(&dir, "content/body/wisp.ymodel", "{ texture: \"\", bones: [] }");
298        write(&dir, "content/fx/glow.ydef", "{ kind: \"effect\", id: \"glow\", category: \"beneficial\", color: 255 }");
299        write(&dir, "content/fx/dim.ydef", "{ kind: \"attribute\", id: \"dim\", default: 1, min: 0, max: 2 }");
300        write(&dir, "content/tags/plants.ydef", "{ kind: \"tag\", of: \"block\", id: \"plants\", values: [] }");
301        let probe = dir.join("content/items/wand.yitem");
302        let ids = |kind: &str| refs(&probe, kind).own.into_iter().map(|r| r.id).collect::<Vec<_>>();
303        assert_eq!(ids("item"), ["mymod:glow_lamp", "mymod:potion", "mymod:wand"], "items include potion-only .yitems and a block's item form");
304        assert_eq!(ids("block"), ["mymod:glow_lamp"]);
305        assert_eq!(ids("entity"), ["mymod:wisp"]);
306        assert_eq!(ids("ymodel"), ["mymod:body/wisp"]);
307        assert_eq!(ids("effect"), ["mymod:glow"]);
308        assert_eq!(refs(&probe, "entity").external, ["create:seat"]);
309        assert_eq!(ids("tag:block"), ["mymod:plants"]);
310        assert!(ids("tag:item").is_empty());
311        let effects = own_defs(&probe, "effect");
312        assert_eq!(effects.len(), 1);
313        assert_eq!(effects[0].value["color"], 255);
314        std::fs::remove_dir_all(&dir).unwrap();
315    }
316
317    #[test]
318    fn the_games_block_kinds_are_listed() {
319        let kinds = block_kinds();
320        let names: Vec<&str> = kinds.as_array().unwrap().iter().filter_map(|k| k["name"].as_str()).collect();
321        assert!(names.contains(&"stairs") && names.contains(&"pressure_plate"), "{names:?}");
322    }
323}