use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use crate::content::Ctx;
use crate::content::Recipe;
use crate::content::RecipeKind;
use crate::content::refs;
use crate::content::valid_namespace;
use crate::content::valid_path;
pub(super) fn compile_recipe(
cx: &mut Ctx,
text: &str,
path: &Path,
mod_ns: Option<&str>,
known: &refs::Known,
kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
brews: &mut Vec<(String, yog_registry::Brew)>,
) -> Option<Recipe> {
use yog_formats::recipe::{Body, RecipeFile};
use yog_data::Path as At;
let doc = match yog_data::text::parse(text) {
Ok(d) => d,
Err(e) => {
cx.err(e.at.line as usize, e.message);
return None;
}
};
let line = |p: &At| doc.locate(text, p).line as usize;
let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
Ok(r) => r,
Err(errors) => {
for e in errors.0 {
let l = e.at.map_or(1, |a| a.line as usize);
if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
}
return None;
}
};
let mut ok = true;
for f in &read.refs {
if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
&& let Err(e) = known.check(kind, &f.value.id)
{
cx.err(line(&f.path), format!("{}: {e}", f.path));
ok = false;
}
}
for f in &read.tags {
if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
cx.err(line(&f.path), format!("{}: {e}", f.path));
ok = false;
}
}
if ok {
for (missing, why) in recipe_gaps(&read.refs, &read.tags) {
cx.warn(line(&At::root()), format!("left out on {}: {why}", refs::describe_hosts(&missing)));
}
}
let RecipeFile { common: c, body } = read.value;
let ty = body.kind().to_string();
let root = At::root();
if let Some(f) = c.format
&& f != 1
{
cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
ok = false;
}
let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
let id_line = line(&root.key("id"));
let (ns, p) = match raw.split_once(':') {
Some((ns, p)) => (ns.to_string(), p.to_string()),
None => match mod_ns {
Some(ns) => (ns.to_string(), raw.clone()),
None => {
cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
return None;
}
},
};
if !valid_namespace(&ns) || !valid_path(&p) {
cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
return None;
}
if !(1..=64).contains(&c.count) {
cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
ok = false;
}
let kind = match body {
Body::Shaped(s) => {
let width = s.pattern.first().map_or(0, |r| r.chars().count());
if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
return None;
}
let mut key = Vec::new();
for (k, ing) in &s.key.0 {
let at = line(&root.key("key").key(k));
let mut chars = k.chars();
match (chars.next(), chars.next()) {
(Some(ch), None) if ch != ' ' => {
if !s.pattern.iter().any(|r| r.contains(ch)) {
cx.warn(at, format!("key {k:?} isn't used by the pattern"));
}
key.push((ch, ing.ids()));
}
_ => {
cx.err(at, format!("key {k:?}: one character other than space"));
ok = false;
}
}
}
for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
return None;
}
}
RecipeKind::Shaped { pattern: s.pattern, key }
}
Body::Shapeless(s) => {
if s.ingredients.is_empty() || s.ingredients.len() > yog_formats::recipe::MAX_SHAPELESS {
cx.err(line(&root.key("ingredients")), format!("ingredients must be 1–{} items or tags", yog_formats::recipe::MAX_SHAPELESS));
return None;
}
RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
}
Body::Cooking(ty, k) => {
use yog_formats::recipe::{COOK_TIME, EXPERIENCE};
let kind = yog_registry::recipe_kinds::game_kind(&ty).expect("a cooking body is one of the game's kinds");
let method = yog_registry::Cooking::of_kind(&ty).expect("a cooking body is a cooking kind");
if !EXPERIENCE.contains(&k.experience) {
cx.err(line(&root.key("experience")), format!("experience: {}–{}", EXPERIENCE.start(), EXPERIENCE.end()));
ok = false;
}
let default_time = kind.default_of("cook_time").map_or(*COOK_TIME.start(), |t| t as u32);
let cook_time = k.cook_time.unwrap_or(default_time);
if !COOK_TIME.contains(&cook_time) {
cx.err(line(&root.key("cook_time")), format!("cook_time: {}–{} ticks", COOK_TIME.start(), COOK_TIME.end()));
ok = false;
}
RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
}
Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
Body::Brewing(k) => {
if c.count != 1 {
cx.err(line(&root.key("count")), "brewing recipes make one potion");
ok = false;
}
if c.category.is_some() {
cx.err(line(&root.key("category")), "brewing recipes don't have a recipe-book category");
ok = false;
}
if ok {
brews.push((c.result.id.clone(), yog_registry::Brew { from: k.from, with: k.ingredient }));
}
return None;
}
Body::Custom(kind, k) => {
let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
if !own {
cx.err(
line(&root.key("type")),
format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", yog_formats::recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
);
return None;
}
if c.category.is_some() {
cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
ok = false;
}
let Some(def) = kinds.get(&kind) else {
let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
cx.err(line(&root.key("type")), format!("type `{kind}`: no such kind — declare it: content/{file}.ydef, {{ kind: \"recipe_kind\", id: \"{file}\", slots: {{ … }} }}"));
return None;
};
for (name, ings) in &k.slots.0 {
let at = root.key("slots").key(name);
match def.slots.0.iter().find(|(n, _)| n == name) {
None => {
let names: Vec<&str> = def.slots.0.iter().map(|(n, _)| n.as_str()).collect();
cx.err(line(&at), format!("slots.{name}: {kind} has no slot `{name}` ({})", names.join(", ")));
ok = false;
}
Some((_, spec)) if ings.len() > spec.max as usize => {
cx.err(line(&at), format!("slots.{name}: at most {} ingredient(s), found {}", spec.max, ings.len()));
ok = false;
}
_ => {}
}
}
for (name, spec) in &def.slots.0 {
if spec.required && !k.slots.0.iter().any(|(n, ings)| n == name && !ings.is_empty()) {
cx.err(line(&root.key("slots")), format!("slots: {kind} needs `{name}`"));
ok = false;
}
}
for (name, v) in &k.values.0 {
let at = root.key("values").key(name);
let fits = match def.values.0.iter().find(|(n, _)| n == name).map(|(_, t)| *t) {
None => {
let names: Vec<&str> = def.values.0.iter().map(|(n, _)| n.as_str()).collect();
cx.err(line(&at), format!("values.{name}: {kind} has no value `{name}` ({})", names.join(", ")));
ok = false;
continue;
}
Some(yog_formats::def::ValueType::Number) => v.as_f64().is_some(),
Some(yog_formats::def::ValueType::Bool) => v.as_bool().is_some(),
Some(yog_formats::def::ValueType::Text) => v.as_str().is_some(),
};
if !fits {
cx.err(line(&at), format!("values.{name}: a {}", yog_formats::content::word(&def.values.0.iter().find(|(n, _)| n == name).unwrap().1)));
ok = false;
}
}
let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
let values = k.values.0.iter().map(|(name, v)| (name.clone(), serde_json::from_str(&yog_data::text::to_string(v)).unwrap_or(Value::Null))).collect();
RecipeKind::Custom { kind, slots, values }
}
};
ok.then(|| Recipe {
id: format!("{ns}:{p}"),
ty,
kind,
result: c.result.id.clone(),
count: c.count,
group: c.group,
category: c.category,
requires: c.requires,
})
}
pub(super) fn recipe_gaps(items: &[yog_data::schema::Found<yog_data::Ref>], tags: &[yog_data::schema::Found<yog_data::Ref>]) -> Vec<(BTreeSet<String>, String)> {
use refs::RefKind;
let mut lists: BTreeMap<String, Vec<(String, Option<BTreeSet<String>>)>> = BTreeMap::new();
let parent = |p: &yog_data::Path| {
let mut p = p.clone();
if matches!(p.segments().last(), Some(yog_data::Seg::Index(_))) {
p.pop();
}
p.to_string()
};
for f in items {
let Some(kind) = RefKind::from_word(&f.value.kind) else { continue };
let potion_alias = kind == RefKind::Item && f.value.id.starts_with("yog:potion/");
if !potion_alias && !f.value.id.starts_with("minecraft:") {
continue;
}
let on: BTreeSet<String> = refs::hosts()
.iter()
.filter(|h| {
h.split_once('/').is_some_and(|(_, v)| {
if potion_alias {
refs::is_vanilla_potion_in(&f.value.id, v)
} else {
refs::equivalent(kind, &f.value.id, v).is_some()
}
})
})
.cloned()
.collect();
let on = (on.len() < refs::hosts().len()).then_some(on);
lists.entry(parent(&f.path)).or_default().push((f.value.id.clone(), on));
}
for f in tags {
let Some(on) = refs::tag_hosts(yog_ids::registry_of(&f.value.kind), &f.value.id) else { continue };
lists.entry(parent(&f.path)).or_default().push((format!("#{}", f.value.id), on));
}
let mut out = Vec::new();
for alts in lists.values() {
if alts.iter().any(|(_, on)| on.is_none()) {
continue;
}
let on: BTreeSet<String> = alts.iter().flat_map(|(_, on)| on.iter().flatten().cloned()).collect();
let missing: BTreeSet<String> = refs::hosts().iter().filter(|h| !on.contains(*h)).cloned().collect();
if missing.is_empty() {
continue;
}
let names: Vec<&str> = alts.iter().map(|(id, _)| id.as_str()).collect();
let hint = alts
.iter()
.filter_map(|(id, _)| id.strip_prefix('#').and_then(|t| refs::yog_tag_for("item", t)))
.find(|y| refs::tag_hosts("item", y) == Some(None))
.map(|y| format!(" — @tag:item:{y} is on every version and loader"))
.unwrap_or_default();
out.push((missing, format!("{} isn't there{hint}", names.join(" / "))));
}
out
}