use serde_json::{Map, Value};
use std::collections::HashMap;
const ENUM_OP: &str = "enum";
pub type EnumLoweringError = crate::diagnostic::DiagnosticError;
use crate::diagnostic::{codes, err};
fn parse_enums_block(
value: &Value,
) -> Result<HashMap<String, HashMap<String, i64>>, EnumLoweringError> {
let Some(root) = value.as_object() else {
return Ok(HashMap::new());
};
let Some(enums_value) = root.get("enums") else {
return Ok(HashMap::new());
};
let Some(enums_obj) = enums_value.as_object() else {
return Err(err(
codes::INVALID_ENUMS_BLOCK,
"top-level `enums` must be an object",
));
};
let mut out = HashMap::new();
for (enum_name, mapping) in enums_obj {
let Some(map_obj) = mapping.as_object() else {
return Err(err(
codes::INVALID_ENUMS_BLOCK,
format!("enums.{enum_name}: entry must be an object"),
));
};
let mut m = HashMap::new();
for (sym, intval) in map_obj {
let Some(n) = intval.as_i64() else {
return Err(err(
codes::INVALID_ENUMS_BLOCK,
format!("enums.{enum_name}.{sym}: value must be an integer"),
));
};
m.insert(sym.clone(), n);
}
out.insert(enum_name.clone(), m);
}
Ok(out)
}
fn lower_enum_node(
node: &Map<String, Value>,
enums: &HashMap<String, HashMap<String, i64>>,
) -> Result<Value, EnumLoweringError> {
let args = node.get("args").and_then(|v| v.as_array()).ok_or_else(|| {
err(
codes::ENUM_INVALID_ARGS,
"`enum` op requires an `args` array of length 2",
)
})?;
if args.len() != 2 {
return Err(err(
codes::ENUM_INVALID_ARGS,
format!(
"`enum` op expects 2 args (enum_name, symbol_name), got {}",
args.len()
),
));
}
let enum_name = args[0].as_str().ok_or_else(|| {
err(
codes::ENUM_INVALID_ARGS,
"`enum` op: first arg must be a string (enum name)",
)
})?;
let symbol_name = args[1].as_str().ok_or_else(|| {
err(
codes::ENUM_INVALID_ARGS,
"`enum` op: second arg must be a string (symbol name)",
)
})?;
let mapping = enums.get(enum_name).ok_or_else(|| {
err(
codes::UNKNOWN_ENUM,
format!(
"unknown_enum: enum `{enum_name}` is not declared in the file's \
`enums` block"
),
)
})?;
let int_value = mapping.get(symbol_name).ok_or_else(|| {
err(
codes::UNKNOWN_ENUM_SYMBOL,
format!(
"unknown_enum_symbol: symbol `{symbol_name}` is not declared \
under enum `{enum_name}`"
),
)
})?;
let mut out = Map::new();
out.insert("op".to_string(), Value::String("const".to_string()));
out.insert("args".to_string(), Value::Array(Vec::new()));
out.insert("value".to_string(), Value::Number((*int_value).into()));
Ok(Value::Object(out))
}
fn walk(
node: &Value,
enums: &HashMap<String, HashMap<String, i64>>,
) -> Result<Value, EnumLoweringError> {
let mut out = node.clone();
crate::json_visit::try_visit_values_mut(&mut out, &mut |v| {
if let Value::Object(obj) = v
&& obj.get("op").and_then(|w| w.as_str()) == Some(ENUM_OP)
{
*v = lower_enum_node(obj, enums)?;
}
Ok(())
})?;
Ok(out)
}
pub(crate) fn lower_enum_ops_for_file(
document: &Value,
target: &mut Value,
open_names: &std::collections::HashSet<String>,
) -> Result<(), EnumLoweringError> {
let mut paths: Vec<String> = Vec::new();
find_enum_paths(target, &mut paths);
if paths.is_empty() {
return Ok(());
}
let enums = parse_enums_block(document)?;
crate::json_visit::try_visit_values_mut(target, &mut |v| {
if let Value::Object(obj) = v
&& obj.get("op").and_then(|w| w.as_str()) == Some(ENUM_OP)
&& let Some([Value::String(name), Value::String(symbol)]) = obj
.get("args")
.and_then(|a| a.as_array())
.map(Vec::as_slice)
&& !open_names.contains(name)
&& !open_names.contains(symbol)
{
*v = lower_enum_node(obj, &enums)?;
}
Ok(())
})
}
pub(crate) fn lower_mounted_document_enums(document: &mut Value) -> Result<(), EnumLoweringError> {
let mut source = Map::new();
if let Some(enums) = document.get("enums") {
source.insert("enums".to_string(), enums.clone());
}
lower_mounted_enum_ops(&Value::Object(source), document)
}
fn lower_mounted_enum_ops(source: &Value, node: &mut Value) -> Result<(), EnumLoweringError> {
let no_open_names = std::collections::HashSet::new();
if node.get("op").and_then(|w| w.as_str()) == Some(ENUM_OP) {
return lower_enum_ops_for_file(source, node, &no_open_names);
}
match node {
Value::Array(items) => {
for item in items {
lower_mounted_enum_ops(source, item)?;
}
}
Value::Object(obj) => {
for (key, child) in obj.iter_mut() {
if key == "expression_templates"
&& let Value::Object(templates) = child
{
for decl in templates.values_mut() {
let params: std::collections::HashSet<String> = decl
.get("params")
.and_then(|p| p.as_array())
.map(|ps| {
ps.iter()
.filter_map(|p| p.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if let Some(body) = decl.get_mut("body") {
lower_enum_ops_for_file(source, body, ¶ms)?;
}
}
} else {
lower_mounted_enum_ops(source, child)?;
}
}
}
_ => {}
}
Ok(())
}
fn find_enum_paths(view: &Value, hits: &mut Vec<String>) {
crate::json_visit::visit_values(view, &mut |path, v| {
if let Value::Object(obj) = v
&& crate::json_visit::small_get(obj, "op").and_then(|w| w.as_str()) == Some(ENUM_OP)
{
hits.push(path.to_string());
}
});
}
pub fn lower_enums(file: &crate::EsmFile) -> Result<crate::EsmFile, EnumLoweringError> {
let mut value = serde_json::to_value(file).map_err(|e| {
err(
codes::INVALID_ENUMS_BLOCK,
format!("could not render the document as JSON for enum lowering: {e}"),
)
})?;
lower_enums_raw(&mut value)?;
serde_json::from_value(value).map_err(|e| {
err(
codes::ENUM_LOWERING_RESIDUAL,
format!("lowered document did not deserialize back into an EsmFile: {e}"),
)
})
}
pub fn lower_enums_mut(file: &mut crate::EsmFile) -> Result<(), EnumLoweringError> {
*file = lower_enums(file)?;
Ok(())
}
pub fn lower_enums_raw(value: &mut Value) -> Result<(), EnumLoweringError> {
lower_enums_raw_scanned(value, true)
}
pub(crate) fn is_enum_node(value: &Value) -> bool {
matches!(value, Value::Object(obj)
if crate::json_visit::small_get(obj, "op").and_then(|w| w.as_str()) == Some(ENUM_OP))
}
pub(crate) fn lower_enums_raw_scanned(
value: &mut Value,
may_have_enum_ops: bool,
) -> Result<(), EnumLoweringError> {
let enums = parse_enums_block(value)?;
if !may_have_enum_ops {
return Ok(());
}
let mut paths: Vec<String> = Vec::new();
find_enum_paths(value, &mut paths);
if paths.is_empty() {
return Ok(());
}
let walked = walk(value, &enums)?;
*value = walked;
let mut leftover: Vec<String> = Vec::new();
find_enum_paths(value, &mut leftover);
if !leftover.is_empty() {
return Err(err(
codes::ENUM_LOWERING_RESIDUAL,
format!(
"enum-op nodes remain after lowering at: {}",
leftover.join(", ")
),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn lowered(input: Value) -> Value {
let mut v = input;
lower_enums_raw(&mut v).expect("lowering should succeed");
v
}
#[test]
fn basic_enum_replaced_with_const() {
let input = json!({
"enums": {
"season": {"winter": 1, "spring": 2, "summer": 3, "autumn": 4}
},
"models": {
"M": {
"variables": {
"s": {
"type": "unknown"}
},
"equations": [
{"lhs": "s", "rhs": {
"op": "enum",
"args": ["season", "summer"]
}}]
}
}
});
let out = lowered(input);
let expr = crate::classification::observed_definition_json(&out["models"]["M"], "s")
.expect("s defining equation");
assert_eq!(expr["op"], "const");
assert_eq!(expr["value"], 3);
assert_eq!(expr["args"], json!([]));
}
#[test]
fn nested_enum_in_index_op() {
let input = json!({
"enums": {
"season": {"summer": 3},
"land_use": {"forest": 5}
},
"models": {
"M": {
"variables": {
"r": {
"type": "unknown"}
},
"equations": [
{"lhs": "r", "rhs": {
"op": "index",
"args": [
{"op": "const", "args": [], "value": [[1, 2, 3]]},
{"op": "enum", "args": ["season", "summer"]},
{"op": "enum", "args": ["land_use", "forest"]}
]
}}]
}
}
});
let out = lowered(input);
let expr = crate::classification::observed_definition_json(&out["models"]["M"], "r")
.expect("r defining equation");
assert_eq!(expr["op"], "index");
assert_eq!(expr["args"][1]["op"], "const");
assert_eq!(expr["args"][1]["value"], 3);
assert_eq!(expr["args"][2]["op"], "const");
assert_eq!(expr["args"][2]["value"], 5);
}
#[test]
fn unknown_enum_rejected() {
let mut v = json!({
"enums": {"season": {"summer": 3}},
"models": {"M": {
"variables": {"x": {
"type": "unknown"}},
"equations": [
{"lhs": "x", "rhs": {"op": "enum", "args": ["weekday", "monday"]}}]
}}
});
let err = lower_enums_raw(&mut v).unwrap_err();
assert_eq!(err.code, "unknown_enum");
assert!(err.message.contains("weekday"));
}
#[test]
fn unknown_enum_symbol_rejected() {
let mut v = json!({
"enums": {"season": {"summer": 3}},
"models": {"M": {
"variables": {"x": {
"type": "unknown"}},
"equations": [
{"lhs": "x", "rhs": {"op": "enum", "args": ["season", "winter"]}}]
}}
});
let err = lower_enums_raw(&mut v).unwrap_err();
assert_eq!(err.code, "unknown_enum_symbol");
assert!(err.message.contains("winter"));
assert!(err.message.contains("season"));
}
#[test]
fn missing_enums_block_rejects_enum_op() {
let mut v = json!({
"models": {"M": {
"variables": {"x": {
"type": "unknown"}},
"equations": [
{"lhs": "x", "rhs": {"op": "enum", "args": ["season", "summer"]}}]
}}
});
let err = lower_enums_raw(&mut v).unwrap_err();
assert_eq!(err.code, "unknown_enum");
}
#[test]
fn wrong_arity_rejected() {
let mut v = json!({
"enums": {"season": {"summer": 3}},
"models": {"M": {
"variables": {"x": {
"type": "unknown"}},
"equations": [
{"lhs": "x", "rhs": {"op": "enum", "args": ["season"]}}]
}}
});
let err = lower_enums_raw(&mut v).unwrap_err();
assert_eq!(err.code, "enum_invalid_args");
}
#[test]
fn no_enums_no_change() {
let input = json!({
"models": {"M": {
"variables": {"x": {"type": "unknown", "default": 0.0}},
"equations": [{
"lhs": {"op": "D", "args": ["x"], "wrt": "t"},
"rhs": {"op": "*", "args": [0.1, "x"]}
}]
}}
});
let out = lowered(input.clone());
assert_eq!(out, input);
}
#[test]
fn enum_op_in_reaction_rate() {
let input = json!({
"enums": {"phase": {"day": 1, "night": 2}},
"reaction_systems": {
"RS": {
"species": [],
"reactions": [{
"id": "r1",
"substrates": {},
"products": {},
"rate": {
"op": "*",
"args": [1.0, {"op": "enum", "args": ["phase", "day"]}]
}
}]
}
}
});
let out = lowered(input);
let rate = &out["reaction_systems"]["RS"]["reactions"][0]["rate"];
assert_eq!(rate["args"][1]["op"], "const");
assert_eq!(rate["args"][1]["value"], 1);
}
#[test]
fn enum_op_in_subsystem_blob() {
let input = json!({
"enums": {"season": {"summer": 3}},
"models": {"Outer": {
"variables": {},
"equations": [],
"subsystems": {
"Inner": {
"variables": {"y": {
"type": "unknown"}},
"equations": [
{"lhs": "y", "rhs": {"op": "enum", "args": ["season", "summer"]}}]
}
}
}}
});
let out = lowered(input);
let expr = crate::classification::observed_definition_json(
&out["models"]["Outer"]["subsystems"]["Inner"],
"y",
)
.expect("y defining equation");
assert_eq!(expr["op"], "const");
assert_eq!(expr["value"], 3);
}
#[test]
fn typed_lower_enums_is_pure_and_agrees_with_the_raw_pass() {
let doc = json!({
"esm": "1.0.0",
"metadata": {"name": "enum_doc"},
"enums": {"season": {"winter": 1, "summer": 3}},
"models": {
"M": {
"variables": {"s": {"type": "unknown"}},
"equations": [
{"lhs": "s", "rhs": {"op": "enum", "args": ["season", "summer"]}}
]
}
}
});
let file: crate::EsmFile = serde_json::from_value(doc.clone()).expect("typed document");
let lowered_file = lower_enums(&file).expect("typed lowering succeeds");
let before = serde_json::to_value(&file).expect("re-render");
assert_eq!(before["models"]["M"]["equations"][0]["rhs"]["op"], "enum");
let after = serde_json::to_value(&lowered_file).expect("re-render");
assert_eq!(after["models"]["M"]["equations"][0]["rhs"]["op"], "const");
assert_eq!(after["models"]["M"]["equations"][0]["rhs"]["value"], 3);
assert_eq!(after, lowered(doc));
let mut mutated = file.clone();
lower_enums_mut(&mut mutated).expect("in-place lowering succeeds");
assert_eq!(serde_json::to_value(&mutated).expect("re-render"), after);
}
#[test]
fn typed_lower_enums_raises_enum_lowering_error() {
let file: crate::EsmFile = serde_json::from_value(json!({
"esm": "1.0.0",
"metadata": {"name": "bad_enum"},
"enums": {"season": {"winter": 1}},
"models": {
"M": {
"variables": {"s": {"type": "unknown"}},
"equations": [
{"lhs": "s", "rhs": {"op": "enum", "args": ["season", "nope"]}}
]
}
}
}))
.expect("typed document");
let e: EnumLoweringError = lower_enums(&file).unwrap_err();
assert_eq!(e.code, codes::UNKNOWN_ENUM_SYMBOL);
}
}