use crate::{EsmFile, error::EsmError};
use crate::flatten::VAR_PLACEHOLDER;
use jsonschema::error::ValidationErrorKind;
use jsonschema::{Draft, JSONSchema};
use serde_json::Value;
use std::collections::HashSet;
use std::sync::OnceLock;
fn library_version() -> (u32, u32, u32) {
crate::diagnostic::parse_semver(crate::SCHEMA_VERSION)
.expect("SCHEMA_VERSION is a valid major.minor.patch string")
}
const ESM_SCHEMA_JSON: &str = include_str!("esm-schema.json");
fn get_schema() -> &'static JSONSchema {
static SCHEMA: OnceLock<JSONSchema> = OnceLock::new();
SCHEMA.get_or_init(|| {
JSONSchema::options()
.with_draft(Draft::Draft202012)
.should_validate_formats(true)
.compile(get_schema_value())
.expect("Bundled schema should compile successfully")
})
}
fn get_schema_value() -> &'static Value {
static SCHEMA_VALUE: OnceLock<Value> = OnceLock::new();
SCHEMA_VALUE.get_or_init(|| {
serde_json::from_str(ESM_SCHEMA_JSON).expect("Bundled schema should be valid JSON")
})
}
pub fn load_string(json_str: &str) -> Result<EsmFile, EsmError> {
load_string_with_options(json_str, &LoadOptions::default())
}
pub fn load_document(document: &Value) -> Result<EsmFile, EsmError> {
load_document_with_options(document, &LoadOptions::default())
}
pub fn load_document_with_options(
document: &Value,
options: &LoadOptions,
) -> Result<EsmFile, EsmError> {
load_value(document.clone(), options)
}
pub(crate) fn load_document_owned(
document: Value,
options: &LoadOptions,
) -> Result<EsmFile, EsmError> {
load_value(document, options)
}
#[derive(Debug, Clone, Default)]
pub struct LoadOptions {
pub base_path: Option<std::path::PathBuf>,
pub metaparameters: std::collections::BTreeMap<String, i64>,
}
pub fn load_string_with_options(
json_str: &str,
options: &LoadOptions,
) -> Result<EsmFile, EsmError> {
let json_value: Value = serde_json::from_str(json_str).map_err(EsmError::JsonParse)?;
load_value(json_value, options)
}
fn load_value(json_value: Value, options: &LoadOptions) -> Result<EsmFile, EsmError> {
let mut json_value = json_value;
let authored = prepare_document_ops_scanned(&mut json_value)?;
let base = options.base_path.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
let mount_declared = if !options.metaparameters.is_empty()
|| crate::template_imports::document_declares_an_extent(&json_value)
{
crate::template_imports::collect_mount_declared_metaparameters(&json_value, &base)
} else {
std::collections::BTreeSet::new()
};
crate::template_imports::check_data_source_extents(&json_value, &base, &mount_declared)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
let staged_index_sets = crate::ref_loading::resolve_subsystem_refs_with_metaparameters(
&mut json_value,
&base,
&options.metaparameters,
)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
crate::data_source_urls::resolve_data_source_urls(&mut json_value, &base)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
crate::lower_expression_templates::reject_expression_templates_pre_v04(&json_value)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
crate::template_imports::reject_template_imports_pre_v08(&json_value)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
crate::template_imports::reject_impure_template_library(&json_value)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
crate::solver::reject_solver_pre_v11(&json_value).map_err(
|e: crate::diagnostic::DiagnosticError| EsmError::SchemaValidation(e.to_string()),
)?;
crate::solver::reject_solver_external_pre_v13(&json_value)
.and_then(|()| crate::solver::reject_solver_time_span_pre_v13(&json_value))
.and_then(|()| crate::solver::reject_solver_external_unresolved(&json_value))
.and_then(|()| crate::solver::reject_solver_time_span_invalid(&json_value))
.map_err(|e: crate::diagnostic::DiagnosticError| {
EsmError::SchemaValidation(e.to_string())
})?;
let const_units = if authored.mounts {
crate::units::reject_const_units_pre_v12(&json_value)
} else {
crate::units::reject_const_units_pre_v12_found(&json_value, || authored.op_with_units)
};
const_units.map_err(|e: crate::diagnostic::DiagnosticError| {
EsmError::SchemaValidation(e.to_string())
})?;
validate_schema(&json_value)?;
crate::template_imports::apply_scope_injections(&mut json_value, &[])
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
if let Some(resolved) = crate::template_imports::resolve_template_machinery_scoped(
&json_value,
&base,
&options.metaparameters,
&mount_declared,
false,
)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?
{
json_value = resolved;
}
let closed_env =
crate::ref_loading::root_metaparameter_env(&json_value, &options.metaparameters);
crate::ref_loading::apply_staged_index_sets(&mut json_value, staged_index_sets, &closed_env)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
validate_structural_json(&json_value)?;
let mut reserved = ReservedIndexScan::new(&json_value);
let mut has_enum_ops = false;
let (has_templates, scanned_whole) =
crate::lower_expression_templates::lower_expression_templates_visiting(
&mut json_value,
&mut |path, value| {
has_enum_ops |= crate::lower_enums::is_enum_node(value);
reserved.visit(path, value);
},
)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
let reserved = (scanned_whole && !has_enum_ops).then_some(reserved);
let component_templates =
crate::lower_expression_templates::capture_component_templates(&json_value);
if has_templates {
crate::lower_expression_templates::expand(&mut json_value)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
} else {
crate::lower_expression_templates::strip_component_template_blocks(&mut json_value);
}
crate::lower_enums::lower_enums_raw_scanned(&mut json_value, has_enum_ops || !scanned_whole)
.map_err(|e| EsmError::SchemaValidation(e.to_string()))?;
match reserved {
Some(scan) => scan.finish(),
None => reject_reserved_index_symbols(&json_value),
}
.map_err(|e| EsmError::StructuralValidation(e.to_string()))?;
let mut esm_file: EsmFile = {
let _intern = crate::intern::InternScope::new();
serde_json::from_value(json_value).map_err(EsmError::JsonParse)?
};
esm_file.component_templates = component_templates;
esm_file.solver = crate::solver::normalize_empty(esm_file.solver.take());
if options.base_path.is_some() {
esm_file.coupling_import_base = Some(base.to_string_lossy().into_owned());
}
esm_file.document_base = crate::solver::absolute_directory(&base);
Ok(esm_file)
}
pub fn load_path<P: AsRef<std::path::Path>>(path: P) -> Result<EsmFile, EsmError> {
load_path_with_options(path, &std::collections::BTreeMap::new())
}
pub fn load_path_with_options<P: AsRef<std::path::Path>>(
path: P,
metaparameters: &std::collections::BTreeMap<String, i64>,
) -> Result<EsmFile, EsmError> {
let path = path.as_ref();
let json_str = std::fs::read_to_string(path).map_err(|e| EsmError::FileRead {
path: path.display().to_string(),
source: e,
})?;
let base = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let options = LoadOptions {
base_path: Some(base),
metaparameters: metaparameters.clone(),
};
load_string_with_options(&json_str, &options)
}
pub fn validate_schema(json_value: &Value) -> Result<(), EsmError> {
let schema = get_schema();
if schema.is_valid(json_value) {
return Ok(());
}
let validation_result = schema.validate(json_value);
match validation_result {
Ok(_) => Ok(()),
Err(errors) => {
let error_messages: Vec<String> = errors.map(|error| error.to_string()).collect();
Err(EsmError::SchemaValidation(error_messages.join("; ")))
}
}
}
fn schema_keyword_for_kind(kind: &ValidationErrorKind) -> &'static str {
use ValidationErrorKind as K;
match kind {
K::AdditionalItems { .. } => "additionalItems",
K::AdditionalProperties { .. } => "additionalProperties",
K::AnyOf => "anyOf",
K::BacktrackLimitExceeded { .. } => "pattern",
K::Constant { .. } => "const",
K::Contains => "contains",
K::ContentEncoding { .. } => "contentEncoding",
K::ContentMediaType { .. } => "contentMediaType",
K::Custom { .. } => "custom",
K::Enum { .. } => "enum",
K::ExclusiveMaximum { .. } => "exclusiveMaximum",
K::ExclusiveMinimum { .. } => "exclusiveMinimum",
K::FalseSchema => "false",
K::FileNotFound { .. } => "$ref",
K::Format { .. } => "format",
K::FromUtf8 { .. } => "contentEncoding",
K::Utf8 { .. } => "format",
K::JSONParse { .. } => "$ref",
K::InvalidReference { .. } => "$ref",
K::InvalidURL { .. } => "format",
K::MaxItems { .. } => "maxItems",
K::Maximum { .. } => "maximum",
K::MaxLength { .. } => "maxLength",
K::MaxProperties { .. } => "maxProperties",
K::MinItems { .. } => "minItems",
K::Minimum { .. } => "minimum",
K::MinLength { .. } => "minLength",
K::MinProperties { .. } => "minProperties",
K::MultipleOf { .. } => "multipleOf",
K::Not { .. } => "not",
K::OneOfMultipleValid | K::OneOfNotValid => "oneOf",
K::Pattern { .. } => "pattern",
K::PropertyNames { .. } => "propertyNames",
K::Required { .. } => "required",
K::Schema => "schema",
K::Type { .. } => "type",
K::UnevaluatedProperties { .. } => "unevaluatedProperties",
K::UniqueItems => "uniqueItems",
K::UnknownReferenceScheme { .. } => "$ref",
K::Resolver { .. } => "$ref",
}
}
const SCHEMA_DESCENT_CAP: usize = 16;
pub fn collect_schema_errors(json_value: &Value) -> Vec<crate::validate::SchemaError> {
let root = get_schema_value();
let defs = root.get("$defs");
let mut out = Vec::new();
let mut cache: std::collections::HashMap<String, Option<JSONSchema>> =
std::collections::HashMap::new();
let summaries = match get_schema().validate(json_value) {
Ok(()) => Vec::new(),
Err(errors) => summarize_errors(errors),
};
handle_schema_errors(
root, defs, &mut cache, root, json_value, "", &summaries, 0, &mut out,
);
let mut seen = std::collections::HashSet::new();
out.retain(|e| seen.insert((e.path.clone(), e.keyword.clone(), e.message.clone())));
out
}
struct ErrSummary {
instance_path: String,
schema_path: String,
keyword: String,
message: String,
is_composition: bool,
}
fn summarize_errors<'a>(
errors: impl Iterator<Item = jsonschema::ValidationError<'a>>,
) -> Vec<ErrSummary> {
errors
.map(|error| ErrSummary {
instance_path: error.instance_path.to_string(),
schema_path: error.schema_path.to_string(),
keyword: schema_keyword_for_kind(&error.kind).to_string(),
message: error.to_string(),
is_composition: matches!(
error.kind,
ValidationErrorKind::OneOfNotValid
| ValidationErrorKind::OneOfMultipleValid
| ValidationErrorKind::AnyOf
),
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn handle_schema_errors(
root: &Value,
defs: Option<&Value>,
cache: &mut std::collections::HashMap<String, Option<JSONSchema>>,
nav_schema: &Value,
instance: &Value,
base: &str,
summaries: &[ErrSummary],
depth: usize,
out: &mut Vec<crate::validate::SchemaError>,
) {
for s in summaries {
let abs_path = format!("{base}{}", s.instance_path);
out.push(crate::validate::SchemaError {
path: abs_path.clone(),
message: s.message.clone(),
keyword: s.keyword.clone(),
});
if s.is_composition && depth < SCHEMA_DESCENT_CAP {
let segments = schema_path_segments(&s.schema_path);
if let Some(branches) =
resolve_schema_at(root, nav_schema, &segments).and_then(|c| c.as_array())
{
let sub_instance = if s.instance_path.is_empty() {
instance
} else {
instance.pointer(&s.instance_path).unwrap_or(instance)
};
for branch in branches {
let branch_schema = follow_refs(root, branch);
let sub_summaries =
validate_and_summarize(cache, defs, branch_schema, sub_instance);
handle_schema_errors(
root,
defs,
cache,
branch_schema,
sub_instance,
&abs_path,
&sub_summaries,
depth + 1,
out,
);
}
}
}
}
}
fn validate_and_summarize(
cache: &mut std::collections::HashMap<String, Option<JSONSchema>>,
defs: Option<&Value>,
branch_schema: &Value,
instance: &Value,
) -> Vec<ErrSummary> {
match compile_branch(cache, defs, branch_schema) {
Some(compiled) => match compiled.validate(instance) {
Ok(()) => Vec::new(),
Err(errors) => summarize_errors(errors),
},
None => Vec::new(),
}
}
fn schema_path_segments(pointer: &str) -> Vec<String> {
pointer
.split('/')
.skip(1) .map(|c| c.replace("~1", "/").replace("~0", "~"))
.collect()
}
fn follow_refs<'a>(root: &'a Value, mut value: &'a Value) -> &'a Value {
let mut guard = 0;
while let Some(reference) = value.get("$ref").and_then(|r| r.as_str()) {
guard += 1;
if guard > SCHEMA_DESCENT_CAP {
break;
}
match resolve_local_ref(root, reference) {
Some(target) => value = target,
None => break,
}
}
value
}
fn resolve_local_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
let pointer = reference.strip_prefix('#')?;
if pointer.is_empty() {
Some(root)
} else {
root.pointer(pointer)
}
}
fn resolve_schema_at<'a>(
root: &'a Value,
start: &'a Value,
segments: &[String],
) -> Option<&'a Value> {
let mut cur = follow_refs(root, start);
for seg in segments {
cur = follow_refs(root, cur);
cur = match cur {
Value::Object(_) => cur.get(seg)?,
Value::Array(arr) => arr.get(seg.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(follow_refs(root, cur))
}
fn compile_branch<'c>(
cache: &'c mut std::collections::HashMap<String, Option<JSONSchema>>,
defs: Option<&Value>,
branch_schema: &Value,
) -> Option<&'c JSONSchema> {
let key = branch_schema.to_string();
cache
.entry(key)
.or_insert_with(|| {
let mut wrapper = branch_schema.clone();
if let (Some(obj), Some(defs)) = (wrapper.as_object_mut(), defs) {
obj.entry("$defs").or_insert_with(|| defs.clone());
}
JSONSchema::options()
.with_draft(Draft::Draft202012)
.should_validate_formats(true)
.compile(&wrapper)
.ok()
})
.as_ref()
}
pub(crate) fn reject_reserved_index_symbols(
json_value: &Value,
) -> Result<(), crate::diagnostic::DiagnosticError> {
let mut scan = ReservedIndexScan::new(json_value);
crate::json_visit::visit_values(json_value, &mut |path, value| scan.visit(path, value));
scan.finish()
}
pub(crate) struct ReservedIndexScan {
independent: String,
offenders: Vec<String>,
}
impl ReservedIndexScan {
pub(crate) fn new(json_value: &Value) -> Self {
let independent = json_value
.get("domain")
.and_then(|d| d.get("independent_variable"))
.and_then(Value::as_str)
.unwrap_or("t")
.to_string();
ReservedIndexScan {
independent,
offenders: Vec::new(),
}
}
pub(crate) fn visit(&mut self, path: &crate::json_visit::JsonPath<'_>, value: &Value) {
let Some(obj) = value.as_object() else {
return;
};
let Some(op) = crate::json_visit::small_get(obj, "op").and_then(Value::as_str) else {
return;
};
let independent = self.independent.as_str();
let reserved = |name: &str| -> Option<&'static str> {
if name == independent {
Some("the document's independent variable")
} else if name == VAR_PLACEHOLDER {
Some("the §6.4 operator placeholder")
} else {
None
}
};
let offenders = &mut self.offenders;
let mut seen: Vec<&str> = Vec::new();
let mut record = |field: &str, sym: &str, role: &str| {
offenders.push(format!(
"`{op}` node at {path} binds '{sym}' as an index symbol ({field}), \
but '{sym}' is {role}"
));
};
if let Some(ranges) = crate::json_visit::small_get(obj, "ranges").and_then(Value::as_object)
{
for key in ranges.keys() {
if let Some(role) = reserved(key) {
seen.push(key);
record("a `ranges` key", key, role);
}
}
}
if let Some(output_idx) =
crate::json_visit::small_get(obj, "output_idx").and_then(Value::as_array)
{
for entry in output_idx.iter().filter_map(Value::as_str) {
if let Some(role) = reserved(entry)
&& !seen.contains(&entry)
{
record("an `output_idx` entry", entry, role);
}
}
}
}
pub(crate) fn finish(self) -> Result<(), crate::diagnostic::DiagnosticError> {
if self.offenders.is_empty() {
return Ok(());
}
let independent = self.independent;
Err(crate::diagnostic::err(
crate::diagnostic::codes::RESERVED_INDEX_SYMBOL,
format!(
"{}. Both '{independent}' (esm-spec §11.3) and '{VAR_PLACEHOLDER}' (§6.4) are \
implicitly declared in every model's expression scope (§4.9.1) and are resolved \
by name before the loop bindings, so a binder spelled with one of them declares \
a loop its body can never address — the reads inside the node see the \
independent variable's value instead of the index, silently (CONFORMANCE_SPEC \
§5.5.8 forbids the same silence for an unresolvable `join.on` key column). \
Rename the index symbol: an index symbol is the author's free choice \
(esm-spec §4.3.1)",
self.offenders.join("; ")
),
))
}
}
fn validate_structural_json(json_value: &Value) -> Result<(), EsmError> {
let mut errors: Vec<String> = Vec::new();
if let Some(obj) = json_value.as_object() {
check_version_compatibility(obj, &mut errors);
check_metadata_formats(obj, &mut errors);
check_data_loader_temporal_durations(obj, &mut errors);
check_coupling_references(obj, &mut errors);
check_circular_model_dependencies_typed(json_value, &mut errors);
check_event_variable_references(obj, &mut errors);
check_reaction_stoichiometries(obj, &mut errors);
}
if errors.is_empty() {
Ok(())
} else {
Err(EsmError::StructuralValidation(format!(
"Structural validation failed: {}",
errors.join("; ")
)))
}
}
fn check_version_compatibility(obj: &serde_json::Map<String, Value>, errors: &mut Vec<String>) {
let Some(ver) = obj.get("esm").and_then(|v| v.as_str()) else {
return;
};
let Some((major, _, _)) = crate::diagnostic::parse_semver(ver) else {
return;
};
let supported_major = library_version().0;
if major != supported_major {
errors.push(format!(
"Unsupported major version {major}. This library supports major version {supported_major} only."
));
}
}
fn check_reaction_stoichiometries(obj: &serde_json::Map<String, Value>, errors: &mut Vec<String>) {
let Some(reaction_systems) = obj.get("reaction_systems").and_then(|v| v.as_object()) else {
return;
};
for (rs_name, rs_val) in reaction_systems {
let Some(rs_obj) = rs_val.as_object() else {
continue;
};
let Some(reactions) = rs_obj.get("reactions").and_then(|v| v.as_array()) else {
continue;
};
for (r_idx, r_val) in reactions.iter().enumerate() {
let Some(r_obj) = r_val.as_object() else {
continue;
};
for side in ["substrates", "products"] {
let Some(entries) = r_obj.get(side).and_then(|v| v.as_array()) else {
continue;
};
for (e_idx, e_val) in entries.iter().enumerate() {
let Some(e_obj) = e_val.as_object() else {
continue;
};
let Some(stoich) = e_obj.get("stoichiometry") else {
continue;
};
let path = format!(
"reaction_systems/{rs_name}/reactions[{r_idx}]/{side}[{e_idx}]/stoichiometry"
);
match stoich.as_f64() {
Some(v) if v.is_finite() && v > 0.0 => {}
Some(v) if !v.is_finite() => {
errors.push(format!("{path}: stoichiometry must be finite, got {v}"));
}
Some(v) => {
errors.push(format!("{path}: stoichiometry must be positive, got {v}"));
}
None => {
errors.push(format!("{path}: stoichiometry must be a number"));
}
}
}
}
}
}
}
fn check_metadata_formats(obj: &serde_json::Map<String, Value>, errors: &mut Vec<String>) {
let Some(md) = obj.get("metadata").and_then(|v| v.as_object()) else {
return;
};
for field in ["created", "modified"] {
if let Some(val) = md.get(field).and_then(|v| v.as_str())
&& !is_iso8601_datetime(val)
{
errors.push(format!(
"metadata/{field}: '{val}' is not a valid ISO 8601 date"
));
}
}
if let Some(refs) = md.get("references").and_then(|v| v.as_array()) {
for (i, r) in refs.iter().enumerate() {
let Some(r_obj) = r.as_object() else { continue };
if let Some(url) = r_obj.get("url").and_then(|v| v.as_str()) {
if url.is_empty() {
errors.push(format!(
"metadata/references[{i}]/url: empty string is not a valid URL"
));
} else if !is_valid_url(url) {
errors.push(format!(
"metadata/references[{i}]/url: '{url}' is not a valid URL"
));
}
}
if let Some(doi) = r_obj.get("doi").and_then(|v| v.as_str()) {
if doi.is_empty() {
errors.push(format!(
"metadata/references[{i}]/doi: empty string is not a valid DOI"
));
} else if !is_valid_doi(doi) {
errors.push(format!(
"metadata/references[{i}]/doi: '{doi}' is not a valid DOI format"
));
}
}
if let Some(citation) = r_obj.get("citation").and_then(|v| v.as_str())
&& citation.is_empty()
{
errors.push(format!(
"metadata/references[{i}]/citation: empty citation string is not allowed"
));
}
}
}
}
fn check_data_loader_temporal_durations(
obj: &serde_json::Map<String, Value>,
errors: &mut Vec<String>,
) {
let Some(loaders) = obj.get("data_sources").and_then(|v| v.as_object()) else {
return;
};
for (dname, dv) in loaders {
let Some(d) = dv.as_object() else { continue };
let Some(temporal) = d.get("temporal").and_then(|v| v.as_object()) else {
continue;
};
for field in ["file_period", "frequency"] {
if let Some(res) = temporal.get(field).and_then(|v| v.as_str())
&& !res.is_empty()
&& !is_iso8601_duration(res)
{
errors.push(format!(
"data_sources/{dname}/temporal/{field}: '{res}' is not a valid ISO 8601 duration"
));
}
}
}
}
fn coupled_system_names_raw(obj: &serde_json::Map<String, Value>) -> HashSet<String> {
let mut coupled = HashSet::new();
let mut add = |name: &str| {
if name.is_empty() {
return;
}
coupled.insert(name.to_string());
if let Some((root, _)) = name.split_once('.') {
coupled.insert(root.to_string());
}
};
for entry in obj
.get("coupling")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
{
for key in ["from", "to"] {
if let Some(s) = entry.get(key).and_then(|v| v.as_str()) {
add(s);
}
}
for s in entry
.get("systems")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
{
add(s);
}
}
coupled
}
fn check_coupling_role_references(
obj: &serde_json::Map<String, Value>,
coupling: &[Value],
errors: &mut Vec<String>,
) {
let roles: std::collections::HashSet<&str> = obj
.get("coupling_roles")
.and_then(|v| v.as_object())
.map(|o| o.keys().map(String::as_str).collect())
.unwrap_or_default();
for (i, c) in coupling.iter().enumerate() {
if !c.is_object() {
continue;
}
let mut unknown: Vec<String> = crate::coupling_imports::collect_role_segments(c)
.into_iter()
.filter(|seg| !roles.contains(seg.as_str()))
.collect();
unknown.sort();
for seg in unknown {
errors.push(format!(
"coupling[{i}]: edge references '{seg}', which is not a declared role \
(esm-spec §10.9: a coupling library's refs name a role in `coupling_roles`)"
));
}
}
}
fn check_coupling_references(obj: &serde_json::Map<String, Value>, errors: &mut Vec<String>) {
let Some(coupling) = obj.get("coupling").and_then(|v| v.as_array()) else {
return;
};
if crate::coupling_imports::is_coupling_library_obj(obj) {
check_coupling_role_references(obj, coupling, errors);
return;
}
let tables = build_symbol_tables(obj);
for (i, c) in coupling.iter().enumerate() {
let Some(cobj) = c.as_object() else { continue };
let Some(from_ref) = cobj.get("from").and_then(|v| v.as_str()) else {
continue;
};
if !from_ref.contains('.') {
continue;
}
let dot_count = from_ref.chars().filter(|ch| *ch == '.').count();
if dot_count > 1 {
let top = from_ref.split('.').next().unwrap_or("");
if !tables.all_systems.contains(top) {
errors.push(format!(
"coupling[{i}]/from: reference '{from_ref}' to undefined system '{top}'"
));
}
continue;
}
let (system, var) = from_ref.split_once('.').unwrap();
if !tables.all_systems.contains(system) {
errors.push(format!(
"coupling[{i}]/from: reference '{from_ref}' to undefined system '{system}'"
));
continue;
}
if !tables.has_variable(system, var) {
errors.push(format!(
"coupling[{i}]/from: reference '{from_ref}' — variable '{var}' not provided by '{system}'"
));
}
}
}
fn check_circular_model_dependencies_typed(json_value: &Value, errors: &mut Vec<String>) {
use serde::Deserialize as _;
let n_models = json_value
.get("models")
.and_then(|m| m.as_object())
.map_or(0, |m| m.len());
if n_models < 2 {
return;
}
let Ok(esm_file) = EsmFile::deserialize(json_value) else {
return;
};
let Some(models) = esm_file.models.as_ref() else {
return;
};
let mut typed_errors: Vec<crate::validate::StructuralError> = Vec::new();
crate::structural::check_circular_dependencies_in_models(models, &mut typed_errors);
for e in typed_errors {
let cycle = e
.details
.get("cycle")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(" -> ")
})
.unwrap_or_default();
errors.push(format!("circular reference (cycle) detected: {cycle}"));
}
}
fn check_event_variable_references(obj: &serde_json::Map<String, Value>, errors: &mut Vec<String>) {
let Some(models) = obj.get("models").and_then(|v| v.as_object()) else {
return;
};
let metaparameters: std::collections::HashSet<&str> = obj
.get("metaparameters")
.and_then(|v| v.as_object())
.map(|m| m.keys().map(String::as_str).collect())
.unwrap_or_default();
let coupled = coupled_system_names_raw(obj);
for (mname, mv) in models {
let Some(m) = mv.as_object() else { continue };
let Some(vars) = m.get("variables").and_then(|v| v.as_object()) else {
continue;
};
let mut declared: std::collections::HashSet<&str> =
vars.keys().map(String::as_str).collect();
if coupled.contains(mname.as_str()) {
declared.insert("_var");
}
if let Some(events) = m.get("continuous_events").and_then(|v| v.as_array()) {
for (ei, event) in events.iter().enumerate() {
let Some(event_obj) = event.as_object() else {
continue;
};
if let Some(affects) = event_obj.get("affects").and_then(|v| v.as_array()) {
for (ai, affect) in affects.iter().enumerate() {
if let Some(lhs) = affect.get("lhs").and_then(|v| v.as_str())
&& !lhs.contains('.')
&& !declared.contains(lhs)
{
errors.push(format!(
"models/{mname}/continuous_events[{ei}]/affects[{ai}]/lhs: \
undeclared variable '{lhs}'"
));
}
}
}
if let Some(conditions) = event_obj.get("conditions").and_then(|v| v.as_array()) {
for (ci, cond) in conditions.iter().enumerate() {
let mut refs: Vec<String> = Vec::new();
collect_variable_refs(cond, &mut refs);
for r in refs {
if r.contains('.') || metaparameters.contains(r.as_str()) {
continue;
}
if !declared.contains(r.as_str()) {
errors.push(format!(
"models/{mname}/continuous_events[{ei}]/conditions[{ci}]: \
undeclared variable '{r}'"
));
}
}
}
}
}
}
if let Some(events) = m.get("discrete_events").and_then(|v| v.as_array()) {
for (ei, event) in events.iter().enumerate() {
let Some(event_obj) = event.as_object() else {
continue;
};
if let Some(affects) = event_obj.get("affects").and_then(|v| v.as_array()) {
for (ai, affect) in affects.iter().enumerate() {
if let Some(lhs) = affect.get("lhs").and_then(|v| v.as_str())
&& !lhs.contains('.')
&& !declared.contains(lhs)
{
errors.push(format!(
"models/{mname}/discrete_events[{ei}]/affects[{ai}]/lhs: \
undeclared variable '{lhs}'"
));
}
}
}
if let Some(trigger) = event_obj.get("trigger").and_then(|v| v.as_object())
&& trigger.get("type").and_then(|v| v.as_str()) == Some("condition")
&& let Some(expression) = trigger.get("expression")
{
let mut refs: Vec<String> = Vec::new();
collect_variable_refs(expression, &mut refs);
for r in refs {
if r.contains('.') || metaparameters.contains(r.as_str()) {
continue;
}
if !declared.contains(r.as_str()) {
errors.push(format!(
"models/{mname}/discrete_events[{ei}]/trigger/expression: \
undeclared variable '{r}'"
));
}
}
}
}
}
}
}
fn collect_variable_refs(expr: &Value, out: &mut Vec<String>) {
match expr {
Value::String(s) => out.push(s.clone()),
Value::Object(obj) => {
for key in crate::types::EXPR_ARRAY_CHILD_KEYS {
if let Some(items) = obj.get(key).and_then(|v| v.as_array()) {
for a in items {
collect_variable_refs(a, out);
}
}
}
for key in crate::types::EXPR_SCALAR_CHILD_KEYS {
if let Some(child) = obj.get(key) {
collect_variable_refs(child, out);
}
}
for key in crate::types::EXPR_MAP_CHILD_KEYS {
if let Some(map) = obj.get(key).and_then(|v| v.as_object()) {
for child in map.values() {
collect_variable_refs(child, out);
}
}
}
}
Value::Array(arr) => {
for a in arr {
collect_variable_refs(a, out);
}
}
_ => {}
}
}
struct SymbolTables {
all_systems: std::collections::HashSet<String>,
per_system: std::collections::HashMap<String, std::collections::HashSet<String>>,
}
impl SymbolTables {
fn has_variable(&self, system: &str, var: &str) -> bool {
self.per_system
.get(system)
.map(|s| s.contains(var))
.unwrap_or(false)
}
}
fn variable_keys(node: &Value) -> std::collections::HashSet<String> {
node.as_object()
.and_then(|o| o.get("variables"))
.and_then(|v| v.as_object())
.map(|vs| vs.keys().cloned().collect())
.unwrap_or_default()
}
fn build_symbol_tables(obj: &serde_json::Map<String, Value>) -> SymbolTables {
let mut all_systems: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut per_system: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
if let Some(models) = obj.get("models").and_then(|v| v.as_object()) {
for (name, m) in models {
all_systems.insert(name.clone());
per_system.insert(name.clone(), variable_keys(m));
}
}
if let Some(rsystems) = obj.get("reaction_systems").and_then(|v| v.as_object()) {
for (name, rs) in rsystems {
all_systems.insert(name.clone());
let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Some(rso) = rs.as_object() {
if let Some(sp) = rso.get("species").and_then(|v| v.as_object()) {
set.extend(sp.keys().cloned());
}
if let Some(params) = rso.get("parameters").and_then(|v| v.as_object()) {
set.extend(params.keys().cloned());
}
}
per_system.insert(name.clone(), set);
}
}
if let Some(loaders) = obj.get("data_sources").and_then(|v| v.as_object()) {
for (name, d) in loaders {
all_systems.insert(name.clone());
per_system.insert(name.clone(), variable_keys(d));
}
}
if let Some(operators) = obj.get("operators").and_then(|v| v.as_object()) {
for name in operators.keys() {
all_systems.insert(name.clone());
per_system.entry(name.clone()).or_default();
}
}
SymbolTables {
all_systems,
per_system,
}
}
fn is_iso8601_datetime(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() < 10 {
return false;
}
if !(bytes[0].is_ascii_digit()
&& bytes[1].is_ascii_digit()
&& bytes[2].is_ascii_digit()
&& bytes[3].is_ascii_digit()
&& bytes[4] == b'-'
&& bytes[5].is_ascii_digit()
&& bytes[6].is_ascii_digit()
&& bytes[7] == b'-'
&& bytes[8].is_ascii_digit()
&& bytes[9].is_ascii_digit())
{
return false;
}
if bytes.len() == 10 {
return true;
}
if bytes[10] != b'T' {
return false;
}
if bytes.len() < 19
|| !(bytes[11].is_ascii_digit()
&& bytes[12].is_ascii_digit()
&& bytes[13] == b':'
&& bytes[14].is_ascii_digit()
&& bytes[15].is_ascii_digit()
&& bytes[16] == b':'
&& bytes[17].is_ascii_digit()
&& bytes[18].is_ascii_digit())
{
return false;
}
let mut idx = 19;
if idx < bytes.len() && bytes[idx] == b'.' {
idx += 1;
let start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == start {
return false;
}
}
if idx == bytes.len() {
return true;
}
if bytes[idx] == b'Z' {
return idx + 1 == bytes.len();
}
if bytes[idx] == b'+' || bytes[idx] == b'-' {
let rest = &bytes[idx + 1..];
return (rest.len() == 5
&& rest[0].is_ascii_digit()
&& rest[1].is_ascii_digit()
&& rest[2] == b':'
&& rest[3].is_ascii_digit()
&& rest[4].is_ascii_digit())
|| (rest.len() == 4 && rest.iter().all(|b| b.is_ascii_digit()));
}
false
}
fn is_valid_url(s: &str) -> bool {
let lowered = s.to_ascii_lowercase();
if !(lowered.starts_with("http://") || lowered.starts_with("https://")) {
return false;
}
let scheme_len = if lowered.starts_with("https://") {
8
} else {
7
};
let after = &s[scheme_len..];
if after.is_empty() {
return false;
}
let host_end = after.find('/').unwrap_or(after.len());
let host = &after[..host_end];
if host.is_empty() || !host.contains('.') {
return false;
}
!s.chars().any(|c| c.is_whitespace())
}
fn is_valid_doi(s: &str) -> bool {
let Some(rest) = s.strip_prefix("10.") else {
return false;
};
let Some(slash) = rest.find('/') else {
return false;
};
let registrant = &rest[..slash];
let suffix = &rest[slash + 1..];
if !(4..=9).contains(®istrant.len()) || !registrant.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
!suffix.is_empty() && !suffix.chars().any(|c| c.is_whitespace())
}
fn is_iso8601_duration(s: &str) -> bool {
let mut chars = s.chars().peekable();
if chars.next() != Some('P') {
return false;
}
let mut any_component = false;
while let Some(&c) = chars.peek() {
if c == 'T' {
break;
}
if !c.is_ascii_digit() {
return false;
}
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
chars.next();
} else {
break;
}
}
match chars.next() {
Some('Y') | Some('M') | Some('W') | Some('D') => {
any_component = true;
}
_ => return false,
}
}
if chars.peek() == Some(&'T') {
chars.next();
while let Some(&c) = chars.peek() {
if !c.is_ascii_digit() {
return false;
}
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
chars.next();
} else {
break;
}
}
if chars.peek() == Some(&'.') {
chars.next();
let mut had_digit = false;
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
chars.next();
had_digit = true;
} else {
break;
}
}
if !had_digit {
return false;
}
if chars.next() != Some('S') {
return false;
}
any_component = true;
break;
}
match chars.next() {
Some('H') | Some('M') | Some('S') => {
any_component = true;
}
_ => return false,
}
}
}
any_component && chars.next().is_none()
}
pub(crate) fn prepare_document_ops(value: &mut serde_json::Value) -> Result<(), EsmError> {
prepare_document_ops_scanned(value).map(|_| ())
}
pub(crate) struct AuthoredFacts {
pub(crate) op_with_units: Option<String>,
pub(crate) mounts: bool,
}
pub(crate) fn prepare_document_ops_scanned(
value: &mut serde_json::Value,
) -> Result<AuthoredFacts, EsmError> {
let faq_too_old = value
.get("esm")
.and_then(|v| v.as_str())
.and_then(crate::diagnostic::parse_semver)
.is_some_and(|(major, minor, _)| (major, minor) < (1, 1));
let esm_declared = value
.get("esm")
.and_then(|v| v.as_str())
.map(str::to_string);
let mut scan = OpScan {
want_faq: faq_too_old,
..OpScan::default()
};
scan_document_ops(value, &crate::json_visit::JsonPath::Root(""), &mut scan);
let facts = AuthoredFacts {
op_with_units: scan.op_with_units.take(),
mounts: scan.mounts,
};
if let Some(path) = scan.removed {
return Err(EsmError::SchemaValidation(format!(
"removed_op at {path}: `\"op\": \"arrayop\"` was removed at esm 0.8.0 and is not a \
deprecated alias; use `\"op\": \"faq\"` (the Functional Aggregate Query node). \
See docs/content/rfcs/faq-node-rename.md."
)));
}
if let Some(path) = scan.faq
&& let Some(esm) = esm_declared
{
return Err(EsmError::SchemaValidation(format!(
"faq_version_too_old at {path}: the `faq` op arrives at esm 1.1.0; file \
declares {esm}. Use `\"op\": \"aggregate\"` (the deprecated pre-1.1.0 \
spelling) or raise the declared version. \
See docs/content/rfcs/faq-node-rename.md."
)));
}
let n = scan.rewritten;
if n > 0 {
raise_esm_floor_to_v11(value);
}
if n > 0 {
let plural = if n == 1 { "node was" } else { "nodes were" };
eprintln!(
"warning: deprecated_op_alias: `\"op\": \"aggregate\"` is the pre-1.1.0 spelling \
of `\"op\": \"faq\"` (Functional Aggregate Query); {n} {plural} normalized on \
load. The alias is REMOVED at esm 2.0.0 — re-emit this document to migrate it \
(docs/content/rfcs/faq-node-rename.md)."
);
}
Ok(facts)
}
#[derive(Default)]
struct OpScan {
want_faq: bool,
removed: Option<String>,
faq: Option<String>,
rewritten: usize,
op_with_units: Option<String>,
mounts: bool,
}
fn scan_document_ops(
value: &mut serde_json::Value,
at: &crate::json_visit::JsonPath<'_>,
scan: &mut OpScan,
) {
use crate::json_visit::JsonPath;
match value {
serde_json::Value::Object(map) => {
let op = crate::json_visit::small_get(map, "op");
if scan.op_with_units.is_none()
&& op.is_some()
&& crate::json_visit::small_get(map, "units").is_some()
{
scan.op_with_units = Some(at.to_string());
}
scan.mounts |= crate::json_visit::small_get(map, "ref").is_some()
|| crate::json_visit::small_get(map, "subsystems").is_some();
match op.and_then(|v| v.as_str()) {
Some("arrayop") => {
scan.removed = Some(at.to_string());
return;
}
Some("faq") if scan.want_faq && scan.faq.is_none() => {
scan.faq = Some(at.to_string());
}
Some("aggregate") => {
map.insert(
"op".to_string(),
serde_json::Value::String("faq".to_string()),
);
scan.rewritten += 1;
}
_ => {}
}
for (k, v) in map.iter_mut() {
scan_document_ops(v, &JsonPath::Key(at, k), scan);
if scan.removed.is_some() {
return;
}
}
}
serde_json::Value::Array(items) => {
for (i, v) in items.iter_mut().enumerate() {
scan_document_ops(v, &JsonPath::Index(at, i), scan);
if scan.removed.is_some() {
return;
}
}
}
_ => {}
}
}
fn raise_esm_floor_to_v11(value: &mut serde_json::Value) {
let below = value
.get("esm")
.and_then(|v| v.as_str())
.and_then(crate::diagnostic::parse_semver)
.is_some_and(|(major, minor, _)| (major, minor) < (1, 1));
if below && let Some(obj) = value.as_object_mut() {
obj.insert(
"esm".to_string(),
serde_json::Value::String("1.1.0".to_string()),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepare_document_ops_reports_as_its_steps_did() {
use serde_json::json;
let run = |mut v: Value| {
prepare_document_ops(&mut v)
.map(|()| v)
.map_err(|e| e.to_string())
};
let e =
run(json!({"esm": "1.0.0", "a": {"op": "faq"}, "b": [{"op": "x"}, {"op": "arrayop"}]}))
.unwrap_err();
assert!(e.contains("removed_op at /b/1:"), "{e}");
let e = run(json!({"esm": "1.0.0", "a": {"op": "aggregate", "args": [{"op": "faq"}]}, "b": {"op": "faq"}}))
.unwrap_err();
assert!(e.contains("faq_version_too_old at /a/args/0:"), "{e}");
assert!(e.contains("file declares 1.0.0"), "{e}");
let v =
run(json!({"esm": "1.0.0", "a": {"op": "aggregate", "args": [{"op": "aggregate"}]}}))
.unwrap();
assert_eq!(
v,
json!({"esm": "1.1.0", "a": {"op": "faq", "args": [{"op": "faq"}]}})
);
let v = run(json!({"esm": "1.1.0", "a": {"op": "faq"}})).unwrap();
assert_eq!(v, json!({"esm": "1.1.0", "a": {"op": "faq"}}));
}
#[test]
fn test_load_minimal_model() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "test_model"
},
"models": {
"simple": {
"variables": {},
"equations": []
}
}
}
"#;
let result = load_string(json);
assert!(result.is_ok());
let esm_file = result.unwrap();
assert_eq!(esm_file.esm, "1.0.0");
assert!(esm_file.models.is_some());
}
#[test]
fn test_load_invalid_json() {
let json = r#"{ invalid json }"#;
let result = load_string(json);
assert!(result.is_err());
match result.unwrap_err() {
EsmError::JsonParse(_) => {} _ => panic!("Expected JsonParse error"),
}
}
#[test]
fn test_load_missing_esm_version() {
let json = r#"
{
"metadata": {
"name": "test_model"
},
"models": {}
}
"#;
let result = load_string(json);
assert!(result.is_err());
match result.unwrap_err() {
EsmError::SchemaValidation(_) => {} _ => panic!("Expected SchemaValidation error"),
}
}
#[test]
fn test_load_malformed_esm_version() {
let json = r#"
{
"esm": "not-a-version",
"metadata": {
"name": "test_model"
},
"models": {}
}
"#;
let result = load_string(json);
assert!(result.is_err());
match result.unwrap_err() {
EsmError::SchemaValidation(_) => {} _ => panic!("Expected SchemaValidation error"),
}
}
#[test]
fn test_load_missing_content() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "test_model"
}
}
"#;
let result = load_string(json);
assert!(result.is_err());
match result.unwrap_err() {
EsmError::SchemaValidation(_) => {} _ => panic!("Expected SchemaValidation error"),
}
}
#[test]
fn test_round_trip() {
use crate::to_json;
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "test_model"
},
"models": {
"simple": {
"variables": {},
"equations": []
}
}
}
"#;
let esm_file = load_string(json).expect("Failed to load ESM file");
let serialized_json = to_json(&esm_file).expect("Failed to save ESM file");
let round_trip_file =
load_string(&serialized_json).expect("Failed to load round-trip ESM file");
assert_eq!(esm_file.esm, round_trip_file.esm);
assert_eq!(esm_file.metadata.name, round_trip_file.metadata.name);
assert!(esm_file.models.is_some());
assert!(round_trip_file.models.is_some());
let models1 = esm_file.models.as_ref().unwrap();
let models2 = round_trip_file.models.as_ref().unwrap();
assert_eq!(models1.len(), models2.len());
assert!(models1.contains_key("simple"));
assert!(models2.contains_key("simple"));
}
}