use crate::types::*;
use std::fmt;
const ELEMENTS: &[&str; 118] = &[
"H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl",
"Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As",
"Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In",
"Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb",
"Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl",
"Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk",
"Cf", "Es", "Fm", "Md", "No", "Lr", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn", "Nh",
"Fl", "Mc", "Lv", "Ts", "Og",
];
const UNICODE_SUBSCRIPTS: [char; 10] = ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉'];
const UNICODE_SUPERSCRIPTS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
const PRECEDENCE: &[(&str, i32)] = &[
("or", 1),
("and", 2),
("==", 3),
("!=", 3),
("=", 3),
("<", 3),
(">", 3),
("<=", 3),
(">=", 3),
("+", 4),
("-", 4),
("*", 5),
("/", 5),
("not", 6),
("^", 7),
];
const ASSOCIATIVE_OPS: &[&str] = &["+", "*", "and", "or"];
const UMINUS_OPERAND_PARENT_PREC: i32 = 4;
fn render_operand(op: &str, op_prec: i32, arg: &Expr, is_right: bool, fmt: Fmt) -> String {
if !is_right {
return render_at(arg, fmt, op_prec - 1);
}
let bare = match arg {
Expr::Operator(n) => {
(n.op == op && ASSOCIATIVE_OPS.contains(&op))
|| (fmt == Fmt::Latex && op == "*" && n.op == "/" && n.args.len() == 2)
}
_ => true,
};
render_at(arg, fmt, if bare { op_prec - 1 } else { op_prec })
}
fn starts_with_literal_power(expr: &Expr) -> bool {
let Expr::Operator(n) = expr else {
return false;
};
match n.op.as_str() {
"^" => matches!(
n.args.first(),
Some(Expr::Integer(_)) | Some(Expr::Number(_))
),
"*" | "/" => n.args.first().is_some_and(starts_with_literal_power),
_ => false,
}
}
fn get_precedence(op: &str) -> i32 {
for (operator, prec) in PRECEDENCE {
if operator == &op {
return *prec;
}
}
0 }
const GREEK_TABLE: &[(&str, char, bool)] = &[
("alpha", 'α', false),
("beta", 'β', false),
("gamma", 'γ', true),
("delta", 'δ', true),
("epsilon", 'ε', false),
("zeta", 'ζ', false),
("eta", 'η', false),
("theta", 'θ', true),
("iota", 'ι', false),
("kappa", 'κ', false),
("lambda", 'λ', true),
("mu", 'μ', false),
("nu", 'ν', false),
("xi", 'ξ', true),
("omicron", 'ο', false),
("pi", 'π', true),
("rho", 'ρ', false),
("sigma", 'σ', true),
("tau", 'τ', false),
("upsilon", 'υ', true),
("phi", 'φ', true),
("chi", 'χ', false),
("psi", 'ψ', true),
("omega", 'ω', true),
];
fn greek_name_to_char(name: &str) -> Option<char> {
GREEK_TABLE
.iter()
.find(|(n, _, _)| *n == name)
.map(|&(_, c, _)| c)
}
fn greek_char_to_name(c: char) -> Option<&'static str> {
GREEK_TABLE
.iter()
.find(|&&(_, ch, _)| ch == c)
.map(|&(n, _, _)| n)
}
fn format_chemical_subscripts(s: &str) -> String {
if let Some(c) = greek_name_to_char(s) {
return c.to_string();
}
let (body, charge) = split_charge(s);
let mut result = String::new();
let chars: Vec<char> = body.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch.is_alphabetic() {
if i + 1 < chars.len() {
let two_letter = format!("{}{}", ch, chars[i + 1]);
if ELEMENTS.contains(&two_letter.as_str()) {
result.push_str(&two_letter);
i += 2;
while i < chars.len() && chars[i].is_ascii_digit() {
let digit = chars[i].to_digit(10).unwrap();
result.push(UNICODE_SUBSCRIPTS[digit as usize]);
i += 1;
}
continue;
}
}
if ELEMENTS.contains(&ch.to_string().as_str()) {
result.push(ch);
i += 1;
while i < chars.len() && chars[i].is_ascii_digit() {
let digit = chars[i].to_digit(10).unwrap();
result.push(UNICODE_SUBSCRIPTS[digit as usize]);
i += 1;
}
continue;
}
result.push(ch);
i += 1;
} else {
result.push(ch);
i += 1;
if ch == ')' || ch == ']' {
while i < chars.len() && chars[i].is_ascii_digit() {
let digit = chars[i].to_digit(10).unwrap();
result.push(UNICODE_SUBSCRIPTS[digit as usize]);
i += 1;
}
}
}
}
result.push_str(&to_superscript_str(&charge));
result
}
fn split_charge(s: &str) -> (&str, String) {
let last = match s.chars().last() {
Some(c) => c,
None => return (s, String::new()),
};
if last == '+' || last == '-' {
let without_sign = &s[..s.len() - 1];
let mag: String = without_sign
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let body = &without_sign[..without_sign.len() - mag.len()];
if !mag.is_empty() && is_chemical_formula(body) {
return (body, format!("{mag}{last}"));
}
}
if last.is_ascii_digit() {
let mag: String = s
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let before = &s[..s.len() - mag.len()];
if let Some(sign) = before.chars().last()
&& (sign == '+' || sign == '-')
{
let body = &before[..before.len() - 1];
if is_chemical_formula(body) {
return (body, format!("{mag}{sign}"));
}
}
}
(s, String::new())
}
enum FloatParts {
Plain(String),
Scientific { mantissa: String, exponent: String },
Inf(bool),
NaN,
}
const SCI_NOTATION_MIN: f64 = 0.01;
const SCI_NOTATION_MAX: f64 = 10000.0;
fn format_display_float(n: f64) -> FloatParts {
if n.is_nan() {
return FloatParts::NaN;
}
if n.is_infinite() {
return FloatParts::Inf(n < 0.0);
}
if n == 0.0 {
return FloatParts::Plain("0".to_string());
}
let abs_n = n.abs();
if !(SCI_NOTATION_MIN..SCI_NOTATION_MAX).contains(&abs_n) {
let sci = shortest_sci(n);
if let Some(e_pos) = sci.find('e') {
let mut mantissa = sci[..e_pos].to_string();
if !mantissa.contains('.') {
mantissa.push_str(".0");
}
return FloatParts::Scientific {
mantissa,
exponent: sci[e_pos + 1..].to_string(),
};
}
return FloatParts::Plain(sci);
}
if n.fract() == 0.0 {
return FloatParts::Plain(format!("{}", n as i64));
}
let (digits, exp) = shortest_digit_count(n);
let frac = usize::try_from(digits as i64 - 1 - exp).unwrap_or(0);
FloatParts::Plain(format!("{n:.frac$}"))
}
fn shortest_digit_count(n: f64) -> (usize, i64) {
let s = format!("{n:e}");
let e_pos = s.find('e').expect("`{:e}` always has an exponent");
let digits = s[..e_pos].trim_start_matches('-').replace('.', "").len();
let exp = s[e_pos + 1..]
.parse()
.expect("`{:e}` exponent is an integer");
(digits, exp)
}
fn shortest_sci(n: f64) -> String {
let (digits, _) = shortest_digit_count(n);
let prec = digits - 1;
format!("{n:.prec$e}")
}
fn unicode_minus(s: String) -> String {
match s.strip_prefix('-') {
Some(rest) => format!("−{rest}"),
None => s,
}
}
fn format_number_unicode(n: f64) -> String {
match format_display_float(n) {
FloatParts::Plain(s) => unicode_minus(s),
FloatParts::Scientific { mantissa, exponent } => {
format!(
"{}×10{}",
unicode_minus(mantissa),
to_superscript_str(&exponent)
)
}
FloatParts::Inf(neg) => if neg { "−∞" } else { "∞" }.to_string(),
FloatParts::NaN => "NaN".to_string(),
}
}
fn format_number_latex(n: f64) -> String {
match format_display_float(n) {
FloatParts::Plain(s) => s,
FloatParts::Scientific { mantissa, exponent } => {
format!("{mantissa} \\times 10^{{{exponent}}}")
}
FloatParts::Inf(neg) => if neg { "-\\infty" } else { "\\infty" }.to_string(),
FloatParts::NaN => "\\text{NaN}".to_string(),
}
}
fn format_number_ascii(n: f64) -> String {
match format_display_float(n) {
FloatParts::Plain(s) => s,
FloatParts::Scientific { mantissa, exponent } => format!("{mantissa}e{exponent}"),
FloatParts::Inf(neg) => if neg { "-inf" } else { "inf" }.to_string(),
FloatParts::NaN => "NaN".to_string(),
}
}
fn nonfinite_string(name: &str, fmt: Fmt) -> Option<String> {
let n = match name {
"Infinity" => f64::INFINITY,
"-Infinity" => f64::NEG_INFINITY,
"NaN" => f64::NAN,
_ => return None,
};
Some(match fmt {
Fmt::Unicode => format_number_unicode(n),
Fmt::Latex => format_number_latex(n),
Fmt::Ascii => format_number_ascii(n),
})
}
impl fmt::Display for Expr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_unicode())
}
}
impl Expr {
pub fn to_unicode(&self) -> String {
self.to_unicode_with_precedence(0)
}
fn to_unicode_with_precedence(&self, parent_prec: i32) -> String {
match self {
Expr::Number(n) => format_number_unicode(*n),
Expr::Integer(n) => format_number_unicode(*n as f64),
Expr::Variable(name) => nonfinite_string(name, Fmt::Unicode)
.unwrap_or_else(|| format_chemical_subscripts(name)),
Expr::Operator(node) => format_operator(node, Fmt::Unicode, parent_prec),
}
}
}
fn call_form(op_symbol: &str, args: &[Expr], render: impl Fn(&Expr) -> String) -> String {
format!(
"{}({})",
op_symbol,
args.iter().map(render).collect::<Vec<_>>().join(", ")
)
}
fn pick(fmt: Fmt, unicode: &'static str, latex: &'static str, ascii: &'static str) -> &'static str {
match fmt {
Fmt::Unicode => unicode,
Fmt::Latex => latex,
Fmt::Ascii => ascii,
}
}
fn comparison_or_call(
op: &str,
op_prec: i32,
args: &[Expr],
infix: &str,
call: &str,
fmt: Fmt,
) -> String {
if args.len() == 2 {
format!(
"{} {} {}",
render_operand(op, op_prec, &args[0], false, fmt),
infix,
render_operand(op, op_prec, &args[1], true, fmt)
)
} else {
call_form(call, args, |a| render_at(a, fmt, 0))
}
}
fn infix_chain(op: &str, op_prec: i32, args: &[Expr], sep: &str, fmt: Fmt) -> String {
args.iter()
.enumerate()
.map(|(i, a)| render_operand(op, op_prec, a, i > 0, fmt))
.collect::<Vec<_>>()
.join(sep)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fmt {
Unicode,
Latex,
Ascii,
}
fn render_fmt(expr: &Expr, fmt: Fmt) -> String {
match fmt {
Fmt::Unicode => to_unicode(expr),
Fmt::Latex => to_latex(expr),
Fmt::Ascii => to_ascii(expr),
}
}
fn render_operator(node: &ExpressionNode, fmt: Fmt) -> String {
format_operator(node, fmt, 0)
}
fn latex_name(name: &str) -> String {
name.replace('_', "\\_")
}
fn integer_exponent(e: &Expr) -> Option<i64> {
match e {
Expr::Integer(n) => Some(*n),
Expr::Number(n) if n.fract() == 0.0 => Some(*n as i64),
_ => None,
}
}
fn to_superscript_str(s: &str) -> String {
s.chars()
.map(|c| match c {
'0'..='9' => UNICODE_SUPERSCRIPTS[c.to_digit(10).unwrap() as usize],
'-' => '⁻',
'+' => '⁺',
other => other,
})
.collect()
}
fn greek_to_ascii(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match greek_char_to_name(c) {
Some(name) => out.push_str(name),
None => out.push(c),
}
}
out
}
fn greek_to_latex(name: &str) -> Option<String> {
for &(n, c, capital_has_latex) in GREEK_TABLE {
if name == n || name.chars().eq(std::iter::once(c)) {
return Some(format!("\\{n}"));
}
let initial = n.chars().next().expect("table names are non-empty");
if capital_has_latex && name.strip_prefix(initial.to_ascii_uppercase()) == Some(&n[1..]) {
return Some(format!("\\{name}"));
}
}
None
}
fn expr_raw_string(e: &Expr) -> String {
match e {
Expr::Variable(s) => s.clone(),
Expr::Integer(n) => n.to_string(),
Expr::Number(n) => n.to_string(),
Expr::Operator(node) => node.op.clone(),
}
}
fn wrap_if_op(expr: &Expr, fmt: Fmt) -> String {
let s = render_fmt(expr, fmt);
if matches!(expr, Expr::Operator(_)) {
format!("({s})")
} else {
s
}
}
fn render_at(expr: &Expr, fmt: Fmt, prec: i32) -> String {
match fmt {
Fmt::Unicode => expr.to_unicode_with_precedence(prec),
Fmt::Latex => to_latex_prec(expr, prec),
Fmt::Ascii => to_ascii_prec(expr, prec),
}
}
fn sum_as_difference(args: &[Expr], op_prec: i32, fmt: Fmt) -> Option<String> {
if args.len() != 2 {
return None;
}
let Expr::Operator(n) = &args[1] else {
return None;
};
if n.op != "-" || n.args.len() != 1 {
return None;
}
let minus = if fmt == Fmt::Unicode { "−" } else { "-" };
Some(format!(
"{} {minus} {}",
render_at(&args[0], fmt, op_prec - 1),
render_at(&n.args[0], fmt, op_prec)
))
}
fn latex_mul_juxtapose(args: &[Expr]) -> bool {
args.iter()
.any(|a| matches!(a, Expr::Variable(v) if v.contains('\\')))
}
fn format_region_bound(bound: &crate::types::RegionBound, fmt: Fmt) -> String {
match bound {
crate::types::RegionBound::Int(i) => i.to_string(),
crate::types::RegionBound::Expr(Expr::Integer(i)) => i.to_string(),
crate::types::RegionBound::Expr(e) => render_fmt(e, fmt),
}
}
fn format_const_value(value: &serde_json::Value, fmt: Fmt) -> String {
match value {
serde_json::Value::Array(arr) => {
let inner = arr
.iter()
.map(|v| format_const_value(v, fmt))
.collect::<Vec<_>>()
.join(", ");
format!("[{inner}]")
}
serde_json::Value::Number(num) => {
if let Some(i) = num.as_i64() {
i.to_string()
} else if let Some(u) = num.as_u64() {
u.to_string()
} else if let Some(f) = num.as_f64() {
format_number_fmt(f, fmt)
} else {
num.to_string()
}
}
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}
fn json_scalar_string(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}
fn aggregate_symbol(semiring: Option<&str>, reduce: &str, fmt: Fmt) -> &'static str {
enum Fam {
Plus,
Times,
Max,
Min,
Bool,
}
let fam = if let Some(sr) = semiring {
match sr {
"max_product" | "max_sum" => Fam::Max,
"min_sum" => Fam::Min,
"bool_and_or" => Fam::Bool,
_ => Fam::Plus,
}
} else {
match reduce {
"*" => Fam::Times,
"max" => Fam::Max,
"min" => Fam::Min,
_ => Fam::Plus,
}
};
let (u, l, a) = match fam {
Fam::Plus => ("Σ", "\\sum", "sum"),
Fam::Times => ("Π", "\\prod", "prod"),
Fam::Max => ("max", "\\max", "max"),
Fam::Min => ("min", "\\min", "min"),
Fam::Bool => ("⋁", "\\bigvee", "any"),
};
match fmt {
Fmt::Unicode => u,
Fmt::Latex => l,
Fmt::Ascii => a,
}
}
fn format_range_spec(rng: &RangeSpec) -> String {
match rng {
RangeSpec::Interval(iv) => format!("{}:{}", iv[0], iv[1]),
RangeSpec::Strided(iv) => format!("{}:{}:{}", iv[0], iv[1], iv[2]),
RangeSpec::IndexSetRef { from, of } => match of {
Some(of) if !of.is_empty() => format!("{}({})", from, of.join(", ")),
_ => from.clone(),
},
RangeSpec::RaggedDyn { offsets, of } => format!("{}({})", offsets, of.join(", ")),
RangeSpec::DerivedDyn { from_faq } => from_faq.clone(),
}
}
fn format_ranges_clause(ranges: &std::collections::HashMap<String, RangeSpec>, fmt: Fmt) -> String {
let in_sym = match fmt {
Fmt::Latex => " \\in ",
Fmt::Unicode => "∈",
Fmt::Ascii => " in ",
};
let mut keys: Vec<&String> = ranges.keys().collect();
keys.sort();
let parts = keys
.iter()
.map(|k| format!("{}{}{}", k, in_sym, format_range_spec(&ranges[*k])))
.collect::<Vec<_>>()
.join(", ");
match fmt {
Fmt::Latex => format!(" \\text{{ where }} \\{{{parts}\\}}"),
_ => format!(" where {{{parts}}}"),
}
}
fn format_join_clause(join: Option<&[JoinClause]>, fmt: Fmt) -> String {
let Some(join) = join.filter(|j| !j.is_empty()) else {
return String::new();
};
let clauses = join
.iter()
.map(|c| {
if let Some(ov) = &c.overlap {
let eps = match ov.eps {
Some(e) => format!(", eps={}", format_number_fmt(e, fmt)),
None => String::new(),
};
return format!(
"overlap(src=[{}], tgt=[{}]{eps})",
ov.src_env.join(", "),
ov.tgt_env.join(", ")
);
}
let mut parts: Vec<String> =
c.on.iter().map(|p| format!("{}={}", p[0], p[1])).collect();
if let Some(syms) = &c.syms {
parts.push(format!("syms=[{}]", syms.join(", ")));
}
parts.join(", ")
})
.collect::<Vec<_>>()
.join("; ");
format!(" join({clauses})")
}
fn format_number_fmt(n: f64, fmt: Fmt) -> String {
match fmt {
Fmt::Unicode => format_number_unicode(n),
Fmt::Latex => format_number_latex(n),
Fmt::Ascii => format_number_ascii(n),
}
}
fn format_aggregate(node: &ExpressionNode, fmt: Fmt) -> String {
let out_idx = node
.output_idx
.as_ref()
.map(|v| v.join(", "))
.unwrap_or_default();
let expr_str = node
.expr
.as_deref()
.map(|e| render_fmt(e, fmt))
.unwrap_or_default();
let semiring = node.semiring.as_deref();
let reduce = node.reduce.as_deref().unwrap_or("+");
let sym = aggregate_symbol(semiring, reduce, fmt);
let idx_part = if fmt == Fmt::Latex {
format!("_{{{out_idx}}}")
} else {
format!("[{out_idx}]")
};
let mut out = format!("{sym}{idx_part} ({expr_str})");
if let Some(ranges) = &node.ranges
&& !ranges.is_empty()
{
out.push_str(&format_ranges_clause(ranges, fmt));
}
out.push_str(&format_join_clause(node.join.as_deref(), fmt));
if let Some(filter) = node.filter.as_deref() {
out.push_str(&format!(" if {}", render_fmt(filter, fmt)));
}
if node.distinct == Some(true) {
out.push_str(" distinct");
}
if let Some(key) = node.key.as_deref() {
out.push_str(&format!(" key={}", render_fmt(key, fmt)));
}
if let Some(id) = node.id.as_deref() {
out.push_str(&format!(" id={id}"));
}
if let Some(sr) = semiring
&& sr != "sum_product"
{
out.push_str(&format!(" [semiring={sr}]"));
}
out
}
fn format_arg_witness(node: &ExpressionNode, fmt: Fmt) -> String {
let arg = node.arg.as_deref().unwrap_or("");
let expr_str = node
.expr
.as_deref()
.map(|e| render_fmt(e, fmt))
.unwrap_or_default();
let idx_part = if fmt == Fmt::Latex {
format!("_{{{arg}}}")
} else {
format!("[{arg}]")
};
let name = if fmt == Fmt::Latex {
format!("\\mathrm{{{}}}", node.op)
} else {
node.op.clone()
};
let mut out = format!("{name}{idx_part} ({expr_str})");
if let Some(ranges) = &node.ranges
&& !ranges.is_empty()
{
out.push_str(&format_ranges_clause(ranges, fmt));
}
out.push_str(&format_join_clause(node.join.as_deref(), fmt));
if let Some(filter) = node.filter.as_deref() {
out.push_str(&format!(" if {}", render_fmt(filter, fmt)));
}
if let Some(id) = node.id.as_deref() {
out.push_str(&format!(" id={id}"));
}
out
}
fn format_structural_op(node: &ExpressionNode, fmt: Fmt) -> Option<String> {
let op = node.op.as_str();
let args = node.args.as_slice();
let eq = if fmt == Fmt::Latex { " = " } else { "=" };
match op {
"const" => Some(format_const_value(
node.value.as_ref().unwrap_or(&serde_json::Value::Null),
fmt,
)),
"true" | "false" => Some(op.to_string()),
"fn" => {
let name = node.name.as_deref().unwrap_or("");
let inner = args
.iter()
.map(|a| render_fmt(a, fmt))
.collect::<Vec<_>>()
.join(", ");
Some(match fmt {
Fmt::Latex => format!("\\mathrm{{{}}}({})", latex_name(name), inner),
_ => format!("{name}({inner})"),
})
}
"enum" => {
let a0 = args.first().map(expr_raw_string).unwrap_or_default();
let a1 = args.get(1).map(expr_raw_string).unwrap_or_default();
let label = format!("{a0}.{a1}");
Some(match fmt {
Fmt::Latex => format!("\\mathrm{{{}}}", latex_name(&label)),
_ => label,
})
}
"index" => {
if args.is_empty() {
return None;
}
let idx = args[1..]
.iter()
.map(|a| render_fmt(a, fmt))
.collect::<Vec<_>>()
.join(", ");
Some(format!("{}[{}]", wrap_if_op(&args[0], fmt), idx))
}
"broadcast" => {
let fn_name = node.broadcast_fn.as_deref()?;
let synth = ExpressionNode {
op: fn_name.to_string(),
args: node.args.clone(),
..Default::default()
};
Some(render_operator(&synth, fmt))
}
"integral" => {
if args.is_empty() {
return None;
}
let f = render_fmt(&args[0], fmt);
let v = node.int_var.as_deref().unwrap_or("x");
let lo = node
.lower
.as_deref()
.map(|e| render_fmt(e, fmt))
.unwrap_or_default();
let hi = node
.upper
.as_deref()
.map(|e| render_fmt(e, fmt))
.unwrap_or_default();
Some(match fmt {
Fmt::Latex => format!("\\int_{{{lo}}}^{{{hi}}} {f} \\, d{v}"),
Fmt::Unicode => format!("∫[{lo}, {hi}] {f} d{v}"),
Fmt::Ascii => format!("integral({f}, {v}, {lo}, {hi})"),
})
}
"table_lookup" => {
let table = node.table.as_deref().unwrap_or("");
let bindings = node
.axes
.as_ref()
.map(|axes| {
let mut keys: Vec<&String> = axes.keys().collect();
keys.sort();
keys.iter()
.map(|k| format!("{}{}{}", k, eq, render_fmt(&axes[*k], fmt)))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let out_str = match &node.output {
Some(v) if !v.is_null() => format!(":{}", json_scalar_string(v)),
_ => String::new(),
};
let name = if fmt == Fmt::Latex {
format!("\\mathrm{{{}}}", latex_name(table))
} else {
table.to_string()
};
Some(format!("{name}[{bindings}]{out_str}"))
}
"apply_expression_template" => {
let name = node.name.as_deref().unwrap_or("");
let inner = node
.bindings
.as_ref()
.map(|b| {
let mut keys: Vec<&String> = b.keys().collect();
keys.sort();
keys.iter()
.map(|k| format!("{}{}{}", k, eq, render_fmt(&b[*k], fmt)))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
Some(match fmt {
Fmt::Latex => format!(
"\\mathrm{{{}}}\\langle {} \\rangle",
latex_name(name),
inner
),
Fmt::Unicode => format!("{name}⟨{inner}⟩"),
Fmt::Ascii => format!("{name}<{inner}>"),
})
}
"makearray" => {
let values = node.values.as_ref();
let parts = node
.regions
.as_ref()
.map(|regions| {
regions
.iter()
.enumerate()
.map(|(i, region)| {
let reg_str = region
.iter()
.map(|dim| {
format!(
"{}:{}",
format_region_bound(&dim[0], fmt),
format_region_bound(&dim[1], fmt)
)
})
.collect::<Vec<_>>()
.join(", ");
let val = values
.and_then(|vs| vs.get(i))
.map(|v| render_fmt(v, fmt))
.unwrap_or_else(|| "?".to_string());
format!("[{reg_str}] = {val}")
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let name = if fmt == Fmt::Latex {
"\\mathrm{makearray}"
} else {
"makearray"
};
Some(format!("{name}({parts})"))
}
"reshape" => {
if args.is_empty() {
return None;
}
let shape = node
.shape
.as_ref()
.map(|s| {
s.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let name = if fmt == Fmt::Latex {
"\\mathrm{reshape}"
} else {
"reshape"
};
Some(format!(
"{}({}, [{}])",
name,
render_fmt(&args[0], fmt),
shape
))
}
"transpose" => {
if args.is_empty() {
return None;
}
if let Some(perm) = &node.perm
&& !perm.is_empty()
{
let p = perm
.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(", ");
let name = if fmt == Fmt::Latex {
"\\mathrm{transpose}"
} else {
"transpose"
};
return Some(format!("{}({}, [{}])", name, render_fmt(&args[0], fmt), p));
}
let a = wrap_if_op(&args[0], fmt);
Some(match fmt {
Fmt::Latex => format!("{a}^{{T}}"),
Fmt::Unicode => format!("{a}ᵀ"),
Fmt::Ascii => format!("transpose({})", render_fmt(&args[0], fmt)),
})
}
"concat" => {
let inner = args
.iter()
.map(|a| render_fmt(a, fmt))
.collect::<Vec<_>>()
.join(", ");
let name = if fmt == Fmt::Latex {
"\\mathrm{concat}"
} else {
"concat"
};
let axis = node.axis.unwrap_or(0);
Some(format!("{name}({inner}, axis={axis})"))
}
"intersect_polygon" | "polygon_intersection_area" => {
let inner = args
.iter()
.map(|a| render_fmt(a, fmt))
.collect::<Vec<_>>()
.join(", ");
let name = if fmt == Fmt::Latex {
format!("\\mathrm{{{}}}", latex_name(op))
} else {
op.to_string()
};
let manifold = node.manifold.as_deref().unwrap_or("");
let id_part = match node.id.as_deref() {
Some(id) => format!(", id={id}"),
None => String::new(),
};
Some(format!("{name}({inner}, manifold={manifold}{id_part})"))
}
"skolem" => {
let label = node.label.as_deref()?;
let inner = args
.iter()
.map(|a| render_fmt(a, fmt))
.collect::<Vec<_>>()
.join(", ");
let name = if fmt == Fmt::Latex {
"\\mathrm{skolem}"
} else {
"skolem"
};
Some(format!("{name}({inner}, label={label})"))
}
"faq" => Some(format_aggregate(node, fmt)),
"argmin" | "argmax" => Some(format_arg_witness(node, fmt)),
_ => None,
}
}
fn format_operator(node: &ExpressionNode, fmt: Fmt, parent_prec: i32) -> String {
if let Some(s) = format_structural_op(node, fmt) {
return s;
}
let op = node.op.as_str();
let args = node.args.as_slice();
let wrt = &node.wrt;
let op_prec = get_precedence(op);
let needs_parens = op_prec > 0 && op_prec <= parent_prec;
let r0 = |arg: &Expr| render_at(arg, fmt, 0);
if fmt == Fmt::Latex && op == "/" && args.len() == 2 {
let frac = format!("\\frac{{{}}}{{{}}}", r0(&args[0]), r0(&args[1]));
return if needs_parens {
format!("({frac})")
} else {
frac
};
}
let result = match op {
"+" => {
if let Some(s) = sum_as_difference(args, op_prec, fmt) {
s
} else if args.len() >= 2 {
infix_chain(op, op_prec, args, " + ", fmt)
} else {
call_form("+", args, r0)
}
}
"-" => {
let minus = pick(fmt, "−", "-", "-");
if args.len() == 1 {
let operand = render_at(&args[0], fmt, UMINUS_OPERAND_PARENT_PREC);
if starts_with_literal_power(&args[0]) {
format!("{minus}({operand})")
} else {
format!("{minus}{operand}")
}
} else if args.len() == 2 {
format!(
"{} {minus} {}",
render_at(&args[0], fmt, op_prec - 1),
render_at(&args[1], fmt, op_prec)
)
} else {
call_form(minus, args, r0)
}
}
"*" => {
if args.len() >= 2 {
let sep: &str = match fmt {
Fmt::Unicode => "·",
Fmt::Ascii => " * ",
Fmt::Latex if latex_mul_juxtapose(args) => " ",
Fmt::Latex => " \\cdot ",
};
infix_chain(op, op_prec, args, sep, fmt)
} else {
call_form(pick(fmt, "·", "\\cdot", "*"), args, r0)
}
}
"/" => {
if args.len() == 2 {
format!(
"{}{}{}",
render_at(&args[0], fmt, op_prec - 1),
pick(fmt, "/", "/", " / "),
render_at(&args[1], fmt, op_prec)
)
} else {
call_form(pick(fmt, "÷", "\\div", "/"), args, r0)
}
}
"^" => {
if args.len() == 2 {
match fmt {
Fmt::Unicode => match integer_exponent(&args[1]) {
Some(n) => format!(
"{}{}",
render_at(&args[0], fmt, op_prec),
to_superscript_str(&n.to_string())
),
None => format!(
"{}^{}",
render_at(&args[0], fmt, op_prec),
render_at(&args[1], fmt, op_prec)
),
},
Fmt::Latex => {
format!("{}^{{{}}}", render_at(&args[0], fmt, op_prec), r0(&args[1]))
}
Fmt::Ascii => format!(
"{}^{}",
render_at(&args[0], fmt, op_prec),
render_at(&args[1], fmt, op_prec)
),
}
} else {
call_form("^", args, r0)
}
}
"D" => {
let wrt_default = wrt
.as_deref()
.unwrap_or(crate::op_registry::STRUCTURAL_DERIVATIVE_WRT);
if let [arg] = args {
let wrt_var = wrt_default;
match fmt {
Fmt::Unicode => format!(
"∂{}/∂{}",
wrap_if_op(arg, fmt),
format_chemical_subscripts(wrt_var)
),
Fmt::Latex => format!(
"\\frac{{\\partial {}}}{{\\partial {}}}",
wrap_if_op(arg, fmt),
wrt_var
),
Fmt::Ascii => format!("D({})/D{wrt_var}", r0(arg)),
}
} else {
call_form("D", args, r0)
}
}
">" => comparison_or_call(op, op_prec, args, ">", ">", fmt),
"<" => comparison_or_call(op, op_prec, args, "<", "<", fmt),
">=" => comparison_or_call(
op,
op_prec,
args,
pick(fmt, "≥", "\\geq", ">="),
pick(fmt, ">=", "\\geq", ">="),
fmt,
),
"<=" => comparison_or_call(
op,
op_prec,
args,
pick(fmt, "≤", "\\leq", "<="),
pick(fmt, "<=", "\\leq", "<="),
fmt,
),
"=" | "==" => comparison_or_call(
op,
op_prec,
args,
pick(fmt, "=", "=", "=="),
pick(fmt, "=", "=", "=="),
fmt,
),
"!=" => comparison_or_call(
op,
op_prec,
args,
pick(fmt, "≠", "\\neq", "!="),
pick(fmt, "!=", "\\neq", "!="),
fmt,
),
"and" => {
if args.len() >= 2 {
infix_chain(
op,
op_prec,
args,
pick(fmt, " ∧ ", " \\land ", " and "),
fmt,
)
} else {
call_form(pick(fmt, "and", "\\land", "and"), args, r0)
}
}
"or" => {
if args.len() >= 2 {
infix_chain(op, op_prec, args, pick(fmt, " ∨ ", " \\lor ", " or "), fmt)
} else {
call_form(pick(fmt, "or", "\\lor", "or"), args, r0)
}
}
"not" => {
if args.len() == 1 {
let prefix = pick(fmt, "¬", "\\neg ", "not ");
if matches!(&args[0], Expr::Operator(_)) {
format!("{prefix}({})", r0(&args[0]))
} else {
format!("{prefix}{}", r0(&args[0]))
}
} else {
call_form(pick(fmt, "not", "\\neg", "not"), args, r0)
}
}
"exp" => match fmt {
Fmt::Latex => {
if let [arg] = args {
if matches!(arg, Expr::Operator(_)) {
format!("\\exp\\left({}\\right)", r0(arg))
} else {
format!("\\exp({})", r0(arg))
}
} else {
call_form("\\exp", args, r0)
}
}
_ => call_form("exp", args, r0),
},
"ifelse" => match fmt {
Fmt::Latex if args.len() == 3 => format!(
"\\begin{{cases}} {} & \\text{{if }} {} \\\\ {} & \\text{{otherwise}} \\end{{cases}}",
r0(&args[1]),
r0(&args[0]),
r0(&args[2])
),
Fmt::Latex => call_form("\\mathrm{ifelse}", args, r0),
_ => call_form("ifelse", args, r0),
},
"log" => {
if args.len() == 1 {
call_form(pick(fmt, "ln", "\\ln", "log"), args, r0)
} else {
call_form(pick(fmt, "log", "\\log", "log"), args, r0)
}
}
"log10" => {
let sym = if args.len() == 1 {
pick(fmt, "log₁₀", "\\log_{10}", "log10")
} else {
pick(fmt, "log10", "\\log_{10}", "log10")
};
call_form(sym, args, r0)
}
"sqrt" => match fmt {
Fmt::Unicode => {
if let [arg] = args {
if matches!(arg, Expr::Operator(_)) {
format!("√({})", r0(arg))
} else {
format!("√{}", r0(arg))
}
} else {
call_form("sqrt", args, r0)
}
}
Fmt::Latex => format!(
"\\sqrt{{{}}}",
args.iter().map(&r0).collect::<Vec<_>>().join(", ")
),
Fmt::Ascii => call_form("sqrt", args, r0),
},
"asin" => {
let sym = if args.len() == 1 {
pick(fmt, "arcsin", "\\arcsin", "asin")
} else {
pick(fmt, "asin", "\\arcsin", "asin")
};
call_form(sym, args, r0)
}
"acos" => {
let sym = if args.len() == 1 {
pick(fmt, "arccos", "\\arccos", "acos")
} else {
pick(fmt, "acos", "\\arccos", "acos")
};
call_form(sym, args, r0)
}
"atan" => {
let sym = if args.len() == 1 {
pick(fmt, "arctan", "\\arctan", "atan")
} else {
pick(fmt, "atan", "\\arctan", "atan")
};
call_form(sym, args, r0)
}
"abs" => match fmt {
Fmt::Unicode => {
if args.len() == 1 {
format!("|{}|", r0(&args[0]))
} else {
call_form("abs", args, r0)
}
}
Fmt::Latex => format!("|{}|", args.iter().map(&r0).collect::<Vec<_>>().join(", ")),
Fmt::Ascii => call_form("abs", args, r0),
},
"sign" => call_form(pick(fmt, "sgn", "\\mathrm{sgn}", "sign"), args, r0),
"floor" => match fmt {
Fmt::Unicode => {
if args.len() == 1 {
format!("⌊{}⌋", r0(&args[0]))
} else {
call_form("floor", args, r0)
}
}
Fmt::Latex => format!(
"\\lfloor {} \\rfloor",
args.iter().map(&r0).collect::<Vec<_>>().join(", ")
),
Fmt::Ascii => call_form("floor", args, r0),
},
"ceil" => match fmt {
Fmt::Unicode => {
if args.len() == 1 {
format!("⌈{}⌉", r0(&args[0]))
} else {
call_form("ceil", args, r0)
}
}
Fmt::Latex => format!(
"\\lceil {} \\rceil",
args.iter().map(&r0).collect::<Vec<_>>().join(", ")
),
Fmt::Ascii => call_form("ceil", args, r0),
},
"asinh" => {
let sym = if args.len() == 1 {
pick(fmt, "sinh⁻¹", "\\sinh^{-1}", "asinh")
} else {
pick(fmt, "asinh", "\\sinh^{-1}", "asinh")
};
call_form(sym, args, r0)
}
"acosh" => {
let sym = if args.len() == 1 {
pick(fmt, "cosh⁻¹", "\\cosh^{-1}", "acosh")
} else {
pick(fmt, "acosh", "\\cosh^{-1}", "acosh")
};
call_form(sym, args, r0)
}
"atanh" => {
let sym = if args.len() == 1 {
pick(fmt, "tanh⁻¹", "\\tanh^{-1}", "atanh")
} else {
pick(fmt, "atanh", "\\tanh^{-1}", "atanh")
};
call_form(sym, args, r0)
}
"min" => call_form(pick(fmt, "min", "\\min", "min"), args, r0),
"max" => call_form(pick(fmt, "max", "\\max", "max"), args, r0),
"atan2" => call_form(pick(fmt, "atan2", "\\mathrm{atan2}", "atan2"), args, r0),
"sin" | "cos" | "tan" | "sinh" | "cosh" | "tanh" => {
let latex = format!("\\{op}");
call_form(
if fmt == Fmt::Latex {
latex.as_str()
} else {
op
},
args,
r0,
)
}
"Pre" => call_form(pick(fmt, "Pre", "\\mathrm{Pre}", "Pre"), args, r0),
_ => {
let latex = format!("\\mathrm{{{}}}", latex_name(op));
call_form(
if fmt == Fmt::Latex {
latex.as_str()
} else {
op
},
args,
r0,
)
}
};
if needs_parens {
format!("({result})")
} else {
result
}
}
pub trait TextRenderable {
fn render_unicode(&self) -> String;
fn render_latex(&self) -> String;
fn render_ascii(&self) -> String;
}
impl TextRenderable for Expr {
fn render_unicode(&self) -> String {
self.to_unicode()
}
fn render_latex(&self) -> String {
to_latex_prec(self, 0)
}
fn render_ascii(&self) -> String {
to_ascii_prec(self, 0)
}
}
impl TextRenderable for Equation {
fn render_unicode(&self) -> String {
format!(
"{} = {}",
self.lhs.render_unicode(),
self.rhs.render_unicode()
)
}
fn render_latex(&self) -> String {
format!("{} = {}", self.lhs.render_latex(), self.rhs.render_latex())
}
fn render_ascii(&self) -> String {
format!("{} = {}", self.lhs.render_ascii(), self.rhs.render_ascii())
}
}
macro_rules! summary_renderable {
($t:ty) => {
impl TextRenderable for $t {
fn render_unicode(&self) -> String {
self.to_string()
}
fn render_latex(&self) -> String {
self.to_string()
}
fn render_ascii(&self) -> String {
self.to_string()
}
}
};
}
summary_renderable!(Model);
summary_renderable!(ReactionSystem);
summary_renderable!(EsmFile);
pub fn to_unicode<T: TextRenderable + ?Sized>(target: &T) -> String {
target.render_unicode()
}
pub fn to_latex<T: TextRenderable + ?Sized>(target: &T) -> String {
target.render_latex()
}
fn to_latex_prec(expr: &Expr, parent_prec: i32) -> String {
match expr {
Expr::Integer(n) => format_number_latex(*n as f64),
Expr::Number(n) => format_number_latex(*n),
Expr::Variable(name) => {
nonfinite_string(name, Fmt::Latex).unwrap_or_else(|| format_variable_latex(name))
}
Expr::Operator(node) => format_operator(node, Fmt::Latex, parent_prec),
}
}
fn format_variable_latex(name: &str) -> String {
if is_preformatted_latex(name) {
return name.to_string();
}
if let Some(cmd) = greek_to_latex(name) {
return cmd;
}
format_chemical_latex(name)
}
fn is_preformatted_latex(v: &str) -> bool {
if v.starts_with("\\mathrm{") {
return true;
}
if v.contains('\\') {
return !v.contains('{');
}
v.contains('{') || v.contains('}')
}
fn has_element_pattern(variable: &str) -> bool {
let chars: Vec<char> = variable.chars().filter(|&c| c != '_').collect();
let mut has_element = false;
let mut i = 0;
while i < chars.len() {
let mut sym_len = 0;
if i + 1 < chars.len() {
let two: String = [chars[i], chars[i + 1]].iter().collect();
if ELEMENTS.contains(&two.as_str()) {
sym_len = 2;
}
}
if sym_len == 0 && ELEMENTS.contains(&chars[i].to_string().as_str()) {
sym_len = 1;
}
if sym_len > 0 {
has_element = true;
i += sym_len;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
} else {
if chars[i].is_ascii_alphabetic() {
return false;
}
i += 1;
}
}
has_element
}
fn latex_chemical_inner(formula: &str) -> String {
let chars: Vec<char> = formula.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i].is_ascii_digit() {
let mut digits = String::new();
while i < chars.len() && chars[i].is_ascii_digit() {
digits.push(chars[i]);
i += 1;
}
if digits.chars().count() == 1 {
out.push('_');
out.push_str(&digits);
} else {
out.push_str(&format!("_{{{digits}}}"));
}
} else {
out.push(chars[i]);
i += 1;
}
}
out
}
fn strip_outer_mathrm(s: &str) -> String {
let inner = s.strip_prefix("\\mathrm{").unwrap_or(s);
inner.strip_suffix('}').unwrap_or(inner).to_string()
}
fn get_chemical_suffix(variable: &str) -> Option<(String, String)> {
if variable.contains('_') {
let parts: Vec<&str> = variable.split('_').collect();
if parts.len() == 2 && has_element_pattern(parts[1]) && !has_element_pattern(parts[0]) {
return Some((parts[0].to_string(), parts[1].to_string()));
}
if parts.len() == 3 {
let suffix = parts[1..].join("_");
if has_element_pattern(&suffix) && !has_element_pattern(parts[0]) {
return Some((parts[0].to_string(), suffix));
}
}
}
let chars: Vec<char> = variable.chars().collect();
for i in 1..chars.len() {
let prefix: String = chars[..i].iter().collect();
let suffix: String = chars[i..].iter().collect();
if has_element_pattern(&suffix) && !has_element_pattern(&prefix) {
return Some((prefix, suffix));
}
}
None
}
fn format_chemical_suffix_inner(variable: &str) -> String {
if get_chemical_suffix(variable).is_some() {
return strip_outer_mathrm(&format_chemical_latex(variable));
}
if ELEMENTS.contains(&variable) && !variable.chars().any(|c| c.is_ascii_digit()) {
return variable.to_string();
}
latex_chemical_inner(variable)
}
fn single_letter_digits(name: &str) -> Option<(String, String)> {
let chars: Vec<char> = name.chars().collect();
if chars.len() >= 2
&& chars[0].is_ascii_alphabetic()
&& chars[1..].iter().all(|c| c.is_ascii_digit())
{
return Some((chars[0].to_string(), chars[1..].iter().collect()));
}
None
}
fn is_chemical_formula(s: &str) -> bool {
if s.is_empty() {
return false;
}
let first_char = s.chars().next().unwrap();
if !first_char.is_ascii_uppercase() {
return false;
}
if s.len() >= 2 {
let two_letter = &s[..2];
if ELEMENTS.contains(&two_letter) {
return true;
}
}
let one_letter = &s[..1];
ELEMENTS.contains(&one_letter)
}
fn format_chemical_latex(variable: &str) -> String {
let (charge_body, charge) = split_charge(variable);
if !charge.is_empty() {
return format!("{}^{{{charge}}}", format_chemical_latex(charge_body));
}
if let Some((prefix, suffix)) = get_chemical_suffix(variable) {
let prefix_multi = prefix.chars().count() > 1;
if suffix.contains('_') {
let segments: Vec<&str> = suffix.split('_').collect();
let should_split = segments[0]
.chars()
.last()
.is_some_and(|c| c.is_ascii_digit())
|| prefix_multi;
if should_split {
let mut result = if prefix_multi {
format!("\\mathrm{{{prefix}}}")
} else {
prefix.clone()
};
for seg in &segments {
if has_element_pattern(seg) {
result.push_str(&format!("_{{\\mathrm{{{}}}}}", latex_chemical_inner(seg)));
} else {
result.push_str(&format!("_\\mathrm{{{seg}}}"));
}
}
return result;
}
}
let inner = format_chemical_suffix_inner(&suffix);
let formatted_prefix = if prefix_multi {
format!("\\mathrm{{{prefix}}}")
} else {
prefix
};
return format!("{formatted_prefix}_{{\\mathrm{{{inner}}}}}");
}
if has_element_pattern(variable) {
if ELEMENTS.contains(&variable) && !variable.chars().any(|c| c.is_ascii_digit()) {
return variable.to_string();
}
return format!("\\mathrm{{{}}}", latex_chemical_inner(variable));
}
if let Some((letter, digits)) = single_letter_digits(variable) {
return if digits.chars().count() == 1 {
format!("{letter}_{digits}")
} else {
format!("{letter}_{{{digits}}}")
};
}
if variable.chars().count() == 1 {
return variable.to_string();
}
if variable.contains('_') {
let parts: Vec<&str> = variable.split('_').collect();
if parts.iter().any(|p| has_element_pattern(p)) {
let base = parts[0];
let mut result =
if base.chars().count() == 1 && base.chars().all(|c| c.is_ascii_alphabetic()) {
base.to_string()
} else if has_element_pattern(base) {
format_chemical_latex(base)
} else {
format!("\\mathrm{{{base}}}")
};
for part in &parts[1..] {
if has_element_pattern(part) {
result.push_str(&format!("_{{\\mathrm{{{}}}}}", latex_chemical_inner(part)));
} else {
result.push_str(&format!("_\\mathrm{{{part}}}"));
}
}
return result;
}
return format!("\\mathrm{{{}}}", variable.replace('_', "\\_"));
}
if !variable.chars().any(|c| c.is_ascii_lowercase()) {
return variable.to_string();
}
format!("\\mathrm{{{variable}}}")
}
pub fn to_ascii<T: TextRenderable + ?Sized>(target: &T) -> String {
target.render_ascii()
}
fn to_ascii_prec(expr: &Expr, parent_prec: i32) -> String {
match expr {
Expr::Integer(n) => format_number_ascii(*n as f64),
Expr::Number(n) => format_number_ascii(*n),
Expr::Variable(name) => {
nonfinite_string(name, Fmt::Ascii).unwrap_or_else(|| greek_to_ascii(name))
}
Expr::Operator(node) => format_operator(node, Fmt::Ascii, parent_prec),
}
}
impl fmt::Display for Model {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = self.name.as_deref().unwrap_or("Unnamed");
let param_count = self
.variables
.iter()
.filter(|(_, v)| matches!(v.var_type, VariableType::Parameter))
.count();
let eq_count = self.equations.len();
writeln!(
f,
" {} ({} parameters, {} equation{})",
name,
param_count,
eq_count,
if eq_count == 1 { "" } else { "s" }
)?;
for eq in &self.equations {
writeln!(f, " {} = {}", eq.lhs, eq.rhs)?;
}
Ok(())
}
}
impl fmt::Display for ReactionSystem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let species_count = self.species.len();
let param_count = self.parameters.len();
let reaction_count = self.reactions.len();
writeln!(
f,
" ReactionSystem ({} species, {} parameters, {} reaction{})",
species_count,
param_count,
reaction_count,
if reaction_count == 1 { "" } else { "s" }
)?;
for (i, reaction) in self.reactions.iter().enumerate() {
let default_name = format!("R{}", i + 1);
let reaction_name = reaction
.id
.as_deref()
.or(reaction.name.as_deref())
.unwrap_or(&default_name);
let substrates = reaction
.substrates
.iter()
.flatten()
.map(|s| {
if s.coefficient == 1.0 {
format_chemical_subscripts(&s.species)
} else {
format!(
"{}·{}",
format_number_unicode(s.coefficient),
format_chemical_subscripts(&s.species)
)
}
})
.collect::<Vec<_>>()
.join(" + ");
let products = reaction
.products
.iter()
.flatten()
.map(|p| {
if p.coefficient == 1.0 {
format_chemical_subscripts(&p.species)
} else {
format!(
"{}·{}",
format_number_unicode(p.coefficient),
format_chemical_subscripts(&p.species)
)
}
})
.collect::<Vec<_>>()
.join(" + ");
writeln!(
f,
" {}: {} → {} rate: {}",
reaction_name, substrates, products, reaction.rate
)?;
}
Ok(())
}
}
impl fmt::Display for EsmFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = self.metadata.name.as_deref().unwrap_or("Unnamed");
let description = self.metadata.description.as_deref().unwrap_or("");
let authors = self
.metadata
.authors
.as_ref()
.map(|a| a.join(", "))
.unwrap_or_else(|| "Unknown".to_string());
writeln!(f, "ESM v{}: {}", self.esm, name)?;
if !description.is_empty() {
writeln!(f, " \"{description}\"")?;
}
writeln!(f, " Authors: {authors}")?;
writeln!(f)?;
if let Some(ref reaction_systems) = self.reaction_systems
&& !reaction_systems.is_empty()
{
writeln!(f, " Reaction Systems:")?;
for system in reaction_systems.values() {
write!(f, "{system}")?;
writeln!(f)?;
}
}
if let Some(ref models) = self.models
&& !models.is_empty()
{
writeln!(f, " Models:")?;
for model in models.values() {
write!(f, "{model}")?;
writeln!(f)?;
}
}
if let Some(ref data_sources) = self.data_sources
&& !data_sources.is_empty()
{
writeln!(f, " Data Sources:")?;
for (name, source) in data_sources {
let kind = match source.kind {
crate::DataSourceKind::Grid => "grid",
crate::DataSourceKind::Points => "points",
crate::DataSourceKind::Static => "static",
};
writeln!(
f,
" {}: [{}] {}{}",
name,
kind,
source.source.url_template,
if source.temporal.is_some() {
" (temporal)"
} else {
""
},
)?;
}
writeln!(f)?;
}
if let Some(ref coupling) = self.coupling
&& !coupling.is_empty()
{
writeln!(f, " Coupling:")?;
for (i, entry) in coupling.iter().enumerate() {
match entry {
CouplingEntry::OperatorCompose { systems, .. } => {
if systems.len() >= 2 {
writeln!(
f,
" {}. operator_compose: {} + {}",
i + 1,
systems[0],
systems[1]
)?;
}
}
CouplingEntry::VariableMap { from, to, .. } => {
writeln!(f, " {}. variable_map: {} → {}", i + 1, from, to)?;
}
_ => {
writeln!(f, " {}. {:?}", i + 1, entry)?;
}
}
}
writeln!(f)?;
}
if let Some(ref domain) = self.domain {
write!(f, " Domain: ")?;
let mut domain_parts = Vec::new();
if domain.temporal.is_some() {
domain_parts.push("temporal".to_string());
}
if domain_parts.is_empty() {
writeln!(f, "[Domain information]")?;
} else {
writeln!(f, "{}", domain_parts.join(", "))?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
#[test]
fn test_chemical_subscripts() {
assert_eq!(format_chemical_subscripts("O3"), "O₃");
assert_eq!(format_chemical_subscripts("NO2"), "NO₂");
assert_eq!(format_chemical_subscripts("CH4"), "CH₄");
assert_eq!(format_chemical_subscripts("C2H6"), "C₂H₆");
assert_eq!(format_chemical_subscripts("H2O2"), "H₂O₂");
}
#[test]
fn test_number_formatting_unicode() {
assert_eq!(format_number_unicode(42.0), "42");
assert_eq!(format_number_unicode(3.15), "3.15");
assert_eq!(format_number_unicode(1.8e-12), "1.8×10⁻¹²");
assert_eq!(format_number_unicode(2.46e19), "2.46×10¹⁹");
assert_eq!(format_number_unicode(0.0), "0");
assert_eq!(format_number_unicode(-0.0), "0");
assert_eq!(format_number_unicode(1.0), "1");
assert_eq!(format_number_unicode(-1.0), "−1");
}
#[test]
fn test_zero_formatting_all_formats() {
let zero_expr = Expr::Number(0.0);
let one_expr = Expr::Number(1.0);
let neg_zero_expr = Expr::Number(-0.0);
assert_eq!(to_unicode(&zero_expr), "0");
assert_eq!(to_unicode(&neg_zero_expr), "0");
assert_eq!(to_unicode(&one_expr), "1");
assert_eq!(to_latex(&zero_expr), "0");
assert_eq!(to_latex(&neg_zero_expr), "0");
assert_eq!(to_latex(&one_expr), "1");
assert_eq!(to_ascii(&zero_expr), "0");
assert_eq!(to_ascii(&neg_zero_expr), "0");
assert_eq!(to_ascii(&one_expr), "1");
}
#[test]
fn the_greek_table_drives_all_three_views() {
let names: Vec<&str> = GREEK_TABLE.iter().map(|&(n, _, _)| n).collect();
assert_eq!(
names,
[
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota",
"kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", "rho", "sigma", "tau",
"upsilon", "phi", "chi", "psi", "omega",
]
);
for &(n, c, _) in GREEK_TABLE {
assert_eq!(greek_name_to_char(n), Some(c), "name → char for {n}");
assert_eq!(greek_char_to_name(c), Some(n), "char → name for {c}");
assert_eq!(greek_to_latex(n).unwrap(), format!("\\{n}"));
assert_eq!(greek_to_latex(&c.to_string()).unwrap(), format!("\\{n}"));
}
let capitals: Vec<&str> = GREEK_TABLE
.iter()
.filter(|&&(_, _, cap)| cap)
.map(|&(n, _, _)| n)
.collect();
assert_eq!(
capitals,
[
"gamma", "delta", "theta", "lambda", "xi", "pi", "sigma", "upsilon", "phi", "psi",
"omega",
]
);
assert_eq!(greek_to_latex("Theta").as_deref(), Some("\\Theta"));
assert_eq!(greek_to_latex("Omega").as_deref(), Some("\\Omega"));
for miss in ["Alpha", "Δ", "GAMMA", "Deltas", "x"] {
assert_eq!(greek_to_latex(miss), None, "{miss} must not match");
}
assert_eq!(greek_to_ascii("θ_Δx"), "theta_Δx");
assert_eq!(format_chemical_subscripts("Delta"), "Delta");
}
#[test]
fn test_scientific_all_backends() {
assert_eq!(format_number_unicode(1.8e-12), "1.8×10⁻¹²");
assert_eq!(format_number_unicode(2.46e19), "2.46×10¹⁹");
assert_eq!(format_number_latex(1.8e-12), "1.8 \\times 10^{-12}");
assert_eq!(format_number_ascii(1.8e-12), "1.8e-12");
assert_eq!(format_number_unicode(f64::INFINITY), "∞");
assert_eq!(format_number_latex(f64::NEG_INFINITY), "-\\infty");
assert_eq!(format_number_ascii(f64::NAN), "NaN");
}
#[test]
fn test_expr_display() {
let expr = Expr::Variable("O3".to_string());
assert_eq!(format!("{expr}"), "O₃");
let expr = Expr::Number(1.8e-12);
assert_eq!(format!("{expr}"), "1.8×10⁻¹²");
}
#[test]
fn test_operator_precedence() {
let add = Expr::operator(ExpressionNode {
op: "+".to_string(),
args: vec![
Expr::operator(ExpressionNode {
op: "*".to_string(),
args: vec![
Expr::Variable("a".to_string()),
Expr::Variable("b".to_string()),
],
wrt: None,
dim: None,
..Default::default()
}),
Expr::Variable("c".to_string()),
],
wrt: None,
dim: None,
..Default::default()
});
assert_eq!(format!("{add}"), "a·b + c");
let mul = Expr::operator(ExpressionNode {
op: "*".to_string(),
args: vec![
Expr::operator(ExpressionNode {
op: "+".to_string(),
args: vec![
Expr::Variable("a".to_string()),
Expr::Variable("b".to_string()),
],
wrt: None,
dim: None,
..Default::default()
}),
Expr::Variable("c".to_string()),
],
wrt: None,
dim: None,
..Default::default()
});
assert_eq!(format!("{mul}"), "(a + b)·c");
}
#[test]
fn test_model_summary_display() {
let metadata = Metadata {
name: Some("TestModel".to_string()),
description: Some("Test description".to_string()),
authors: Some(vec!["Test Author".to_string()]),
..Default::default()
};
let mut parameters = IndexMap::new();
parameters.insert(
"k1".to_string(),
Parameter {
default: Some(1.8e-12),
units: Some("cm3/molec/s".to_string()),
description: Some("Rate constant".to_string()),
..Default::default()
},
);
let reactions = vec![Reaction {
id: Some("R1".to_string()),
name: Some("R1".to_string()),
substrates: Some(vec![StoichiometricEntry {
species: "A".to_string(),
coefficient: 1.0,
}]),
products: Some(vec![StoichiometricEntry {
species: "B".to_string(),
coefficient: 1.0,
}]),
rate: Expr::Variable("k1".to_string()),
reference: None,
}];
let mut species = IndexMap::new();
species.insert(
"A".to_string(),
Species {
default: Some(1e-9),
units: Some("molec/cm3".to_string()),
description: None,
constant: None,
..Default::default()
},
);
species.insert(
"B".to_string(),
Species {
default: Some(0.0),
units: Some("molec/cm3".to_string()),
description: None,
constant: None,
..Default::default()
},
);
let reaction_system = ReactionSystem {
species,
parameters,
reactions,
..Default::default()
};
let mut reaction_systems = IndexMap::new();
reaction_systems.insert("TestReactions".to_string(), reaction_system);
let esm_file = EsmFile {
esm: "0.1.0".to_string(),
metadata,
reaction_systems: Some(reaction_systems),
..Default::default()
};
let output = format!("{esm_file}");
assert!(output.contains("ESM v0.1.0: TestModel"));
assert!(output.contains("\"Test description\""));
assert!(output.contains("Authors: Test Author"));
assert!(output.contains("Reaction Systems:"));
assert!(output.contains("(2 species, 1 parameters, 1 reaction)"));
assert!(output.contains("R1: A → B rate: k1"));
assert_eq!(to_unicode(&esm_file), output);
assert_eq!(to_latex(&esm_file), output);
assert_eq!(to_ascii(&esm_file), output);
let rs = &esm_file.reaction_systems.as_ref().unwrap()["TestReactions"];
assert_eq!(to_unicode(rs), rs.to_string());
assert_eq!(to_ascii(rs), rs.to_string());
}
#[test]
fn equations_render_in_all_three_formats() {
let eq = Equation {
lhs: Expr::Variable("alpha".to_string()),
rhs: Expr::operator(ExpressionNode {
op: "*".to_string(),
args: vec![Expr::Variable("beta".to_string()), Expr::Number(2.0)],
..Default::default()
}),
..Default::default()
};
assert_eq!(to_unicode(&eq), "α = β·2");
assert_eq!(to_ascii(&eq), "alpha = beta * 2");
assert_eq!(to_latex(&eq), "\\alpha = \\beta \\cdot 2");
}
#[test]
fn test_pre_operator_formatting() {
use crate::types::*;
let pre_expr = Expr::operator(ExpressionNode {
op: "Pre".to_string(),
args: vec![Expr::Variable("x".to_string())],
wrt: None,
dim: None,
..Default::default()
});
assert_eq!(to_unicode(&pre_expr), "Pre(x)");
assert_eq!(to_latex(&pre_expr), "\\mathrm{Pre}(x)");
assert_eq!(to_ascii(&pre_expr), "Pre(x)");
let complex_pre = Expr::operator(ExpressionNode {
op: "Pre".to_string(),
args: vec![Expr::operator(ExpressionNode {
op: "+".to_string(),
args: vec![
Expr::Variable("a".to_string()),
Expr::Variable("b".to_string()),
],
wrt: None,
dim: None,
..Default::default()
})],
wrt: None,
dim: None,
..Default::default()
});
assert_eq!(to_unicode(&complex_pre), "Pre(a + b)");
assert_eq!(to_latex(&complex_pre), "\\mathrm{Pre}(a + b)");
assert_eq!(to_ascii(&complex_pre), "Pre(a + b)");
let multi_arg_pre = Expr::operator(ExpressionNode {
op: "Pre".to_string(),
args: vec![
Expr::Variable("x".to_string()),
Expr::Variable("y".to_string()),
],
wrt: None,
dim: None,
..Default::default()
});
assert_eq!(to_unicode(&multi_arg_pre), "Pre(x, y)");
assert_eq!(to_latex(&multi_arg_pre), "\\mathrm{Pre}(x, y)");
assert_eq!(to_ascii(&multi_arg_pre), "Pre(x, y)");
}
fn op_node(op: &str, args: Vec<Expr>) -> Expr {
Expr::operator(ExpressionNode {
op: op.to_string(),
args,
wrt: None,
dim: None,
..Default::default()
})
}
fn var(name: &str) -> Expr {
Expr::Variable(name.to_string())
}
#[test]
fn test_precedence_parens_all_printers() {
let mul = op_node("*", vec![op_node("+", vec![var("a"), var("b")]), var("c")]);
assert_eq!(to_unicode(&mul), "(a + b)·c");
assert_eq!(to_latex(&mul), "(a + b) \\cdot c");
assert_eq!(to_ascii(&mul), "(a + b) * c");
let add = op_node("+", vec![var("a"), op_node("*", vec![var("b"), var("c")])]);
assert_eq!(to_unicode(&add), "a + b·c");
assert_eq!(to_latex(&add), "a + b \\cdot c");
assert_eq!(to_ascii(&add), "a + b * c");
let sub = op_node("-", vec![var("a"), op_node("-", vec![var("b"), var("c")])]);
assert_eq!(to_unicode(&sub), "a − (b − c)");
assert_eq!(to_latex(&sub), "a - (b - c)");
assert_eq!(to_ascii(&sub), "a - (b - c)");
let pow = op_node(
"^",
vec![op_node("+", vec![var("a"), var("b")]), Expr::Number(2.0)],
);
assert_eq!(to_unicode(&pow), "(a + b)²");
assert_eq!(to_latex(&pow), "(a + b)^{2}");
assert_eq!(to_ascii(&pow), "(a + b)^2");
let frac_mul = op_node("*", vec![var("a"), op_node("/", vec![var("b"), var("c")])]);
assert_eq!(to_unicode(&frac_mul), "a·(b/c)");
assert_eq!(to_latex(&frac_mul), "a \\cdot \\frac{b}{c}");
assert_eq!(to_ascii(&frac_mul), "a * (b / c)");
let ind = op_node(
"*",
vec![
op_node("<=", vec![var("a"), var("b")]),
op_node("<", vec![var("c"), var("d")]),
],
);
assert_eq!(to_ascii(&ind), "(a <= b) * (c < d)");
let lt_plus = op_node("+", vec![op_node("<", vec![var("a"), var("b")]), var("c")]);
assert_eq!(to_ascii(<_plus), "(a < b) + c");
let and_mul = op_node(
"*",
vec![op_node("and", vec![var("a"), var("b")]), var("c")],
);
assert_eq!(to_ascii(&and_mul), "(a and b) * c");
let and3 = op_node(
"and",
vec![
op_node("<=", vec![var("a"), var("b")]),
op_node("<", vec![var("b"), var("c")]),
op_node("<", vec![var("c"), var("d")]),
],
);
assert_eq!(to_ascii(&and3), "a <= b and b < c and c < d");
assert_eq!(to_unicode(&and3), "a ≤ b ∧ b < c ∧ c < d");
let eq_lt = op_node("==", vec![var("a"), op_node("<", vec![var("b"), var("c")])]);
assert_eq!(to_ascii(&eq_lt), "a == (b < c)");
let neg_lt = op_node("-", vec![op_node("<", vec![var("a"), var("b")])]);
assert_eq!(to_ascii(&neg_lt), "-(a < b)");
let neg_sum = op_node("-", vec![op_node("+", vec![var("a"), var("b")])]);
assert_eq!(to_ascii(&neg_sum), "-(a + b)");
let neg_prod = op_node("-", vec![op_node("*", vec![var("a"), var("b")])]);
assert_eq!(to_ascii(&neg_prod), "-a * b");
}
#[test]
fn test_scientific_notation_unified_across_printers() {
let large = Expr::Number(2.46e19);
assert_eq!(to_unicode(&large), "2.46×10¹⁹");
assert_eq!(to_latex(&large), "2.46 \\times 10^{19}");
assert_eq!(to_ascii(&large), "2.46e19");
let small = Expr::Number(1.8e-12);
assert_eq!(to_unicode(&small), "1.8×10⁻¹²");
assert_eq!(to_latex(&small), "1.8 \\times 10^{-12}");
assert_eq!(to_ascii(&small), "1.8e-12");
let big_int = Expr::Integer(15000);
assert_eq!(to_unicode(&big_int), "1.5×10⁴");
assert_eq!(to_ascii(&big_int), "1.5e4");
}
#[test]
fn test_operator_matrix_all_backends() {
fn opn(op: &str, args: Vec<Expr>) -> Expr {
Expr::operator(ExpressionNode {
op: op.to_string(),
args,
..Default::default()
})
}
fn dop(wrt: &str, arg: Expr) -> Expr {
Expr::operator(ExpressionNode {
op: "D".to_string(),
args: vec![arg],
wrt: Some(wrt.to_string()),
..Default::default()
})
}
let a = || var("a");
let b = || var("b");
let c = || var("c");
let ab = || opn("+", vec![a(), b()]);
let chk = |e: Expr, u: &str, l: &str, s: &str| {
assert_eq!(to_unicode(&e), u, "unicode");
assert_eq!(to_latex(&e), l, "latex");
assert_eq!(to_ascii(&e), s, "ascii");
};
chk(opn("*", vec![a(), b()]), "a·b", r"a \cdot b", "a * b");
chk(
opn("*", vec![ab(), c()]),
"(a + b)·c",
r"(a + b) \cdot c",
"(a + b) * c",
);
chk(
opn("*", vec![var("\\mathrm{O_3}"), var("\\mathrm{N_2}")]),
r"\mathrm{O_3}·\mathrm{N_2}",
r"\mathrm{O_3} \mathrm{N_2}",
r"\mathrm{O_3} * \mathrm{N_2}",
);
chk(opn("/", vec![a(), b()]), "a/b", r"\frac{a}{b}", "a / b");
chk(
opn("/", vec![ab(), c()]),
"(a + b)/c",
r"\frac{a + b}{c}",
"(a + b) / c",
);
chk(
opn("*", vec![a(), opn("/", vec![b(), c()])]),
"a·(b/c)",
r"a \cdot \frac{b}{c}",
"a * (b / c)",
);
chk(opn("^", vec![a(), b()]), "a^b", r"a^{b}", "a^b");
chk(opn("^", vec![a(), Expr::Integer(2)]), "a²", r"a^{2}", "a^2");
chk(
opn("^", vec![a(), Expr::Number(2.0)]),
"a²",
r"a^{2}",
"a^2",
);
chk(
opn("^", vec![a(), Expr::Number(2.5)]),
"a^2.5",
r"a^{2.5}",
"a^2.5",
);
chk(
opn("^", vec![ab(), Expr::Integer(2)]),
"(a + b)²",
r"(a + b)^{2}",
"(a + b)^2",
);
chk(
opn("^", vec![a(), Expr::Integer(-3)]),
"a⁻³",
r"a^{-3}",
"a^-3",
);
chk(opn("-", vec![a()]), "−a", "-a", "-a");
chk(
opn("-", vec![a(), opn("-", vec![b(), c()])]),
"a − (b − c)",
"a - (b - c)",
"a - (b - c)",
);
chk(
opn("+", vec![a(), opn("-", vec![b()])]),
"a − b",
"a - b",
"a - b",
);
chk(
dop("t", a()),
"∂a/∂t",
r"\frac{\partial a}{\partial t}",
"D(a)/Dt",
);
chk(
dop("t", ab()),
"∂(a + b)/∂t",
r"\frac{\partial (a + b)}{\partial t}",
"D(a + b)/Dt",
);
chk(
opn("D", vec![a()]),
"∂a/∂t",
r"\frac{\partial a}{\partial t}",
"D(a)/Dt",
);
chk(opn("D", vec![a(), b()]), "D(a, b)", "D(a, b)", "D(a, b)");
chk(opn(">", vec![a(), b()]), "a > b", "a > b", "a > b");
chk(opn(">=", vec![a(), b()]), "a ≥ b", r"a \geq b", "a >= b");
chk(opn(">=", vec![a()]), ">=(a)", r"\geq(a)", ">=(a)");
chk(opn("<=", vec![a(), b()]), "a ≤ b", r"a \leq b", "a <= b");
chk(opn("=", vec![a(), b()]), "a = b", "a = b", "a == b");
chk(opn("==", vec![a()]), "=(a)", "=(a)", "==(a)");
chk(opn("!=", vec![a(), b()]), "a ≠ b", r"a \neq b", "a != b");
chk(opn("!=", vec![a()]), "!=(a)", r"\neq(a)", "!=(a)");
chk(opn("and", vec![a(), b()]), "a ∧ b", r"a \land b", "a and b");
chk(opn("or", vec![a(), b()]), "a ∨ b", r"a \lor b", "a or b");
chk(opn("not", vec![a()]), "¬a", r"\neg a", "not a");
chk(
opn("not", vec![opn("==", vec![a(), b()])]),
"¬(a = b)",
r"\neg (a = b)",
"not (a == b)",
);
chk(
opn("not", vec![a(), b()]),
"not(a, b)",
r"\neg(a, b)",
"not(a, b)",
);
chk(opn("log", vec![a()]), "ln(a)", r"\ln(a)", "log(a)");
chk(
opn("log", vec![a(), b()]),
"log(a, b)",
r"\log(a, b)",
"log(a, b)",
);
chk(
opn("log10", vec![a()]),
"log₁₀(a)",
r"\log_{10}(a)",
"log10(a)",
);
chk(
opn("log10", vec![a(), b()]),
"log10(a, b)",
r"\log_{10}(a, b)",
"log10(a, b)",
);
chk(opn("sqrt", vec![a()]), "√a", r"\sqrt{a}", "sqrt(a)");
chk(
opn("sqrt", vec![a(), b()]),
"sqrt(a, b)",
r"\sqrt{a, b}",
"sqrt(a, b)",
);
chk(
opn("sqrt", vec![ab()]),
"√(a + b)",
r"\sqrt{a + b}",
"sqrt(a + b)",
);
chk(
opn("asin", vec![a()]),
"arcsin(a)",
r"\arcsin(a)",
"asin(a)",
);
chk(opn("abs", vec![a()]), "|a|", "|a|", "abs(a)");
chk(
opn("abs", vec![a(), b()]),
"abs(a, b)",
"|a, b|",
"abs(a, b)",
);
chk(
opn("sign", vec![a()]),
"sgn(a)",
r"\mathrm{sgn}(a)",
"sign(a)",
);
chk(
opn("floor", vec![a()]),
"⌊a⌋",
r"\lfloor a \rfloor",
"floor(a)",
);
chk(
opn("floor", vec![a(), b()]),
"floor(a, b)",
r"\lfloor a, b \rfloor",
"floor(a, b)",
);
chk(opn("ceil", vec![a()]), "⌈a⌉", r"\lceil a \rceil", "ceil(a)");
chk(
opn("asinh", vec![a()]),
"sinh⁻¹(a)",
r"\sinh^{-1}(a)",
"asinh(a)",
);
chk(opn("exp", vec![a()]), "exp(a)", r"\exp(a)", "exp(a)");
chk(
opn("exp", vec![ab()]),
"exp(a + b)",
r"\exp\left(a + b\right)",
"exp(a + b)",
);
chk(opn("min", vec![a()]), "min(a)", r"\min(a)", "min(a)");
chk(
opn("atan2", vec![a()]),
"atan2(a)",
r"\mathrm{atan2}(a)",
"atan2(a)",
);
chk(opn("sin", vec![a()]), "sin(a)", r"\sin(a)", "sin(a)");
chk(
opn("Pre", vec![a()]),
"Pre(a)",
r"\mathrm{Pre}(a)",
"Pre(a)",
);
chk(
opn("ifelse", vec![a(), b(), c()]),
"ifelse(a, b, c)",
r"\begin{cases} b & \text{if } a \\ c & \text{otherwise} \end{cases}",
"ifelse(a, b, c)",
);
chk(
opn("ifelse", vec![a(), b()]),
"ifelse(a, b)",
r"\mathrm{ifelse}(a, b)",
"ifelse(a, b)",
);
chk(
opn("grad", vec![a()]),
"grad(a)",
r"\mathrm{grad}(a)",
"grad(a)",
);
chk(
opn("my_op", vec![a(), b()]),
"my_op(a, b)",
r"\mathrm{my\_op}(a, b)",
"my_op(a, b)",
);
}
}