use indexmap::IndexMap;
use serde_json::Value;
use crate::compile_error::CompileError;
use crate::diagnostic::codes;
use crate::flatten::FlattenedSystem;
use crate::substitute::{map_exprs_in_continuous_event, map_exprs_in_discrete_event};
use crate::types::{
AssertionReference, EsmFile, Expr, ExpressionNode, FunctionTable, FunctionTableAxis, Model,
};
const TABLE_LOOKUP: &str = "table_lookup";
fn err(code: &'static str, reason: impl Into<String>) -> CompileError {
CompileError::TableLookupLowering {
code,
reason: reason.into(),
}
}
pub(crate) fn lower_table_lookups(file: &mut EsmFile) -> Result<(), CompileError> {
let EsmFile {
function_tables,
models,
reaction_systems,
..
} = file;
let Some(tables) = function_tables.as_ref().filter(|t| !t.is_empty()) else {
return Ok(());
};
let mut first_err: Option<CompileError> = None;
let mut lower = |expr: &Expr| -> Expr {
match lower_expr(expr, tables) {
Ok(lowered) => lowered,
Err(e) => {
first_err.get_or_insert(e);
expr.clone()
}
}
};
if let Some(models) = models.as_mut() {
for model in models.values_mut() {
*model = crate::substitute::map_exprs_in_model(model, &mut lower);
lower_model_remainder(model, &mut lower);
}
}
if let Some(systems) = reaction_systems.as_mut() {
for system in systems.values_mut() {
*system = crate::substitute::map_exprs_in_reaction_system(system, &mut lower);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
fn lower_model_remainder(model: &mut Model, lower: &mut impl FnMut(&Expr) -> Expr) {
for eq in model.initialization_equations.iter_mut().flatten() {
eq.lhs = lower(&eq.lhs);
eq.rhs = lower(&eq.rhs);
}
for var in model.variables.values_mut() {
var.for_each_expression_mut(&mut |expr| *expr = lower(expr));
}
for test in model.tests.iter_mut().flatten() {
for assertion in &mut test.assertions {
if let Some(AssertionReference::Expression(expr)) = assertion.reference.as_mut() {
**expr = lower(expr);
}
}
}
}
pub(crate) fn lowered_copy(file: &EsmFile) -> Option<EsmFile> {
if file.function_tables.as_ref().is_none_or(|t| t.is_empty()) {
return None;
}
let mut owned = file.clone();
lower_table_lookups(&mut owned).ok().map(|()| owned)
}
pub(crate) fn lowered_flattened_copy(
flat: &FlattenedSystem,
) -> Result<Option<FlattenedSystem>, CompileError> {
let tables = &flat.function_tables;
if tables.is_empty() {
return Ok(None);
}
let mut first_err: Option<CompileError> = None;
let mut lower = |expr: &Expr| -> Expr {
match lower_expr(expr, tables) {
Ok(lowered) => lowered,
Err(e) => {
first_err.get_or_insert(e);
expr.clone()
}
}
};
let mut owned = flat.clone();
for eq in &mut owned.equations {
eq.lhs = lower(&eq.lhs);
}
owned.map_rhs_per_contribution(&mut lower)?;
owned.continuous_events = flat
.continuous_events
.iter()
.map(|event| map_exprs_in_continuous_event(event, &mut lower))
.collect();
owned.discrete_events = flat
.discrete_events
.iter()
.map(|event| map_exprs_in_discrete_event(event, &mut lower))
.collect();
for (_, rhs) in &mut owned.field_ics {
*rhs = lower(rhs);
}
for variables in [
&mut owned.state_variables,
&mut owned.parameters,
&mut owned.observed_variables,
&mut owned.algebraic_variables,
&mut owned.brownian_parameters,
&mut owned.discrete_parameters,
] {
for var in variables.values_mut() {
var.for_each_expression_mut(&mut |expr| *expr = lower(expr));
}
}
match first_err {
Some(e) => Err(e),
None => Ok(Some(owned)),
}
}
fn has_table_lookup(expr: &Expr) -> bool {
match expr {
Expr::Operator(node) => {
node.op == TABLE_LOOKUP || node.any_child(&mut |child| has_table_lookup(child))
}
_ => false,
}
}
fn lower_expr(expr: &Expr, tables: &IndexMap<String, FunctionTable>) -> Result<Expr, CompileError> {
if !has_table_lookup(expr) {
return Ok(expr.clone());
}
let Expr::Operator(node) = expr else {
return Ok(expr.clone());
};
let mut first_err: Option<CompileError> = None;
let rebuilt = node.map_children(&mut |child| match lower_expr(child, tables) {
Ok(lowered) => lowered,
Err(e) => {
first_err.get_or_insert(e);
child.clone()
}
});
if let Some(e) = first_err {
return Err(e);
}
if rebuilt.op != TABLE_LOOKUP {
return Ok(Expr::operator(rebuilt));
}
lower_node(&rebuilt, tables)
}
fn lower_node(
node: &ExpressionNode,
tables: &IndexMap<String, FunctionTable>,
) -> Result<Expr, CompileError> {
let table_id = node.table.as_deref().ok_or_else(|| {
err(
codes::TABLE_LOOKUP_UNKNOWN_TABLE,
"a `table_lookup` node carries no `table` id (esm-spec §9.5.2)",
)
})?;
let table = tables.get(table_id).ok_or_else(|| {
err(
codes::TABLE_LOOKUP_UNKNOWN_TABLE,
format!(
"`table_lookup` references table `{table_id}`, which the document's \
`function_tables` block does not declare"
),
)
})?;
let strict = (table.out_of_bounds.as_deref() == Some("error")).then_some(table_id);
let inputs = axis_inputs(node, table, table_id)?;
let output = output_index(node, table, table_id)?;
let data = output_slice(table, output, table_id)?;
let kind = table.interpolation.as_deref().unwrap_or("linear");
match (kind, table.axes.as_slice()) {
("linear", [x]) => Ok(closed_fn(
strict,
"interp.linear",
vec![
const_expr(data.clone()),
axis_const(x, table_id)?,
inputs[0].clone(),
],
)),
("bilinear", [x, y]) => Ok(closed_fn(
strict,
"interp.bilinear",
vec![
const_expr(data.clone()),
axis_const(x, table_id)?,
axis_const(y, table_id)?,
inputs[0].clone(),
inputs[1].clone(),
],
)),
("nearest", [x]) => Ok(Expr::operator(ExpressionNode {
op: "index".to_string(),
args: vec![
const_expr(data.clone()),
closed_fn(
strict,
"interp.searchsorted",
vec![inputs[0].clone(), axis_const(x, table_id)?],
),
],
..Default::default()
})),
_ => Err(err(
codes::TABLE_INTERPOLATION_AXES_MISMATCH,
format!(
"table `{table_id}` declares `interpolation: \"{kind}\"` over {} axes; \
`linear` and `nearest` require 1, `bilinear` requires 2 (esm-spec §9.5.1)",
table.axes.len()
),
)),
}
}
fn axis_inputs(
node: &ExpressionNode,
table: &FunctionTable,
table_id: &str,
) -> Result<Vec<Expr>, CompileError> {
if !node.args.is_empty() {
return Err(err(
codes::TABLE_LOOKUP_AXIS_NAME_MISMATCH,
format!(
"`table_lookup` on table `{table_id}` carries {} positional `args`; the per-axis \
inputs live under `axes` and `args` MUST be empty (esm-spec §9.5.2)",
node.args.len()
),
));
}
let axes = node.axes.as_ref();
let supplied = axes.map_or(0, |a| a.len());
let mut out = Vec::with_capacity(table.axes.len());
for axis in &table.axes {
let Some(input) = axes.and_then(|a| a.get(&axis.name)) else {
return Err(err(
codes::TABLE_LOOKUP_AXIS_NAME_MISMATCH,
format!(
"`table_lookup` on table `{table_id}` supplies no input for its declared \
axis `{}`",
axis.name
),
));
};
out.push(input.clone());
}
if supplied != table.axes.len() {
let declared: Vec<&str> = table.axes.iter().map(|a| a.name.as_str()).collect();
return Err(err(
codes::TABLE_LOOKUP_AXIS_NAME_MISMATCH,
format!(
"`table_lookup` on table `{table_id}` supplies {supplied} axis inputs but the \
table declares {} ({}); the key sets must match exactly (esm-spec §9.5.2)",
declared.len(),
declared.join(", ")
),
));
}
Ok(out)
}
fn output_index(
node: &ExpressionNode,
table: &FunctionTable,
table_id: &str,
) -> Result<usize, CompileError> {
let out_of_range = |detail: String| err(codes::TABLE_LOOKUP_OUTPUT_OUT_OF_RANGE, detail);
match (&node.output, table.outputs.as_deref()) {
(None, _) => Ok(0),
(Some(Value::Number(n)), outputs) => {
let Some(i) = n.as_u64().map(|i| i as usize) else {
return Err(out_of_range(format!(
"`table_lookup.output` on table `{table_id}` is {n}; an integer output index \
must be ≥ 0"
)));
};
match outputs {
Some(names) if i < names.len() => Ok(i),
Some(names) => Err(out_of_range(format!(
"`table_lookup.output` {i} on table `{table_id}` is out of range: the table \
declares {} outputs",
names.len()
))),
None if i == 0 => Ok(0),
None => Err(out_of_range(format!(
"`table_lookup.output` {i} on table `{table_id}`, which declares no `outputs` \
and is therefore single-output"
))),
}
}
(Some(Value::String(s)), Some(names)) => {
names.iter().position(|name| name == s).ok_or_else(|| {
out_of_range(format!(
"`table_lookup.output` \"{s}\" is not one of table `{table_id}`'s outputs \
({})",
names.join(", ")
))
})
}
(Some(Value::String(s)), None) => Err(out_of_range(format!(
"`table_lookup.output` \"{s}\" names an output, but table `{table_id}` declares no \
`outputs` list"
))),
(Some(other), _) => Err(out_of_range(format!(
"`table_lookup.output` on table `{table_id}` must be a non-negative integer or an \
output name, not {other}"
))),
}
}
fn output_slice<'a>(
table: &'a FunctionTable,
output: usize,
table_id: &str,
) -> Result<&'a Value, CompileError> {
if table.outputs.is_none() {
return Ok(&table.data);
}
table
.data
.as_array()
.and_then(|rows| rows.get(output))
.ok_or_else(|| {
err(
codes::TABLE_DATA_SHAPE_MISMATCH,
format!(
"table `{table_id}`: `data` has no row {output} for the selected output — its \
leading dimension must equal `len(outputs)` (esm-spec §9.5.1)"
),
)
})
}
fn axis_const(axis: &FunctionTableAxis, table_id: &str) -> Result<Expr, CompileError> {
let values = serde_json::to_value(&axis.values).map_err(|_| {
err(
codes::TABLE_AXIS_NAN,
format!(
"table `{table_id}`: axis `{}` carries a non-finite value; axis `values` must be \
strictly-increasing FINITE floats (esm-spec §9.5.1)",
axis.name
),
)
})?;
Ok(const_expr(values))
}
fn const_expr(value: Value) -> Expr {
Expr::operator(ExpressionNode {
op: "const".to_string(),
value: Some(value),
..Default::default()
})
}
fn closed_fn(strict: Option<&str>, name: &str, args: Vec<Expr>) -> Expr {
Expr::operator(ExpressionNode {
op: "fn".to_string(),
name: Some(name.to_string()),
table: strict.map(str::to_string),
args,
..Default::default()
})
}
pub(crate) fn strict_table(node: &ExpressionNode) -> Option<&str> {
if node.op == "fn" {
node.table.as_deref()
} else {
None
}
}
pub(crate) fn out_of_bounds_fault(
table_id: &str,
which: usize,
axis: &[f64],
x: f64,
) -> Option<String> {
let (&lo, &hi) = (axis.first()?, axis.last()?);
(x < lo || x > hi).then(|| {
format!(
"{}: table `{table_id}` declares `out_of_bounds: \"error\"`, and the query {x} on its \
axis {which} lies outside the axis range [{lo}, {hi}] (esm-spec §9.5.1)",
codes::TABLE_LOOKUP_OUT_OF_BOUNDS
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::load_string;
const FIXTURE: &str = r#"{
"esm": "1.0.0",
"metadata": { "name": "tl", "authors": ["test"] },
"function_tables": {
"t_prof": {
"axes": [{"name": "p", "values": [1.0, 2.0, 3.0, 4.0]}],
"interpolation": "linear",
"data": [10.0, 20.0, 30.0, 40.0]
}
},
"models": {
"M": {
"variables": {
"y": {"type": "unknown", "default": 0.0},
"p": {"type": "parameter", "default": 2.5}
},
"equations": [
{"lhs": "y",
"rhs": {"op": "table_lookup", "table": "t_prof", "axes": {"p": "p"}, "args": []}}
]
}
}
}"#;
fn rhs(file: &EsmFile) -> Expr {
file.models.as_ref().unwrap()["M"].equations[0].rhs.clone()
}
#[test]
fn lowers_a_one_axis_linear_lookup_to_the_spec_form() {
let mut file = load_string(FIXTURE).expect("loads");
lower_table_lookups(&mut file).expect("lowers");
let expected = serde_json::json!({
"op": "fn",
"name": "interp.linear",
"args": [
{"op": "const", "args": [], "value": [10.0, 20.0, 30.0, 40.0]},
{"op": "const", "args": [], "value": [1.0, 2.0, 3.0, 4.0]},
"p"
]
});
assert_eq!(serde_json::to_value(rhs(&file)).unwrap(), expected);
}
const BILINEAR: &str = r#"{
"esm": "1.0.0",
"metadata": { "name": "tl2", "authors": ["test"] },
"function_tables": {
"F_actinic": {
"axes": [
{"name": "P", "values": [10.0, 100.0, 1000.0]},
{"name": "cos_sza", "values": [0.1, 0.5, 1.0]}
],
"interpolation": "bilinear",
"outputs": ["NO2", "O3", "HCHO"],
"data": [
[[1.0, 1.5, 2.0], [1.1, 1.6, 2.1], [1.2, 1.7, 2.2]],
[[2.0, 2.5, 3.0], [2.1, 2.6, 3.1], [2.2, 2.7, 3.2]],
[[3.0, 3.5, 4.0], [3.1, 3.6, 4.1], [3.2, 3.7, 4.2]]
]
}
},
"models": {
"M": {
"variables": {
"j_NO2": {"type": "unknown", "default": 0.0},
"j_O3": {"type": "unknown", "default": 0.0},
"P_atm": {"type": "parameter", "default": 100.0},
"cos_sza": {"type": "parameter", "default": 0.5}
},
"equations": [
{"lhs": "j_NO2",
"rhs": {"op": "table_lookup", "table": "F_actinic",
"axes": {"P": "P_atm", "cos_sza": "cos_sza"},
"output": "NO2", "args": []}},
{"lhs": "j_O3",
"rhs": {"op": "table_lookup", "table": "F_actinic",
"axes": {"P": "P_atm", "cos_sza": "cos_sza"},
"output": 1, "args": []}}
]
}
}
}"#;
#[test]
fn lowers_a_multi_output_bilinear_lookup_to_the_spec_form() {
let mut file = load_string(BILINEAR).expect("loads");
lower_table_lookups(&mut file).expect("lowers");
let eqs = &file.models.as_ref().unwrap()["M"].equations;
let by_name = serde_json::to_value(eqs[0].rhs.clone()).unwrap();
assert_eq!(by_name["op"], "fn");
assert_eq!(by_name["name"], "interp.bilinear");
assert_eq!(
by_name["args"][0]["value"],
serde_json::json!([[1.0, 1.5, 2.0], [1.1, 1.6, 2.1], [1.2, 1.7, 2.2]]),
"output \"NO2\" is the first row"
);
assert_eq!(
by_name["args"][1]["value"],
serde_json::json!([10.0, 100.0, 1000.0])
);
assert_eq!(
by_name["args"][2]["value"],
serde_json::json!([0.1, 0.5, 1.0])
);
assert_eq!(by_name["args"][3], serde_json::json!("P_atm"));
assert_eq!(by_name["args"][4], serde_json::json!("cos_sza"));
let by_index = serde_json::to_value(eqs[1].rhs.clone()).unwrap();
assert_eq!(
by_index["args"][0]["value"],
serde_json::json!([[2.0, 2.5, 3.0], [2.1, 2.6, 3.1], [2.2, 2.7, 3.2]]),
"output 1 is the second row"
);
}
#[test]
fn lowering_is_idempotent() {
let mut file = load_string(FIXTURE).expect("loads");
lower_table_lookups(&mut file).expect("lowers");
let once = rhs(&file);
lower_table_lookups(&mut file).expect("lowers again");
assert_eq!(once, rhs(&file));
}
fn assert_refused_with(source: &str, code: &str) {
let mut file = load_string(source).expect("loads");
let e = lower_table_lookups(&mut file).expect_err("must be refused");
assert!(e.to_string().starts_with(code), "expected {code}, got: {e}");
}
#[test]
fn an_out_of_range_output_is_a_named_diagnostic() {
assert_refused_with(
&BILINEAR.replace("\"output\": 1,", "\"output\": 7,"),
codes::TABLE_LOOKUP_OUTPUT_OUT_OF_RANGE,
);
}
#[test]
fn an_unknown_table_is_a_named_diagnostic_not_a_panic() {
assert_refused_with(
&FIXTURE.replace("\"table\": \"t_prof\"", "\"table\": \"nope\""),
codes::TABLE_LOOKUP_UNKNOWN_TABLE,
);
}
#[test]
fn a_misnamed_axis_is_a_named_diagnostic() {
assert_refused_with(
&FIXTURE.replace("\"axes\": {\"p\": \"p\"}", "\"axes\": {\"q\": \"p\"}"),
codes::TABLE_LOOKUP_AXIS_NAME_MISMATCH,
);
}
#[test]
fn an_error_out_of_bounds_table_lowers_to_the_marked_spec_form() {
let mut file = load_string(&FIXTURE.replace(
"\"interpolation\": \"linear\",",
"\"interpolation\": \"linear\", \"out_of_bounds\": \"error\",",
))
.expect("loads");
lower_table_lookups(&mut file).expect("`error` is implemented");
let lowered = serde_json::to_value(rhs(&file)).unwrap();
assert_eq!(lowered["name"], "interp.linear");
assert_eq!(lowered["table"], "t_prof", "the strict-table marker");
let mut clamp = load_string(FIXTURE).expect("loads");
lower_table_lookups(&mut clamp).expect("lowers");
let mut unmarked = lowered.clone();
unmarked.as_object_mut().unwrap().remove("table");
assert_eq!(unmarked, serde_json::to_value(rhs(&clamp)).unwrap());
}
#[test]
fn a_nearest_error_table_marks_its_searchsorted() {
let mut file = load_string(&FIXTURE.replace(
"\"interpolation\": \"linear\",",
"\"interpolation\": \"nearest\", \"out_of_bounds\": \"error\",",
))
.expect("loads");
lower_table_lookups(&mut file).expect("lowers");
let lowered = serde_json::to_value(rhs(&file)).unwrap();
assert_eq!(lowered["op"], "index");
assert!(lowered.get("table").is_none());
assert_eq!(lowered["args"][1]["name"], "interp.searchsorted");
assert_eq!(lowered["args"][1]["table"], "t_prof");
}
#[test]
fn out_of_bounds_is_strictly_outside_the_end_knots() {
let axis = [1.0, 2.0, 4.0];
for x in [1.0, 1.5, 4.0, f64::NAN] {
assert_eq!(out_of_bounds_fault("t", 1, &axis, x), None, "x = {x}");
}
for x in [0.999, -1.0, 4.0000001, f64::INFINITY, f64::NEG_INFINITY] {
let msg = out_of_bounds_fault("t", 2, &axis, x).expect("out of range");
assert!(
msg.starts_with("table_lookup_out_of_bounds: table `t`"),
"{msg}"
);
assert!(msg.contains("axis 2"), "{msg}");
}
}
#[test]
fn an_explicit_clamp_out_of_bounds_mode_still_lowers() {
let source = FIXTURE.replace(
"\"interpolation\": \"linear\",",
"\"interpolation\": \"linear\", \"out_of_bounds\": \"clamp\",",
);
let mut file = load_string(&source).expect("loads");
lower_table_lookups(&mut file).expect("clamp is implemented");
}
#[test]
fn the_flattened_pass_reaches_every_expression_position() {
let file = load_string(FIXTURE).expect("loads");
let mut flat = crate::flatten::flatten(&file).expect("flattens");
let lookup = serde_json::json!(
{"op": "table_lookup", "table": "t_prof", "axes": {"p": "M.p"}, "args": []});
let affects = serde_json::json!([{"lhs": "M.y", "rhs": lookup}]);
let variable = |update: serde_json::Value| -> crate::types::ModelVariable {
serde_json::from_value(
serde_json::json!({"type": "parameter", "default": 0.0, "update": update}),
)
.expect("variable")
};
flat.continuous_events = vec![
serde_json::from_value(serde_json::json!({
"conditions": [lookup],
"affects": affects,
"affect_neg": affects,
}))
.expect("continuous event"),
];
flat.discrete_events = vec![
serde_json::from_value(serde_json::json!({
"trigger": {"type": "condition", "expression": lookup},
"affects": affects,
}))
.expect("discrete event"),
];
flat.field_ics = vec![(
"M.y".to_string(),
serde_json::from_value(lookup.clone()).expect("initial-field expression"),
)];
let scheduled = variable(
serde_json::json!({"kind": "schedule", "interval": 1.0, "expression": lookup}),
);
let conditional = variable(
serde_json::json!({"kind": "condition", "when": lookup, "expression": lookup}),
);
for (map, var) in [
(&mut flat.state_variables, &scheduled),
(&mut flat.parameters, &scheduled),
(&mut flat.observed_variables, &scheduled),
(&mut flat.algebraic_variables, &conditional),
(&mut flat.brownian_parameters, &conditional),
(&mut flat.discrete_parameters, &conditional),
] {
map.insert("M.u".to_string(), var.clone());
}
let lowered = lowered_flattened_copy(&flat)
.expect("lowers")
.expect("the system declares tables");
let json = serde_json::to_string(&lowered).expect("serializes");
assert!(
!json.contains(TABLE_LOOKUP),
"a table_lookup survived: {json}"
);
let authored = serde_json::to_string(&flat).expect("serializes");
assert!(
authored.contains(TABLE_LOOKUP),
"the caller's system was rewritten"
);
}
#[test]
fn the_flattened_pass_keeps_a_callers_rhs_edit() {
let file = load_string(FIXTURE).expect("loads");
let mut flat = crate::flatten::flatten(&file).expect("flattens");
let origins = flat.equation_contributions[0][0].origins.clone();
let authored = flat.equations[0].rhs.clone();
flat.equations[0].rhs = Expr::operator(crate::types::ExpressionNode {
op: "*".to_string(),
args: vec![Expr::Integer(2), authored],
..Default::default()
});
let lowered = lowered_flattened_copy(&flat)
.expect("lowers")
.expect("the system declares tables");
let Expr::Operator(node) = &lowered.equations[0].rhs else {
panic!("the edited right-hand side is a product");
};
assert_eq!(node.op, "*", "the caller's edit was reverted");
assert_eq!(node.args[0], Expr::Integer(2));
assert!(
matches!(&node.args[1], Expr::Operator(n) if n.op == "fn"),
"the lookup inside the edit is lowered: {:?}",
node.args[1]
);
let contributions = &lowered.equation_contributions[0];
assert_eq!(contributions.len(), 1);
assert_eq!(contributions[0].expr, lowered.equations[0].rhs);
assert_eq!(contributions[0].origins, origins);
}
#[test]
fn the_flattened_pass_refuses_an_out_of_step_contribution_list() {
let file = load_string(FIXTURE).expect("loads");
let mut flat = crate::flatten::flatten(&file).expect("flattens");
let extra = flat.equation_contributions[0].clone();
flat.equation_contributions.push(extra);
match lowered_flattened_copy(&flat) {
Err(CompileError::Flatten(crate::flatten::FlattenError::ContributionsOutOfStep {
field: "equation_contributions",
entries: 2,
equations: 1,
})) => {}
other => panic!("expected ContributionsOutOfStep, got {other:?}"),
}
}
#[test]
fn a_document_with_no_tables_is_left_alone() {
let source = r#"{
"esm": "1.0.0",
"metadata": { "name": "plain", "authors": ["test"] },
"models": { "M": {
"variables": {"x": {"type": "unknown", "default": 1.0}},
"equations": [{"lhs": {"op": "D", "args": ["x"], "wrt": "t"},
"rhs": {"op": "neg", "args": ["x"]}}]
}}
}"#;
let mut file = load_string(source).expect("loads");
let before = rhs(&file);
lower_table_lookups(&mut file).expect("no-op");
assert_eq!(before, rhs(&file));
}
}