Skip to main content

earthsci_ast/
display.rs

1//! Display formatting implementations for ESM expressions, models, and files
2//!
3//! This module implements `std::fmt::Display` and other formatting functions
4//! for expressions with Unicode mathematical notation, chemical subscripts,
5//! LaTeX output, and summary displays for models and reaction systems.
6
7use crate::types::*;
8use std::fmt;
9
10// Element table - periodic table symbols for chemical subscript detection
11const ELEMENTS: &[&str; 118] = &[
12    "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl",
13    "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As",
14    "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In",
15    "Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb",
16    "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl",
17    "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk",
18    "Cf", "Es", "Fm", "Md", "No", "Lr", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn", "Nh",
19    "Fl", "Mc", "Lv", "Ts", "Og",
20];
21
22// Unicode subscript digits
23const UNICODE_SUBSCRIPTS: [char; 10] = ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉'];
24
25// Unicode superscript digits
26const UNICODE_SUPERSCRIPTS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
27
28// Operator precedence levels (higher = tighter binding): the esm-spec §4.1.1
29// table, identical to `op_precedence` in parse_expression.rs. Comparisons and
30// `and`/`or` carry their real levels so the generic rule parenthesizes them
31// under arithmetic: `(a <= b) * (c < d)`, never `a <= b * c < d`.
32const PRECEDENCE: &[(&str, i32)] = &[
33    ("or", 1),
34    ("and", 2),
35    ("==", 3),
36    ("!=", 3),
37    ("=", 3),
38    ("<", 3),
39    (">", 3),
40    ("<=", 3),
41    (">=", 3),
42    ("+", 4),
43    ("-", 4),
44    ("*", 5),
45    ("/", 5),
46    ("not", 6),
47    ("^", 7),
48];
49
50/// Operators whose same-op RIGHT operand needs no parentheses: the parser
51/// re-flattens `a + (b + c)` into one n-ary node. Every other same-level right
52/// operand keeps its parentheses (`a * (b / c)`, `a == (b < c)`, `a^(b^c)`),
53/// because the parser groups same-level operators to the LEFT. Mirrors
54/// `ASSOCIATIVE_OPS` in pretty-print.ts.
55const ASSOCIATIVE_OPS: &[&str] = &["+", "*", "and", "or"];
56
57/// The `parent_prec` a unary-minus operand renders at: the ADDITIVE level, so
58/// a `+`/binary-`-` child (and anything looser: comparisons, `and`, `or`) is
59/// parenthesized and a `*`/`/`/`^` child is not. It mirrors the parser's
60/// `UMINUS_MIN` (parse_expression.rs), which reads a unary-minus operand at
61/// multiplicative precedence — print `-(a + b)` without the parentheses and it
62/// reads back as `(-a) + b`, a different expression. `-a * b` and `-a^2` need
63/// none. Mirrors `UMINUS_OPERAND_MIN` in pretty-print.ts.
64const UMINUS_OPERAND_PARENT_PREC: i32 = 4;
65
66/// Render operand `arg` of the infix operator `op` (precedence `op_prec`).
67/// A LEFT operand is parenthesized only when it binds strictly looser; a RIGHT
68/// operand (for an n-ary `+ * and or`, every argument after the first) also
69/// when it sits at the SAME level, unless it is the very same associative
70/// operator. The one exception is LaTeX, where a binary `\frac` operand of `*`
71/// is self-delimiting and is never wrapped.
72fn render_operand(op: &str, op_prec: i32, arg: &Expr, is_right: bool, fmt: Fmt) -> String {
73    if !is_right {
74        return render_at(arg, fmt, op_prec - 1);
75    }
76    let bare = match arg {
77        Expr::Operator(n) => {
78            (n.op == op && ASSOCIATIVE_OPS.contains(&op))
79                || (fmt == Fmt::Latex && op == "*" && n.op == "/" && n.args.len() == 2)
80        }
81        _ => true,
82    };
83    render_at(arg, fmt, if bare { op_prec - 1 } else { op_prec })
84}
85
86/// Whether a unary-minus operand needs parentheses that the precedence table
87/// cannot express: a `-` directly before a numeric literal is part of the
88/// literal, so `-(2^2)` printed as `-2^2` reads back as `(-2)^2`; the base is
89/// the leftmost leaf of the operand, reached through `*` / `/`.
90fn starts_with_literal_power(expr: &Expr) -> bool {
91    let Expr::Operator(n) = expr else {
92        return false;
93    };
94    match n.op.as_str() {
95        "^" => matches!(
96            n.args.first(),
97            Some(Expr::Integer(_)) | Some(Expr::Number(_))
98        ),
99        "*" | "/" => n.args.first().is_some_and(starts_with_literal_power),
100        _ => false,
101    }
102}
103
104/// Get operator precedence (higher means tighter binding)
105fn get_precedence(op: &str) -> i32 {
106    for (operator, prec) in PRECEDENCE {
107        if operator == &op {
108            return *prec;
109        }
110    }
111    0 // Default for unknown operators
112}
113
114/// The ONE canonical Greek-letter table; every derived lookup below
115/// ([`greek_name_to_char`], [`greek_char_to_name`], [`greek_to_latex`]) reads
116/// it, mirroring `GREEK_TABLE` in pretty-print.ts. Each row is
117/// `(spelled ascii name, lowercase Unicode char, capital-has-distinct-LaTeX)`:
118/// the flagged capitals render as a `\Gamma`-style command when SPELLED
119/// (`"Gamma"`); the rest look like Latin capitals and carry none. Uppercase
120/// Unicode chars appear in NO view — every backend passes them through
121/// unchanged — and the name↔char views are lowercase-only, both deliberate
122/// (they preserve each backend's prior behavior, and match pretty-print.ts).
123const GREEK_TABLE: &[(&str, char, bool)] = &[
124    ("alpha", 'α', false),
125    ("beta", 'β', false),
126    ("gamma", 'γ', true),
127    ("delta", 'δ', true),
128    ("epsilon", 'ε', false),
129    ("zeta", 'ζ', false),
130    ("eta", 'η', false),
131    ("theta", 'θ', true),
132    ("iota", 'ι', false),
133    ("kappa", 'κ', false),
134    ("lambda", 'λ', true),
135    ("mu", 'μ', false),
136    ("nu", 'ν', false),
137    ("xi", 'ξ', true),
138    ("omicron", 'ο', false),
139    ("pi", 'π', true),
140    ("rho", 'ρ', false),
141    ("sigma", 'σ', true),
142    ("tau", 'τ', false),
143    ("upsilon", 'υ', true),
144    ("phi", 'φ', true),
145    ("chi", 'χ', false),
146    ("psi", 'ψ', true),
147    ("omega", 'ω', true),
148];
149
150/// The Unicode symbol for a spelled lowercase Greek-letter name
151/// (`"pi"` → `'π'`), for the Unicode backend.
152fn greek_name_to_char(name: &str) -> Option<char> {
153    GREEK_TABLE
154        .iter()
155        .find(|(n, _, _)| *n == name)
156        .map(|&(_, c, _)| c)
157}
158
159/// The spelled name of a lowercase Greek Unicode char (`'π'` → `"pi"`), for
160/// the ASCII backend.
161fn greek_char_to_name(c: char) -> Option<&'static str> {
162    GREEK_TABLE
163        .iter()
164        .find(|&&(_, ch, _)| ch == c)
165        .map(|&(n, _, _)| n)
166}
167
168/// Convert a string with chemical subscripts and Greek letters to Unicode
169fn format_chemical_subscripts(s: &str) -> String {
170    // A name that is exactly a spelled Greek letter becomes its symbol.
171    if let Some(c) = greek_name_to_char(s) {
172        return c.to_string();
173    }
174
175    // Peel a trailing ionic charge (`Ca2+`, `SO4-2`) so its magnitude renders
176    // as a SUPERSCRIPT (`Ca²⁺`, `SO₄²⁻`) rather than an atom-count subscript.
177    let (body, charge) = split_charge(s);
178
179    let mut result = String::new();
180    let chars: Vec<char> = body.chars().collect();
181    let mut i = 0;
182
183    while i < chars.len() {
184        let ch = chars[i];
185
186        if ch.is_alphabetic() {
187            // Try to match 2-letter element symbol first
188            if i + 1 < chars.len() {
189                let two_letter = format!("{}{}", ch, chars[i + 1]);
190                if ELEMENTS.contains(&two_letter.as_str()) {
191                    result.push_str(&two_letter);
192                    i += 2;
193
194                    // Convert following digits to subscripts
195                    while i < chars.len() && chars[i].is_ascii_digit() {
196                        let digit = chars[i].to_digit(10).unwrap();
197                        result.push(UNICODE_SUBSCRIPTS[digit as usize]);
198                        i += 1;
199                    }
200                    continue;
201                }
202            }
203
204            // Try 1-letter element symbol
205            if ELEMENTS.contains(&ch.to_string().as_str()) {
206                result.push(ch);
207                i += 1;
208
209                // Convert following digits to subscripts
210                while i < chars.len() && chars[i].is_ascii_digit() {
211                    let digit = chars[i].to_digit(10).unwrap();
212                    result.push(UNICODE_SUBSCRIPTS[digit as usize]);
213                    i += 1;
214                }
215                continue;
216            }
217
218            // Not an element symbol, just add the character
219            result.push(ch);
220            i += 1;
221        } else {
222            // A group-count after a closing bracket subscripts too (`Ca(OH)2`
223            // → `Ca(OH)₂`); every other non-element character is copied as-is.
224            result.push(ch);
225            i += 1;
226            if ch == ')' || ch == ']' {
227                while i < chars.len() && chars[i].is_ascii_digit() {
228                    let digit = chars[i].to_digit(10).unwrap();
229                    result.push(UNICODE_SUBSCRIPTS[digit as usize]);
230                    i += 1;
231                }
232            }
233        }
234    }
235
236    result.push_str(&to_superscript_str(&charge));
237    result
238}
239
240/// Split off a trailing ionic charge suffix, returning `(body, charge)` where
241/// `charge` is the raw magnitude-then-sign string (`"2+"`, `"2-"`) — the caller
242/// renders it as a superscript (Unicode `²⁺` via `to_superscript_str`, or LaTeX
243/// `^{2+}`). Empty `charge` when no sign is present or the remaining body is not
244/// a chemical formula, so ordinary identifiers are untouched. Input `2+` and
245/// `-2` after a chemical body both normalize to `"2+"` / `"2-"`.
246fn split_charge(s: &str) -> (&str, String) {
247    let last = match s.chars().last() {
248        Some(c) => c,
249        None => return (s, String::new()),
250    };
251    // digits-then-sign, e.g. "Ca2+"
252    if last == '+' || last == '-' {
253        let without_sign = &s[..s.len() - 1];
254        let mag: String = without_sign
255            .chars()
256            .rev()
257            .take_while(|c| c.is_ascii_digit())
258            .collect::<Vec<_>>()
259            .into_iter()
260            .rev()
261            .collect();
262        let body = &without_sign[..without_sign.len() - mag.len()];
263        if !mag.is_empty() && is_chemical_formula(body) {
264            return (body, format!("{mag}{last}"));
265        }
266    }
267    // sign-then-digits, e.g. "SO4-2"
268    if last.is_ascii_digit() {
269        let mag: String = s
270            .chars()
271            .rev()
272            .take_while(|c| c.is_ascii_digit())
273            .collect::<Vec<_>>()
274            .into_iter()
275            .rev()
276            .collect();
277        let before = &s[..s.len() - mag.len()];
278        if let Some(sign) = before.chars().last()
279            && (sign == '+' || sign == '-')
280        {
281            let body = &before[..before.len() - 1];
282            if is_chemical_formula(body) {
283                return (body, format!("{mag}{sign}"));
284            }
285        }
286    }
287    (s, String::new())
288}
289
290/// Backend-independent pieces of a formatted floating-point number.
291///
292/// The Unicode, LaTeX, and ASCII printers previously each hand-rolled the
293/// 0.0 special case, integer collapse, trailing-zero trimming, and
294/// scientific-notation cutoffs — with slightly divergent orderings. They now
295/// share `format_display_float` and differ only in how they render the
296/// exponent (Unicode superscripts, LaTeX `\times 10^{...}`, ASCII `e`).
297enum FloatParts {
298    /// Rendered verbatim in every backend (e.g. "0", "42", "3.15").
299    Plain(String),
300    /// Scientific notation, e.g. mantissa "1.8" with exponent "-12". The
301    /// mantissa always carries at least one decimal digit ("1" -> "1.0") and
302    /// the exponent has no leading `+` (matching tests/display goldens).
303    Scientific { mantissa: String, exponent: String },
304    /// `+inf` (`false`) / `-inf` (`true`).
305    Inf(bool),
306    /// Not-a-number.
307    NaN,
308}
309
310// Scientific-notation cutoffs (esm-spec §6.1 / RENDERING_CONTRACT.md "Number
311// formatting"): a nonzero magnitude below the min or at/above the max renders
312// in scientific notation, in EVERY backend and for integers alike.
313const SCI_NOTATION_MIN: f64 = 0.01;
314const SCI_NOTATION_MAX: f64 = 10000.0;
315
316/// Shared float-formatting core for all three expression printers. Integer and
317/// float leaves both flow through here so a magnitude like `15000` renders as
318/// `1.5×10⁴` regardless of which JSON token produced it.
319fn format_display_float(n: f64) -> FloatParts {
320    if n.is_nan() {
321        return FloatParts::NaN;
322    }
323    if n.is_infinite() {
324        return FloatParts::Inf(n < 0.0);
325    }
326    // Zero prints as a bare "0" (never "0.0"), in every backend.
327    if n == 0.0 {
328        return FloatParts::Plain("0".to_string());
329    }
330
331    let abs_n = n.abs();
332    if !(SCI_NOTATION_MIN..SCI_NOTATION_MAX).contains(&abs_n) {
333        // The shortest round-tripping mantissa with no precision loss (e.g.
334        // 0.009999 -> "9.999e-3") and an exponent with no leading `+`, matching
335        // the normative number-formatting contract. See `shortest_sci` for why
336        // it is not plain `{:e}`.
337        let sci = shortest_sci(n);
338        if let Some(e_pos) = sci.find('e') {
339            let mut mantissa = sci[..e_pos].to_string();
340            // Ensure at least one decimal place: "1" -> "1.0", "8.64" stays.
341            if !mantissa.contains('.') {
342                mantissa.push_str(".0");
343            }
344            return FloatParts::Scientific {
345                mantissa,
346                exponent: sci[e_pos + 1..].to_string(),
347            };
348        }
349        return FloatParts::Plain(sci);
350    }
351
352    // Integers in normal range display without a decimal point.
353    if n.fract() == 0.0 {
354        return FloatParts::Plain(format!("{}", n as i64));
355    }
356
357    // In-range fractional value: shortest round-tripping decimal, no rounding,
358    // printed at that many fraction digits so a tie breaks to even (see
359    // `shortest_sci`).
360    let (digits, exp) = shortest_digit_count(n);
361    let frac = usize::try_from(digits as i64 - 1 - exp).unwrap_or(0);
362    FloatParts::Plain(format!("{n:.frac$}"))
363}
364
365/// The significant-digit count and decimal exponent of the shortest decimal
366/// that round-trips `n` (finite, non-zero), read off Rust's `{:e}`.
367fn shortest_digit_count(n: f64) -> (usize, i64) {
368    let s = format!("{n:e}");
369    let e_pos = s.find('e').expect("`{:e}` always has an exponent");
370    let digits = s[..e_pos].trim_start_matches('-').replace('.', "").len();
371    let exp = s[e_pos + 1..]
372        .parse()
373        .expect("`{:e}` exponent is an integer");
374    (digits, exp)
375}
376
377/// `n` in scientific notation with the shortest round-tripping mantissa.
378///
379/// `{:e}` finds the right digit COUNT, but when the float lies exactly halfway
380/// between two candidates of that length (844280270821319.2 is stored as
381/// …319.25) it rounds the last digit up, while every other binding — and
382/// JavaScript's and Python's own float printing — breaks the tie to even
383/// (`8.442802708213192e14`). Re-printing at that precision uses exact decimal
384/// conversion, which rounds half to even, so all five bindings agree digit for
385/// digit.
386fn shortest_sci(n: f64) -> String {
387    let (digits, _) = shortest_digit_count(n);
388    let prec = digits - 1;
389    format!("{n:.prec$e}")
390}
391
392/// Replace a leading ASCII hyphen with the Unicode U+2212 MINUS SIGN (used by
393/// the Unicode number printer for both the mantissa and plain values).
394fn unicode_minus(s: String) -> String {
395    match s.strip_prefix('-') {
396        Some(rest) => format!("−{rest}"),
397        None => s,
398    }
399}
400
401/// Unicode number rendering (U+2212 minus, superscript exponent, `∞`/`NaN`).
402fn format_number_unicode(n: f64) -> String {
403    match format_display_float(n) {
404        FloatParts::Plain(s) => unicode_minus(s),
405        FloatParts::Scientific { mantissa, exponent } => {
406            format!(
407                "{}×10{}",
408                unicode_minus(mantissa),
409                to_superscript_str(&exponent)
410            )
411        }
412        FloatParts::Inf(neg) => if neg { "−∞" } else { "∞" }.to_string(),
413        FloatParts::NaN => "NaN".to_string(),
414    }
415}
416
417/// LaTeX number rendering (`\times 10^{…}`, `\infty`/`\text{NaN}`).
418fn format_number_latex(n: f64) -> String {
419    match format_display_float(n) {
420        FloatParts::Plain(s) => s,
421        FloatParts::Scientific { mantissa, exponent } => {
422            format!("{mantissa} \\times 10^{{{exponent}}}")
423        }
424        FloatParts::Inf(neg) => if neg { "-\\infty" } else { "\\infty" }.to_string(),
425        FloatParts::NaN => "\\text{NaN}".to_string(),
426    }
427}
428
429/// ASCII number rendering (`mantissa e exp`, no `+` on a positive exponent).
430fn format_number_ascii(n: f64) -> String {
431    match format_display_float(n) {
432        FloatParts::Plain(s) => s,
433        FloatParts::Scientific { mantissa, exponent } => format!("{mantissa}e{exponent}"),
434        FloatParts::Inf(neg) => if neg { "-inf" } else { "inf" }.to_string(),
435        FloatParts::NaN => "NaN".to_string(),
436    }
437}
438
439/// Render a non-finite value carried as a JSON *string* token (`"Infinity"`,
440/// `"-Infinity"`, `"NaN"`) — JSON cannot encode these as numbers, so the corpus
441/// smuggles them through as variable names. Returns `None` for any ordinary
442/// identifier. See RENDERING_CONTRACT.md "Number formatting".
443fn nonfinite_string(name: &str, fmt: Fmt) -> Option<String> {
444    let n = match name {
445        "Infinity" => f64::INFINITY,
446        "-Infinity" => f64::NEG_INFINITY,
447        "NaN" => f64::NAN,
448        _ => return None,
449    };
450    Some(match fmt {
451        Fmt::Unicode => format_number_unicode(n),
452        Fmt::Latex => format_number_latex(n),
453        Fmt::Ascii => format_number_ascii(n),
454    })
455}
456
457impl fmt::Display for Expr {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        write!(f, "{}", self.to_unicode())
460    }
461}
462
463impl Expr {
464    /// Convert expression to Unicode mathematical notation
465    pub fn to_unicode(&self) -> String {
466        self.to_unicode_with_precedence(0)
467    }
468
469    fn to_unicode_with_precedence(&self, parent_prec: i32) -> String {
470        match self {
471            // Integers and floats share one formatter so a large-magnitude
472            // integer (e.g. 15000) renders as scientific `1.5×10⁴` too.
473            Expr::Number(n) => format_number_unicode(*n),
474            Expr::Integer(n) => format_number_unicode(*n as f64),
475            Expr::Variable(name) => nonfinite_string(name, Fmt::Unicode)
476                .unwrap_or_else(|| format_chemical_subscripts(name)),
477            Expr::Operator(node) => format_operator(node, Fmt::Unicode, parent_prec),
478        }
479    }
480}
481
482/// Render a call-shaped form `op_symbol(arg1, arg2, ...)`.
483///
484/// Dozens of match arms and every `_` fallback across the three printers
485/// previously hand-rolled this exact shape; they share this helper instead.
486/// `render` supplies the backend-specific argument rendering (typically at
487/// precedence 0, since the parentheses already delimit the arguments).
488fn call_form(op_symbol: &str, args: &[Expr], render: impl Fn(&Expr) -> String) -> String {
489    format!(
490        "{}({})",
491        op_symbol,
492        args.iter().map(render).collect::<Vec<_>>().join(", ")
493    )
494}
495
496/// Select the backend-specific spelling of a symbol that differs only by text
497/// (`·`/`\cdot`/`*`, `≥`/`\geq`/`>=`, …). The symbol table that lets the three
498/// operator printers share one traversal.
499fn pick(fmt: Fmt, unicode: &'static str, latex: &'static str, ascii: &'static str) -> &'static str {
500    match fmt {
501        Fmt::Unicode => unicode,
502        Fmt::Latex => latex,
503        Fmt::Ascii => ascii,
504    }
505}
506
507/// Render a binary comparison `a SYM b` (left operand at the strictly-looser
508/// level, right operand under the same-level rule of [`render_operand`]), or
509/// fall back to a call form at any other arity. `infix`/`call` are the
510/// already-selected backend symbols.
511fn comparison_or_call(
512    op: &str,
513    op_prec: i32,
514    args: &[Expr],
515    infix: &str,
516    call: &str,
517    fmt: Fmt,
518) -> String {
519    if args.len() == 2 {
520        format!(
521            "{} {} {}",
522            render_operand(op, op_prec, &args[0], false, fmt),
523            infix,
524            render_operand(op, op_prec, &args[1], true, fmt)
525        )
526    } else {
527        call_form(call, args, |a| render_at(a, fmt, 0))
528    }
529}
530
531/// Render an n-ary infix chain `a SEP b SEP c`: every argument after the first
532/// is a RIGHT operand (see [`render_operand`]), so `k * (a / b) * c`.
533fn infix_chain(op: &str, op_prec: i32, args: &[Expr], sep: &str, fmt: Fmt) -> String {
534    args.iter()
535        .enumerate()
536        .map(|(i, a)| render_operand(op, op_prec, a, i > 0, fmt))
537        .collect::<Vec<_>>()
538        .join(sep)
539}
540
541/// The three text-rendering backends. Shared by the structural / array-query
542/// op renderer so it can produce all three formats from one code path (the
543/// scalar-op dispatch keeps three separate functions for readability).
544#[derive(Clone, Copy, PartialEq, Eq)]
545enum Fmt {
546    Unicode,
547    Latex,
548    Ascii,
549}
550
551/// Render a sub-expression in the requested backend at outer precedence.
552fn render_fmt(expr: &Expr, fmt: Fmt) -> String {
553    match fmt {
554        Fmt::Unicode => to_unicode(expr),
555        Fmt::Latex => to_latex(expr),
556        Fmt::Ascii => to_ascii(expr),
557    }
558}
559
560/// Render an operator node as a scalar expression in the requested backend
561/// (used by `broadcast`, which renders identically to its elementwise `fn`).
562fn render_operator(node: &ExpressionNode, fmt: Fmt) -> String {
563    format_operator(node, fmt, 0)
564}
565
566/// Escape LaTeX-special underscores in a bare operator / identifier name.
567fn latex_name(name: &str) -> String {
568    name.replace('_', "\\_")
569}
570
571/// The integer value of an exponent literal, if it is one (used to pick
572/// Unicode-superscript rendering for `x^n`).
573fn integer_exponent(e: &Expr) -> Option<i64> {
574    match e {
575        Expr::Integer(n) => Some(*n),
576        Expr::Number(n) if n.fract() == 0.0 => Some(*n as i64),
577        _ => None,
578    }
579}
580
581/// Convert a decimal integer string to Unicode superscript characters.
582fn to_superscript_str(s: &str) -> String {
583    s.chars()
584        .map(|c| match c {
585            '0'..='9' => UNICODE_SUPERSCRIPTS[c.to_digit(10).unwrap() as usize],
586            '-' => '⁻',
587            '+' => '⁺',
588            other => other,
589        })
590        .collect()
591}
592
593/// Replace Unicode Greek characters with their ASCII names (e.g. `θ` → `theta`)
594/// for the ASCII backend. Non-Greek characters pass through unchanged.
595fn greek_to_ascii(s: &str) -> String {
596    let mut out = String::new();
597    for c in s.chars() {
598        match greek_char_to_name(c) {
599            Some(name) => out.push_str(name),
600            None => out.push(c),
601        }
602    }
603    out
604}
605
606/// LaTeX command for a variable name that is exactly a Greek letter, given
607/// either as a spelled name (`"phi"`) or a single Unicode character (`"θ"`).
608/// The capitals with a distinct LaTeX command are recognized SPELLED only
609/// (`"Phi"` → `\Phi`); the rest, and every uppercase Unicode char, return
610/// `None` — as does any non-Greek identifier.
611fn greek_to_latex(name: &str) -> Option<String> {
612    for &(n, c, capital_has_latex) in GREEK_TABLE {
613        // A lowercase letter, spelled either way (`"phi"` or `"φ"`).
614        if name == n || name.chars().eq(std::iter::once(c)) {
615            return Some(format!("\\{n}"));
616        }
617        // Its spelled capital (`"Phi"`), when the capital has its own command.
618        let initial = n.chars().next().expect("table names are non-empty");
619        if capital_has_latex && name.strip_prefix(initial.to_ascii_uppercase()) == Some(&n[1..]) {
620            return Some(format!("\\{name}"));
621        }
622    }
623    None
624}
625
626/// The raw identifier string carried by a leaf expression (used by `enum`,
627/// whose members are bare `Type`/`Member` names, not rendered sub-expressions).
628fn expr_raw_string(e: &Expr) -> String {
629    match e {
630        Expr::Variable(s) => s.clone(),
631        Expr::Integer(n) => n.to_string(),
632        Expr::Number(n) => n.to_string(),
633        Expr::Operator(node) => node.op.clone(),
634    }
635}
636
637/// Parenthesize a sub-expression only when it is an operator node (used by
638/// `index` / `transpose`, whose base is bracketed rather than call-wrapped).
639fn wrap_if_op(expr: &Expr, fmt: Fmt) -> String {
640    let s = render_fmt(expr, fmt);
641    if matches!(expr, Expr::Operator(_)) {
642        format!("({s})")
643    } else {
644        s
645    }
646}
647
648/// Render a sub-expression at a specific parent precedence in the given backend.
649fn render_at(expr: &Expr, fmt: Fmt, prec: i32) -> String {
650    match fmt {
651        Fmt::Unicode => expr.to_unicode_with_precedence(prec),
652        Fmt::Latex => to_latex_prec(expr, prec),
653        Fmt::Ascii => to_ascii_prec(expr, prec),
654    }
655}
656
657/// A binary `+` whose right operand is a unary minus renders as a subtraction
658/// (`a + (−b)` → `a − b`); returns the rendered string when this applies.
659fn sum_as_difference(args: &[Expr], op_prec: i32, fmt: Fmt) -> Option<String> {
660    if args.len() != 2 {
661        return None;
662    }
663    let Expr::Operator(n) = &args[1] else {
664        return None;
665    };
666    if n.op != "-" || n.args.len() != 1 {
667        return None;
668    }
669    let minus = if fmt == Fmt::Unicode { "−" } else { "-" };
670    Some(format!(
671        "{} {minus} {}",
672        render_at(&args[0], fmt, op_prec - 1),
673        render_at(&n.args[0], fmt, op_prec)
674    ))
675}
676
677/// True when a LaTeX product has a direct operand that is a variable NAME
678/// already carrying a command backslash (e.g. `\mathrm{O_3}`); such products
679/// render by juxtaposition (a space) rather than with `\cdot`. Mirrors the
680/// `latexSep` decision in pretty-print.ts.
681fn latex_mul_juxtapose(args: &[Expr]) -> bool {
682    args.iter()
683        .any(|a| matches!(a, Expr::Variable(v) if v.contains('\\')))
684}
685
686/// Render one `makearray` region bound. An integer prints PLAIN (`10000`, never
687/// `1.0e4`) — it is an index, not a measured quantity — while an unfolded bound
688/// expression renders like any other sub-expression. Mirrors `formatBound` in
689/// pretty-print.ts.
690fn format_region_bound(bound: &crate::types::RegionBound, fmt: Fmt) -> String {
691    match bound {
692        crate::types::RegionBound::Int(i) => i.to_string(),
693        crate::types::RegionBound::Expr(Expr::Integer(i)) => i.to_string(),
694        crate::types::RegionBound::Expr(e) => render_fmt(e, fmt),
695    }
696}
697
698/// Render a `const` node's literal JSON value (scalar number or nested array).
699/// Integer JSON tokens print as plain integers (so `const 0` is `0`, not
700/// `0.0`); float tokens use the backend's number formatting.
701fn format_const_value(value: &serde_json::Value, fmt: Fmt) -> String {
702    match value {
703        serde_json::Value::Array(arr) => {
704            let inner = arr
705                .iter()
706                .map(|v| format_const_value(v, fmt))
707                .collect::<Vec<_>>()
708                .join(", ");
709            format!("[{inner}]")
710        }
711        serde_json::Value::Number(num) => {
712            if let Some(i) = num.as_i64() {
713                i.to_string()
714            } else if let Some(u) = num.as_u64() {
715                u.to_string()
716            } else if let Some(f) = num.as_f64() {
717                format_number_fmt(f, fmt)
718            } else {
719                num.to_string()
720            }
721        }
722        serde_json::Value::String(s) => s.clone(),
723        serde_json::Value::Bool(b) => b.to_string(),
724        other => other.to_string(),
725    }
726}
727
728/// Stringify a scalar JSON value (e.g. a `table_lookup` `output` selector).
729fn json_scalar_string(v: &serde_json::Value) -> String {
730    match v {
731        serde_json::Value::String(s) => s.clone(),
732        serde_json::Value::Number(n) => n.to_string(),
733        serde_json::Value::Bool(b) => b.to_string(),
734        other => other.to_string(),
735    }
736}
737
738/// Big-operator symbol for a `faq` reduction (semiring supersedes the
739/// bare `reduce`). See tests/display/RENDERING_CONTRACT.md.
740fn aggregate_symbol(semiring: Option<&str>, reduce: &str, fmt: Fmt) -> &'static str {
741    enum Fam {
742        Plus,
743        Times,
744        Max,
745        Min,
746        Bool,
747    }
748    let fam = if let Some(sr) = semiring {
749        match sr {
750            "max_product" | "max_sum" => Fam::Max,
751            "min_sum" => Fam::Min,
752            "bool_and_or" => Fam::Bool,
753            _ => Fam::Plus,
754        }
755    } else {
756        match reduce {
757            "*" => Fam::Times,
758            "max" => Fam::Max,
759            "min" => Fam::Min,
760            _ => Fam::Plus,
761        }
762    };
763    let (u, l, a) = match fam {
764        Fam::Plus => ("Σ", "\\sum", "sum"),
765        Fam::Times => ("Π", "\\prod", "prod"),
766        Fam::Max => ("max", "\\max", "max"),
767        Fam::Min => ("min", "\\min", "min"),
768        Fam::Bool => ("⋁", "\\bigvee", "any"),
769    };
770    match fmt {
771        Fmt::Unicode => u,
772        Fmt::Latex => l,
773        Fmt::Ascii => a,
774    }
775}
776
777/// Render one range entry (`[a,b]` → `a:b`, index-set ref → `name(of…)`).
778fn format_range_spec(rng: &RangeSpec) -> String {
779    match rng {
780        RangeSpec::Interval(iv) => format!("{}:{}", iv[0], iv[1]),
781        RangeSpec::Strided(iv) => format!("{}:{}:{}", iv[0], iv[1], iv[2]),
782        RangeSpec::IndexSetRef { from, of } => match of {
783            Some(of) if !of.is_empty() => format!("{}({})", from, of.join(", ")),
784            _ => from.clone(),
785        },
786        RangeSpec::RaggedDyn { offsets, of } => format!("{}({})", offsets, of.join(", ")),
787        RangeSpec::DerivedDyn { from_faq } => from_faq.clone(),
788    }
789}
790
791/// Render the ` where {…}` range clause shared by aggregate and argmin/argmax
792/// (keys sorted for determinism).
793fn format_ranges_clause(ranges: &std::collections::HashMap<String, RangeSpec>, fmt: Fmt) -> String {
794    let in_sym = match fmt {
795        Fmt::Latex => " \\in ",
796        Fmt::Unicode => "∈",
797        Fmt::Ascii => " in ",
798    };
799    let mut keys: Vec<&String> = ranges.keys().collect();
800    keys.sort();
801    let parts = keys
802        .iter()
803        .map(|k| format!("{}{}{}", k, in_sym, format_range_spec(&ranges[*k])))
804        .collect::<Vec<_>>()
805        .join(", ");
806    match fmt {
807        Fmt::Latex => format!(" \\text{{ where }} \\{{{parts}\\}}"),
808        _ => format!(" where {{{parts}}}"),
809    }
810}
811
812/// Render the ` join(…)` clause shared by `faq` and `argmin`/`argmax`, or `""`
813/// when there is none. Clauses are joined `; `. An equality clause lists its
814/// key-column pairs `l=r` and then, when present, the self-join side assignment
815/// `syms=[left, right]`; an overlap clause is the keyword call
816/// `overlap(src=[…], tgt=[…])` with `, eps=E` whenever `eps` is present
817/// (including `0`). Mirrors `formatJoinClause` in pretty-print.ts.
818fn format_join_clause(join: Option<&[JoinClause]>, fmt: Fmt) -> String {
819    let Some(join) = join.filter(|j| !j.is_empty()) else {
820        return String::new();
821    };
822    let clauses = join
823        .iter()
824        .map(|c| {
825            if let Some(ov) = &c.overlap {
826                let eps = match ov.eps {
827                    Some(e) => format!(", eps={}", format_number_fmt(e, fmt)),
828                    None => String::new(),
829                };
830                return format!(
831                    "overlap(src=[{}], tgt=[{}]{eps})",
832                    ov.src_env.join(", "),
833                    ov.tgt_env.join(", ")
834                );
835            }
836            let mut parts: Vec<String> =
837                c.on.iter().map(|p| format!("{}={}", p[0], p[1])).collect();
838            if let Some(syms) = &c.syms {
839                parts.push(format!("syms=[{}]", syms.join(", ")));
840            }
841            parts.join(", ")
842        })
843        .collect::<Vec<_>>()
844        .join("; ");
845    format!(" join({clauses})")
846}
847
848/// A float in the backend's number formatting.
849fn format_number_fmt(n: f64, fmt: Fmt) -> String {
850    match fmt {
851        Fmt::Unicode => format_number_unicode(n),
852        Fmt::Latex => format_number_latex(n),
853        Fmt::Ascii => format_number_ascii(n),
854    }
855}
856
857/// Render a `faq` node per tests/display/RENDERING_CONTRACT.md §aggregate.
858fn format_aggregate(node: &ExpressionNode, fmt: Fmt) -> String {
859    let out_idx = node
860        .output_idx
861        .as_ref()
862        .map(|v| v.join(", "))
863        .unwrap_or_default();
864    let expr_str = node
865        .expr
866        .as_deref()
867        .map(|e| render_fmt(e, fmt))
868        .unwrap_or_default();
869    let semiring = node.semiring.as_deref();
870    let reduce = node.reduce.as_deref().unwrap_or("+");
871    let sym = aggregate_symbol(semiring, reduce, fmt);
872    let idx_part = if fmt == Fmt::Latex {
873        format!("_{{{out_idx}}}")
874    } else {
875        format!("[{out_idx}]")
876    };
877    let mut out = format!("{sym}{idx_part} ({expr_str})");
878    if let Some(ranges) = &node.ranges
879        && !ranges.is_empty()
880    {
881        out.push_str(&format_ranges_clause(ranges, fmt));
882    }
883    out.push_str(&format_join_clause(node.join.as_deref(), fmt));
884    if let Some(filter) = node.filter.as_deref() {
885        out.push_str(&format!(" if {}", render_fmt(filter, fmt)));
886    }
887    if node.distinct == Some(true) {
888        out.push_str(" distinct");
889    }
890    if let Some(key) = node.key.as_deref() {
891        out.push_str(&format!(" key={}", render_fmt(key, fmt)));
892    }
893    // `id` is a bare-name clause rather than part of the `[…]` suffix so it adds
894    // no new bracket ambiguity with `table_lookup`'s `name[axis=…]` surface.
895    if let Some(id) = node.id.as_deref() {
896        out.push_str(&format!(" id={id}"));
897    }
898    if let Some(sr) = semiring
899        && sr != "sum_product"
900    {
901        out.push_str(&format!(" [semiring={sr}]"));
902    }
903    out
904}
905
906/// Render an `argmin` / `argmax` arg-witness node.
907fn format_arg_witness(node: &ExpressionNode, fmt: Fmt) -> String {
908    let arg = node.arg.as_deref().unwrap_or("");
909    let expr_str = node
910        .expr
911        .as_deref()
912        .map(|e| render_fmt(e, fmt))
913        .unwrap_or_default();
914    let idx_part = if fmt == Fmt::Latex {
915        format!("_{{{arg}}}")
916    } else {
917        format!("[{arg}]")
918    };
919    let name = if fmt == Fmt::Latex {
920        format!("\\mathrm{{{}}}", node.op)
921    } else {
922        node.op.clone()
923    };
924    let mut out = format!("{name}{idx_part} ({expr_str})");
925    if let Some(ranges) = &node.ranges
926        && !ranges.is_empty()
927    {
928        out.push_str(&format_ranges_clause(ranges, fmt));
929    }
930    out.push_str(&format_join_clause(node.join.as_deref(), fmt));
931    if let Some(filter) = node.filter.as_deref() {
932        out.push_str(&format!(" if {}", render_fmt(filter, fmt)));
933    }
934    if let Some(id) = node.id.as_deref() {
935        out.push_str(&format!(" id={id}"));
936    }
937    out
938}
939
940/// Render the closed-core structural / array-query ops (esm-spec §4.2), whose
941/// defining data lives in fields OTHER than `args`, plus `integral`. Returns a
942/// fully-formatted string, or `None` for ops handled by the scalar-op dispatch
943/// (arithmetic, elementary functions, comparisons, `D`, `Pre`, …) or by the
944/// generic fallback (open-tier sugar `grad`/`div`/`laplacian`, unknown user
945/// ops). Mirrors `formatStructuralOp` in pretty-print.ts.
946fn format_structural_op(node: &ExpressionNode, fmt: Fmt) -> Option<String> {
947    let op = node.op.as_str();
948    let args = node.args.as_slice();
949    let eq = if fmt == Fmt::Latex { " = " } else { "=" };
950
951    match op {
952        "const" => Some(format_const_value(
953            node.value.as_ref().unwrap_or(&serde_json::Value::Null),
954            fmt,
955        )),
956
957        "true" | "false" => Some(op.to_string()),
958
959        "fn" => {
960            let name = node.name.as_deref().unwrap_or("");
961            let inner = args
962                .iter()
963                .map(|a| render_fmt(a, fmt))
964                .collect::<Vec<_>>()
965                .join(", ");
966            Some(match fmt {
967                Fmt::Latex => format!("\\mathrm{{{}}}({})", latex_name(name), inner),
968                _ => format!("{name}({inner})"),
969            })
970        }
971
972        "enum" => {
973            let a0 = args.first().map(expr_raw_string).unwrap_or_default();
974            let a1 = args.get(1).map(expr_raw_string).unwrap_or_default();
975            let label = format!("{a0}.{a1}");
976            Some(match fmt {
977                Fmt::Latex => format!("\\mathrm{{{}}}", latex_name(&label)),
978                _ => label,
979            })
980        }
981
982        "index" => {
983            if args.is_empty() {
984                return None;
985            }
986            let idx = args[1..]
987                .iter()
988                .map(|a| render_fmt(a, fmt))
989                .collect::<Vec<_>>()
990                .join(", ");
991            Some(format!("{}[{}]", wrap_if_op(&args[0], fmt), idx))
992        }
993
994        "broadcast" => {
995            let fn_name = node.broadcast_fn.as_deref()?;
996            let synth = ExpressionNode {
997                op: fn_name.to_string(),
998                args: node.args.clone(),
999                ..Default::default()
1000            };
1001            Some(render_operator(&synth, fmt))
1002        }
1003
1004        "integral" => {
1005            if args.is_empty() {
1006                return None;
1007            }
1008            let f = render_fmt(&args[0], fmt);
1009            let v = node.int_var.as_deref().unwrap_or("x");
1010            let lo = node
1011                .lower
1012                .as_deref()
1013                .map(|e| render_fmt(e, fmt))
1014                .unwrap_or_default();
1015            let hi = node
1016                .upper
1017                .as_deref()
1018                .map(|e| render_fmt(e, fmt))
1019                .unwrap_or_default();
1020            Some(match fmt {
1021                Fmt::Latex => format!("\\int_{{{lo}}}^{{{hi}}} {f} \\, d{v}"),
1022                Fmt::Unicode => format!("∫[{lo}, {hi}] {f} d{v}"),
1023                Fmt::Ascii => format!("integral({f}, {v}, {lo}, {hi})"),
1024            })
1025        }
1026
1027        "table_lookup" => {
1028            let table = node.table.as_deref().unwrap_or("");
1029            let bindings = node
1030                .axes
1031                .as_ref()
1032                .map(|axes| {
1033                    let mut keys: Vec<&String> = axes.keys().collect();
1034                    keys.sort();
1035                    keys.iter()
1036                        .map(|k| format!("{}{}{}", k, eq, render_fmt(&axes[*k], fmt)))
1037                        .collect::<Vec<_>>()
1038                        .join(", ")
1039                })
1040                .unwrap_or_default();
1041            let out_str = match &node.output {
1042                Some(v) if !v.is_null() => format!(":{}", json_scalar_string(v)),
1043                _ => String::new(),
1044            };
1045            let name = if fmt == Fmt::Latex {
1046                format!("\\mathrm{{{}}}", latex_name(table))
1047            } else {
1048                table.to_string()
1049            };
1050            Some(format!("{name}[{bindings}]{out_str}"))
1051        }
1052
1053        "apply_expression_template" => {
1054            let name = node.name.as_deref().unwrap_or("");
1055            let inner = node
1056                .bindings
1057                .as_ref()
1058                .map(|b| {
1059                    let mut keys: Vec<&String> = b.keys().collect();
1060                    keys.sort();
1061                    keys.iter()
1062                        .map(|k| format!("{}{}{}", k, eq, render_fmt(&b[*k], fmt)))
1063                        .collect::<Vec<_>>()
1064                        .join(", ")
1065                })
1066                .unwrap_or_default();
1067            Some(match fmt {
1068                Fmt::Latex => format!(
1069                    "\\mathrm{{{}}}\\langle {} \\rangle",
1070                    latex_name(name),
1071                    inner
1072                ),
1073                Fmt::Unicode => format!("{name}⟨{inner}⟩"),
1074                Fmt::Ascii => format!("{name}<{inner}>"),
1075            })
1076        }
1077
1078        "makearray" => {
1079            let values = node.values.as_ref();
1080            let parts = node
1081                .regions
1082                .as_ref()
1083                .map(|regions| {
1084                    regions
1085                        .iter()
1086                        .enumerate()
1087                        .map(|(i, region)| {
1088                            let reg_str = region
1089                                .iter()
1090                                .map(|dim| {
1091                                    format!(
1092                                        "{}:{}",
1093                                        format_region_bound(&dim[0], fmt),
1094                                        format_region_bound(&dim[1], fmt)
1095                                    )
1096                                })
1097                                .collect::<Vec<_>>()
1098                                .join(", ");
1099                            let val = values
1100                                .and_then(|vs| vs.get(i))
1101                                .map(|v| render_fmt(v, fmt))
1102                                .unwrap_or_else(|| "?".to_string());
1103                            format!("[{reg_str}] = {val}")
1104                        })
1105                        .collect::<Vec<_>>()
1106                        .join(", ")
1107                })
1108                .unwrap_or_default();
1109            let name = if fmt == Fmt::Latex {
1110                "\\mathrm{makearray}"
1111            } else {
1112                "makearray"
1113            };
1114            Some(format!("{name}({parts})"))
1115        }
1116
1117        "reshape" => {
1118            if args.is_empty() {
1119                return None;
1120            }
1121            let shape = node
1122                .shape
1123                .as_ref()
1124                .map(|s| {
1125                    s.iter()
1126                        .map(|x| x.to_string())
1127                        .collect::<Vec<_>>()
1128                        .join(", ")
1129                })
1130                .unwrap_or_default();
1131            let name = if fmt == Fmt::Latex {
1132                "\\mathrm{reshape}"
1133            } else {
1134                "reshape"
1135            };
1136            Some(format!(
1137                "{}({}, [{}])",
1138                name,
1139                render_fmt(&args[0], fmt),
1140                shape
1141            ))
1142        }
1143
1144        "transpose" => {
1145            if args.is_empty() {
1146                return None;
1147            }
1148            if let Some(perm) = &node.perm
1149                && !perm.is_empty()
1150            {
1151                let p = perm
1152                    .iter()
1153                    .map(|x| x.to_string())
1154                    .collect::<Vec<_>>()
1155                    .join(", ");
1156                let name = if fmt == Fmt::Latex {
1157                    "\\mathrm{transpose}"
1158                } else {
1159                    "transpose"
1160                };
1161                return Some(format!("{}({}, [{}])", name, render_fmt(&args[0], fmt), p));
1162            }
1163            let a = wrap_if_op(&args[0], fmt);
1164            Some(match fmt {
1165                Fmt::Latex => format!("{a}^{{T}}"),
1166                Fmt::Unicode => format!("{a}ᵀ"),
1167                Fmt::Ascii => format!("transpose({})", render_fmt(&args[0], fmt)),
1168            })
1169        }
1170
1171        "concat" => {
1172            let inner = args
1173                .iter()
1174                .map(|a| render_fmt(a, fmt))
1175                .collect::<Vec<_>>()
1176                .join(", ");
1177            let name = if fmt == Fmt::Latex {
1178                "\\mathrm{concat}"
1179            } else {
1180                "concat"
1181            };
1182            let axis = node.axis.unwrap_or(0);
1183            Some(format!("{name}({inner}, axis={axis})"))
1184        }
1185
1186        "intersect_polygon" | "polygon_intersection_area" => {
1187            let inner = args
1188                .iter()
1189                .map(|a| render_fmt(a, fmt))
1190                .collect::<Vec<_>>()
1191                .join(", ");
1192            let name = if fmt == Fmt::Latex {
1193                format!("\\mathrm{{{}}}", latex_name(op))
1194            } else {
1195                op.to_string()
1196            };
1197            let manifold = node.manifold.as_deref().unwrap_or("");
1198            // `id` (RFC §6.1 node identity) is emitted as a trailing named
1199            // argument when present, so a node addressable as a derived index
1200            // set's `from_faq` referent survives the text round-trip. Absent
1201            // `id` prints exactly as before.
1202            let id_part = match node.id.as_deref() {
1203                Some(id) => format!(", id={id}"),
1204                None => String::new(),
1205            };
1206            Some(format!("{name}({inner}, manifold={manifold}{id_part})"))
1207        }
1208
1209        // The documentary `label` is a trailing named argument, like
1210        // `intersect_polygon`'s `manifold=`; an unlabeled skolem is a plain
1211        // call (the generic fallback).
1212        "skolem" => {
1213            let label = node.label.as_deref()?;
1214            let inner = args
1215                .iter()
1216                .map(|a| render_fmt(a, fmt))
1217                .collect::<Vec<_>>()
1218                .join(", ");
1219            let name = if fmt == Fmt::Latex {
1220                "\\mathrm{skolem}"
1221            } else {
1222                "skolem"
1223            };
1224            Some(format!("{name}({inner}, label={label})"))
1225        }
1226
1227        "faq" => Some(format_aggregate(node, fmt)),
1228
1229        "argmin" | "argmax" => Some(format_arg_witness(node, fmt)),
1230
1231        _ => None,
1232    }
1233}
1234
1235/// Render a scalar operator node (arithmetic, comparisons, boolean ops,
1236/// elementary functions, `D`, `Pre`, and the generic call fallback) in the
1237/// requested backend. The three backends share one traversal and per-op symbol
1238/// table (`pick`); only genuinely shape-divergent ops keep a per-`fmt` leaf:
1239/// LaTeX `\frac`/`\begin{cases}`/`\exp`/juxtaposition-multiplication, and the
1240/// Unicode integer-exponent superscripts and `⌊⌋`/`⌈⌉`/`|·|`/`√` delimiters.
1241/// Mirrors the `format_structural_op` unification for closed-core ops.
1242fn format_operator(node: &ExpressionNode, fmt: Fmt, parent_prec: i32) -> String {
1243    // Closed-core structural / array-query ops (and `integral`) render from
1244    // their non-`args` fields and are never parenthesized by precedence.
1245    if let Some(s) = format_structural_op(node, fmt) {
1246        return s;
1247    }
1248
1249    let op = node.op.as_str();
1250    let args = node.args.as_slice();
1251    let wrt = &node.wrt;
1252    let op_prec = get_precedence(op);
1253    let needs_parens = op_prec > 0 && op_prec <= parent_prec;
1254    // Renders an argument at precedence 0 (used inside delimited contexts).
1255    let r0 = |arg: &Expr| render_at(arg, fmt, 0);
1256
1257    // `\frac{...}{...}` groups its OWN operands visually, so the numerator and
1258    // denominator render at precedence 0 — but the fraction still takes
1259    // parentheses from its PARENT when precedence demands them, exactly as the
1260    // reference printer does (`(\frac{b}{c})^{d}`). Returning early here without
1261    // consulting `needs_parens` silently dropped them.
1262    if fmt == Fmt::Latex && op == "/" && args.len() == 2 {
1263        let frac = format!("\\frac{{{}}}{{{}}}", r0(&args[0]), r0(&args[1]));
1264        return if needs_parens {
1265            format!("({frac})")
1266        } else {
1267            frac
1268        };
1269    }
1270
1271    let result = match op {
1272        "+" => {
1273            if let Some(s) = sum_as_difference(args, op_prec, fmt) {
1274                s
1275            } else if args.len() >= 2 {
1276                infix_chain(op, op_prec, args, " + ", fmt)
1277            } else {
1278                call_form("+", args, r0)
1279            }
1280        }
1281        "-" => {
1282            let minus = pick(fmt, "−", "-", "-");
1283            if args.len() == 1 {
1284                // A unary-minus operand renders at the ADDITIVE level, so a sum
1285                // or difference keeps its parentheses (`−(a + b)`) while a
1286                // product or power does not (`−a · b`, `−a^2`) — exactly the
1287                // operands the parser re-absorbs. See
1288                // `UMINUS_OPERAND_PARENT_PREC`.
1289                let operand = render_at(&args[0], fmt, UMINUS_OPERAND_PARENT_PREC);
1290                if starts_with_literal_power(&args[0]) {
1291                    format!("{minus}({operand})")
1292                } else {
1293                    format!("{minus}{operand}")
1294                }
1295            } else if args.len() == 2 {
1296                // Left-associative and NON-associative on the right: the right
1297                // operand renders at `op_prec` — a child at the SAME precedence
1298                // keeps its parentheses (`a − (b − c)`), while one that binds
1299                // TIGHTER does not (`a − b · c`, not `a − (b · c)`). Rendering
1300                // it at `op_prec + 1` over-parenthesized every product /
1301                // quotient / power, diverging from the reference printer
1302                // (pretty-print.ts `needsParentheses`).
1303                format!(
1304                    "{} {minus} {}",
1305                    render_at(&args[0], fmt, op_prec - 1),
1306                    render_at(&args[1], fmt, op_prec)
1307                )
1308            } else {
1309                call_form(minus, args, r0)
1310            }
1311        }
1312        "*" => {
1313            if args.len() >= 2 {
1314                // A LaTeX product of already-typeset factors uses implicit
1315                // juxtaposition; the other backends use their explicit symbol.
1316                let sep: &str = match fmt {
1317                    Fmt::Unicode => "·",
1318                    Fmt::Ascii => " * ",
1319                    Fmt::Latex if latex_mul_juxtapose(args) => " ",
1320                    Fmt::Latex => " \\cdot ",
1321                };
1322                infix_chain(op, op_prec, args, sep, fmt)
1323            } else {
1324                call_form(pick(fmt, "·", "\\cdot", "*"), args, r0)
1325            }
1326        }
1327        "/" => {
1328            // Binary LaTeX `/` is handled by the `\frac` early return above.
1329            if args.len() == 2 {
1330                // The left operand of a left-associative `/` only needs parens
1331                // when it binds *strictly* looser (op_prec - 1), so `a·b/c`
1332                // stays unparenthesized while `(a + b)/c` does not.
1333                format!(
1334                    "{}{}{}",
1335                    render_at(&args[0], fmt, op_prec - 1),
1336                    pick(fmt, "/", "/", " / "),
1337                    render_at(&args[1], fmt, op_prec)
1338                )
1339            } else {
1340                call_form(pick(fmt, "÷", "\\div", "/"), args, r0)
1341            }
1342        }
1343        "^" => {
1344            if args.len() == 2 {
1345                match fmt {
1346                    // Integer exponents render as Unicode superscripts.
1347                    Fmt::Unicode => match integer_exponent(&args[1]) {
1348                        Some(n) => format!(
1349                            "{}{}",
1350                            render_at(&args[0], fmt, op_prec),
1351                            to_superscript_str(&n.to_string())
1352                        ),
1353                        None => format!(
1354                            "{}^{}",
1355                            render_at(&args[0], fmt, op_prec),
1356                            render_at(&args[1], fmt, op_prec)
1357                        ),
1358                    },
1359                    // The exponent sits inside `^{...}`, which groups visually,
1360                    // so it renders at precedence 0.
1361                    Fmt::Latex => {
1362                        format!("{}^{{{}}}", render_at(&args[0], fmt, op_prec), r0(&args[1]))
1363                    }
1364                    Fmt::Ascii => format!(
1365                        "{}^{}",
1366                        render_at(&args[0], fmt, op_prec),
1367                        render_at(&args[1], fmt, op_prec)
1368                    ),
1369                }
1370            } else {
1371                call_form("^", args, r0)
1372            }
1373        }
1374        "D" => {
1375            // Derivative operator; the operand is parenthesized when it is an
1376            // operator node (`∂(x + y)/∂t`, never `∂x + y/∂t`).
1377            //
1378            // An ABSENT `wrt` means `t` (esm-spec §4.2), so it renders exactly
1379            // as the explicit spelling does. Requiring `Some(_)` here printed
1380            // `D(x)` for a node Julia, Python and TypeScript all print as
1381            // `∂x/∂t` (EarthSciAST#407).
1382            let wrt_default = wrt
1383                .as_deref()
1384                .unwrap_or(crate::op_registry::STRUCTURAL_DERIVATIVE_WRT);
1385            if let [arg] = args {
1386                let wrt_var = wrt_default;
1387                match fmt {
1388                    Fmt::Unicode => format!(
1389                        "∂{}/∂{}",
1390                        wrap_if_op(arg, fmt),
1391                        format_chemical_subscripts(wrt_var)
1392                    ),
1393                    Fmt::Latex => format!(
1394                        "\\frac{{\\partial {}}}{{\\partial {}}}",
1395                        wrap_if_op(arg, fmt),
1396                        wrt_var
1397                    ),
1398                    // ASCII fraction form `D(operand)/Dt`.
1399                    Fmt::Ascii => format!("D({})/D{wrt_var}", r0(arg)),
1400                }
1401            } else {
1402                call_form("D", args, r0)
1403            }
1404        }
1405        ">" => comparison_or_call(op, op_prec, args, ">", ">", fmt),
1406        "<" => comparison_or_call(op, op_prec, args, "<", "<", fmt),
1407        ">=" => comparison_or_call(
1408            op,
1409            op_prec,
1410            args,
1411            pick(fmt, "≥", "\\geq", ">="),
1412            pick(fmt, ">=", "\\geq", ">="),
1413            fmt,
1414        ),
1415        "<=" => comparison_or_call(
1416            op,
1417            op_prec,
1418            args,
1419            pick(fmt, "≤", "\\leq", "<="),
1420            pick(fmt, "<=", "\\leq", "<="),
1421            fmt,
1422        ),
1423        "=" | "==" => comparison_or_call(
1424            op,
1425            op_prec,
1426            args,
1427            pick(fmt, "=", "=", "=="),
1428            pick(fmt, "=", "=", "=="),
1429            fmt,
1430        ),
1431        "!=" => comparison_or_call(
1432            op,
1433            op_prec,
1434            args,
1435            pick(fmt, "≠", "\\neq", "!="),
1436            pick(fmt, "!=", "\\neq", "!="),
1437            fmt,
1438        ),
1439        "and" => {
1440            if args.len() >= 2 {
1441                infix_chain(
1442                    op,
1443                    op_prec,
1444                    args,
1445                    pick(fmt, " ∧ ", " \\land ", " and "),
1446                    fmt,
1447                )
1448            } else {
1449                call_form(pick(fmt, "and", "\\land", "and"), args, r0)
1450            }
1451        }
1452        "or" => {
1453            if args.len() >= 2 {
1454                infix_chain(op, op_prec, args, pick(fmt, " ∨ ", " \\lor ", " or "), fmt)
1455            } else {
1456                call_form(pick(fmt, "or", "\\lor", "or"), args, r0)
1457            }
1458        }
1459        "not" => {
1460            if args.len() == 1 {
1461                // Parenthesize a complex operand (`¬(x == 0)`).
1462                let prefix = pick(fmt, "¬", "\\neg ", "not ");
1463                if matches!(&args[0], Expr::Operator(_)) {
1464                    format!("{prefix}({})", r0(&args[0]))
1465                } else {
1466                    format!("{prefix}{}", r0(&args[0]))
1467                }
1468            } else {
1469                call_form(pick(fmt, "not", "\\neg", "not"), args, r0)
1470            }
1471        }
1472        "exp" => match fmt {
1473            // LaTeX wraps a compound argument in `\left(…\right)`.
1474            Fmt::Latex => {
1475                if let [arg] = args {
1476                    if matches!(arg, Expr::Operator(_)) {
1477                        format!("\\exp\\left({}\\right)", r0(arg))
1478                    } else {
1479                        format!("\\exp({})", r0(arg))
1480                    }
1481                } else {
1482                    call_form("\\exp", args, r0)
1483                }
1484            }
1485            _ => call_form("exp", args, r0),
1486        },
1487        "ifelse" => match fmt {
1488            Fmt::Latex if args.len() == 3 => format!(
1489                "\\begin{{cases}} {} & \\text{{if }} {} \\\\ {} & \\text{{otherwise}} \\end{{cases}}",
1490                r0(&args[1]),
1491                r0(&args[0]),
1492                r0(&args[2])
1493            ),
1494            Fmt::Latex => call_form("\\mathrm{ifelse}", args, r0),
1495            _ => call_form("ifelse", args, r0),
1496        },
1497        "log" => {
1498            if args.len() == 1 {
1499                call_form(pick(fmt, "ln", "\\ln", "log"), args, r0)
1500            } else {
1501                call_form(pick(fmt, "log", "\\log", "log"), args, r0)
1502            }
1503        }
1504        "log10" => {
1505            let sym = if args.len() == 1 {
1506                pick(fmt, "log₁₀", "\\log_{10}", "log10")
1507            } else {
1508                pick(fmt, "log10", "\\log_{10}", "log10")
1509            };
1510            call_form(sym, args, r0)
1511        }
1512        "sqrt" => match fmt {
1513            Fmt::Unicode => {
1514                if let [arg] = args {
1515                    // Parenthesize a compound radicand for clarity.
1516                    if matches!(arg, Expr::Operator(_)) {
1517                        format!("√({})", r0(arg))
1518                    } else {
1519                        format!("√{}", r0(arg))
1520                    }
1521                } else {
1522                    call_form("sqrt", args, r0)
1523                }
1524            }
1525            Fmt::Latex => format!(
1526                "\\sqrt{{{}}}",
1527                args.iter().map(&r0).collect::<Vec<_>>().join(", ")
1528            ),
1529            Fmt::Ascii => call_form("sqrt", args, r0),
1530        },
1531        "asin" => {
1532            let sym = if args.len() == 1 {
1533                pick(fmt, "arcsin", "\\arcsin", "asin")
1534            } else {
1535                pick(fmt, "asin", "\\arcsin", "asin")
1536            };
1537            call_form(sym, args, r0)
1538        }
1539        "acos" => {
1540            let sym = if args.len() == 1 {
1541                pick(fmt, "arccos", "\\arccos", "acos")
1542            } else {
1543                pick(fmt, "acos", "\\arccos", "acos")
1544            };
1545            call_form(sym, args, r0)
1546        }
1547        "atan" => {
1548            let sym = if args.len() == 1 {
1549                pick(fmt, "arctan", "\\arctan", "atan")
1550            } else {
1551                pick(fmt, "atan", "\\arctan", "atan")
1552            };
1553            call_form(sym, args, r0)
1554        }
1555        "abs" => match fmt {
1556            Fmt::Unicode => {
1557                if args.len() == 1 {
1558                    format!("|{}|", r0(&args[0]))
1559                } else {
1560                    call_form("abs", args, r0)
1561                }
1562            }
1563            Fmt::Latex => format!("|{}|", args.iter().map(&r0).collect::<Vec<_>>().join(", ")),
1564            Fmt::Ascii => call_form("abs", args, r0),
1565        },
1566        "sign" => call_form(pick(fmt, "sgn", "\\mathrm{sgn}", "sign"), args, r0),
1567        "floor" => match fmt {
1568            Fmt::Unicode => {
1569                if args.len() == 1 {
1570                    format!("⌊{}⌋", r0(&args[0]))
1571                } else {
1572                    call_form("floor", args, r0)
1573                }
1574            }
1575            Fmt::Latex => format!(
1576                "\\lfloor {} \\rfloor",
1577                args.iter().map(&r0).collect::<Vec<_>>().join(", ")
1578            ),
1579            Fmt::Ascii => call_form("floor", args, r0),
1580        },
1581        "ceil" => match fmt {
1582            Fmt::Unicode => {
1583                if args.len() == 1 {
1584                    format!("⌈{}⌉", r0(&args[0]))
1585                } else {
1586                    call_form("ceil", args, r0)
1587                }
1588            }
1589            Fmt::Latex => format!(
1590                "\\lceil {} \\rceil",
1591                args.iter().map(&r0).collect::<Vec<_>>().join(", ")
1592            ),
1593            Fmt::Ascii => call_form("ceil", args, r0),
1594        },
1595        "asinh" => {
1596            let sym = if args.len() == 1 {
1597                pick(fmt, "sinh⁻¹", "\\sinh^{-1}", "asinh")
1598            } else {
1599                pick(fmt, "asinh", "\\sinh^{-1}", "asinh")
1600            };
1601            call_form(sym, args, r0)
1602        }
1603        "acosh" => {
1604            let sym = if args.len() == 1 {
1605                pick(fmt, "cosh⁻¹", "\\cosh^{-1}", "acosh")
1606            } else {
1607                pick(fmt, "acosh", "\\cosh^{-1}", "acosh")
1608            };
1609            call_form(sym, args, r0)
1610        }
1611        "atanh" => {
1612            let sym = if args.len() == 1 {
1613                pick(fmt, "tanh⁻¹", "\\tanh^{-1}", "atanh")
1614            } else {
1615                pick(fmt, "atanh", "\\tanh^{-1}", "atanh")
1616            };
1617            call_form(sym, args, r0)
1618        }
1619        "min" => call_form(pick(fmt, "min", "\\min", "min"), args, r0),
1620        "max" => call_form(pick(fmt, "max", "\\max", "max"), args, r0),
1621        "atan2" => call_form(pick(fmt, "atan2", "\\mathrm{atan2}", "atan2"), args, r0),
1622        "sin" | "cos" | "tan" | "sinh" | "cosh" | "tanh" => {
1623            // LaTeX prefixes a backslash (`\sin`); the others use the bare name.
1624            let latex = format!("\\{op}");
1625            call_form(
1626                if fmt == Fmt::Latex {
1627                    latex.as_str()
1628                } else {
1629                    op
1630                },
1631                args,
1632                r0,
1633            )
1634        }
1635        // `Pre` renders as a call form, matching the cross-language contract.
1636        "Pre" => call_form(pick(fmt, "Pre", "\\mathrm{Pre}", "Pre"), args, r0),
1637        // Generic fallback: open-tier sugar (grad/div/laplacian) and any
1638        // unknown user op. LaTeX wraps in `\mathrm{…}`, escaping underscores.
1639        _ => {
1640            let latex = format!("\\mathrm{{{}}}", latex_name(op));
1641            call_form(
1642                if fmt == Fmt::Latex {
1643                    latex.as_str()
1644                } else {
1645                    op
1646                },
1647                args,
1648                r0,
1649            )
1650        }
1651    };
1652
1653    if needs_parens {
1654        format!("({result})")
1655    } else {
1656        result
1657    }
1658}
1659
1660/// The domain the three text renderers accept: expressions AND containers.
1661///
1662/// API_SPEC.md §8 item 18 requires [`to_unicode`] / [`to_latex`] /
1663/// [`to_ascii`] to accept the full domain in every binding — this binding
1664/// accepted [`Expr`] only. The domain mirrors Python's
1665/// `Expr | Equation | Model | ReactionSystem | EsmFile`.
1666///
1667/// Container renderings are the esm-spec §6.3 SUMMARIES and are
1668/// format-independent, exactly as Python's `_format_*_summary` helpers are
1669/// (they take a `format_type` and ignore it). Only the expression and
1670/// equation renderings differ per format.
1671pub trait TextRenderable {
1672    /// Unicode mathematical notation.
1673    fn render_unicode(&self) -> String;
1674    /// LaTeX notation.
1675    fn render_latex(&self) -> String;
1676    /// Plain-ASCII notation.
1677    fn render_ascii(&self) -> String;
1678}
1679
1680impl TextRenderable for Expr {
1681    fn render_unicode(&self) -> String {
1682        self.to_unicode()
1683    }
1684    fn render_latex(&self) -> String {
1685        to_latex_prec(self, 0)
1686    }
1687    fn render_ascii(&self) -> String {
1688        to_ascii_prec(self, 0)
1689    }
1690}
1691
1692impl TextRenderable for Equation {
1693    fn render_unicode(&self) -> String {
1694        format!(
1695            "{} = {}",
1696            self.lhs.render_unicode(),
1697            self.rhs.render_unicode()
1698        )
1699    }
1700    fn render_latex(&self) -> String {
1701        format!("{} = {}", self.lhs.render_latex(), self.rhs.render_latex())
1702    }
1703    fn render_ascii(&self) -> String {
1704        format!("{} = {}", self.lhs.render_ascii(), self.rhs.render_ascii())
1705    }
1706}
1707
1708/// Container summaries: the same §6.3 summary in all three formats, which is
1709/// what the peer bindings do (their summary helpers accept a format and never
1710/// branch on it). Rendered by the type's own `Display`, so the text is the one
1711/// already pinned by `tests/display/model_summary.json`.
1712macro_rules! summary_renderable {
1713    ($t:ty) => {
1714        impl TextRenderable for $t {
1715            fn render_unicode(&self) -> String {
1716                self.to_string()
1717            }
1718            fn render_latex(&self) -> String {
1719                self.to_string()
1720            }
1721            fn render_ascii(&self) -> String {
1722                self.to_string()
1723            }
1724        }
1725    };
1726}
1727
1728summary_renderable!(Model);
1729summary_renderable!(ReactionSystem);
1730summary_renderable!(EsmFile);
1731
1732/// Render `target` as Unicode mathematical notation.
1733///
1734/// Accepts the full display domain (API_SPEC.md §8 item 18): an [`Expr`], an
1735/// [`Equation`], or a [`Model`] / [`ReactionSystem`] / [`EsmFile`] summary.
1736pub fn to_unicode<T: TextRenderable + ?Sized>(target: &T) -> String {
1737    target.render_unicode()
1738}
1739
1740/// Render `target` as LaTeX. See [`to_unicode`] for the accepted domain.
1741pub fn to_latex<T: TextRenderable + ?Sized>(target: &T) -> String {
1742    target.render_latex()
1743}
1744
1745/// LaTeX rendering with the parent operator's precedence threaded through.
1746///
1747/// The LaTeX printer previously ignored precedence entirely, so `(a+b)*c`
1748/// rendered as the mathematically wrong `a + b \cdot c`; it now applies the
1749/// same parenthesization rule as the reference Unicode printer.
1750fn to_latex_prec(expr: &Expr, parent_prec: i32) -> String {
1751    match expr {
1752        Expr::Integer(n) => format_number_latex(*n as f64),
1753        Expr::Number(n) => format_number_latex(*n),
1754        Expr::Variable(name) => {
1755            nonfinite_string(name, Fmt::Latex).unwrap_or_else(|| format_variable_latex(name))
1756        }
1757        Expr::Operator(node) => format_operator(node, Fmt::Latex, parent_prec),
1758    }
1759}
1760
1761fn format_variable_latex(name: &str) -> String {
1762    // A name that is already LaTeX passes through verbatim.
1763    if is_preformatted_latex(name) {
1764        return name.to_string();
1765    }
1766    // A variable that is exactly a Greek letter renders as its LaTeX command.
1767    if let Some(cmd) = greek_to_latex(name) {
1768        return cmd;
1769    }
1770    format_chemical_latex(name)
1771}
1772
1773/// True when a variable NAME is already hand-written LaTeX and must be emitted
1774/// verbatim (mirrors `isPreformattedLatex` in pretty-print.ts): a `\mathrm{…}`
1775/// atom, a bare command like `\theta` (a backslash with no brace), or a subscript
1776/// atom like `k_{NO_O3}` (braces without a leading `\command{…}`). A braced
1777/// `\command{…}` such as `\mathbf{v}` is NOT pre-formatted — it still takes the
1778/// generic `\mathrm{…}` wrap.
1779fn is_preformatted_latex(v: &str) -> bool {
1780    if v.starts_with("\\mathrm{") {
1781        return true;
1782    }
1783    if v.contains('\\') {
1784        return !v.contains('{');
1785    }
1786    v.contains('{') || v.contains('}')
1787}
1788
1789/// True when `variable` (underscores ignored) is PURELY a chemical formula: at
1790/// least one element symbol and no non-element letters. Uses the same greedy
1791/// 2-char-before-1-char element tokenizer as `scanElements` in pretty-print.ts.
1792fn has_element_pattern(variable: &str) -> bool {
1793    let chars: Vec<char> = variable.chars().filter(|&c| c != '_').collect();
1794    let mut has_element = false;
1795    let mut i = 0;
1796    while i < chars.len() {
1797        let mut sym_len = 0;
1798        if i + 1 < chars.len() {
1799            let two: String = [chars[i], chars[i + 1]].iter().collect();
1800            if ELEMENTS.contains(&two.as_str()) {
1801                sym_len = 2;
1802            }
1803        }
1804        if sym_len == 0 && ELEMENTS.contains(&chars[i].to_string().as_str()) {
1805            sym_len = 1;
1806        }
1807        if sym_len > 0 {
1808            has_element = true;
1809            i += sym_len;
1810            while i < chars.len() && chars[i].is_ascii_digit() {
1811                i += 1;
1812            }
1813        } else {
1814            // A non-element letter means this is not a pure chemical formula.
1815            if chars[i].is_ascii_alphabetic() {
1816                return false;
1817            }
1818            i += 1;
1819        }
1820    }
1821    has_element
1822}
1823
1824/// Convert every digit run in a formula to a LaTeX subscript (`H2O` → `H_2O`,
1825/// `C12` → `C_{12}`), WITHOUT the `\mathrm{}` wrapper.
1826fn latex_chemical_inner(formula: &str) -> String {
1827    let chars: Vec<char> = formula.chars().collect();
1828    let mut out = String::new();
1829    let mut i = 0;
1830    while i < chars.len() {
1831        if chars[i].is_ascii_digit() {
1832            let mut digits = String::new();
1833            while i < chars.len() && chars[i].is_ascii_digit() {
1834                digits.push(chars[i]);
1835                i += 1;
1836            }
1837            if digits.chars().count() == 1 {
1838                out.push('_');
1839                out.push_str(&digits);
1840            } else {
1841                out.push_str(&format!("_{{{digits}}}"));
1842            }
1843        } else {
1844            out.push(chars[i]);
1845            i += 1;
1846        }
1847    }
1848    out
1849}
1850
1851/// Peel one leading `\mathrm{` and one trailing `}` (independently).
1852fn strip_outer_mathrm(s: &str) -> String {
1853    let inner = s.strip_prefix("\\mathrm{").unwrap_or(s);
1854    inner.strip_suffix('}').unwrap_or(inner).to_string()
1855}
1856
1857/// Split a variable into a non-element prefix + element-bearing suffix
1858/// (`jNO2` → `("j","NO2")`, `k_NO_O3` → `("k","NO_O3")`), or `None`.
1859fn get_chemical_suffix(variable: &str) -> Option<(String, String)> {
1860    if variable.contains('_') {
1861        let parts: Vec<&str> = variable.split('_').collect();
1862        if parts.len() == 2 && has_element_pattern(parts[1]) && !has_element_pattern(parts[0]) {
1863            return Some((parts[0].to_string(), parts[1].to_string()));
1864        }
1865        if parts.len() == 3 {
1866            let suffix = parts[1..].join("_");
1867            if has_element_pattern(&suffix) && !has_element_pattern(parts[0]) {
1868                return Some((parts[0].to_string(), suffix));
1869            }
1870        }
1871    }
1872    let chars: Vec<char> = variable.chars().collect();
1873    for i in 1..chars.len() {
1874        let prefix: String = chars[..i].iter().collect();
1875        let suffix: String = chars[i..].iter().collect();
1876        if has_element_pattern(&suffix) && !has_element_pattern(&prefix) {
1877            return Some((prefix, suffix));
1878        }
1879    }
1880    None
1881}
1882
1883/// Inner content of an element-bearing suffix embedded in a larger variable's
1884/// subscript (the text INSIDE the enclosing `\mathrm{...}`).
1885fn format_chemical_suffix_inner(variable: &str) -> String {
1886    if get_chemical_suffix(variable).is_some() {
1887        return strip_outer_mathrm(&format_chemical_latex(variable));
1888    }
1889    if ELEMENTS.contains(&variable) && !variable.chars().any(|c| c.is_ascii_digit()) {
1890        return variable.to_string();
1891    }
1892    latex_chemical_inner(variable)
1893}
1894
1895/// Match a single-letter-then-digits variable (`x1`, `T298`) → `(letter, digits)`.
1896fn single_letter_digits(name: &str) -> Option<(String, String)> {
1897    let chars: Vec<char> = name.chars().collect();
1898    if chars.len() >= 2
1899        && chars[0].is_ascii_alphabetic()
1900        && chars[1..].iter().all(|c| c.is_ascii_digit())
1901    {
1902        return Some((chars[0].to_string(), chars[1..].iter().collect()));
1903    }
1904    None
1905}
1906
1907fn is_chemical_formula(s: &str) -> bool {
1908    if s.is_empty() {
1909        return false;
1910    }
1911
1912    // Check if starts with an element symbol (uppercase letter)
1913    let first_char = s.chars().next().unwrap();
1914    if !first_char.is_ascii_uppercase() {
1915        return false;
1916    }
1917
1918    // Try to find a matching element symbol at the start
1919    if s.len() >= 2 {
1920        let two_letter = &s[..2];
1921        if ELEMENTS.contains(&two_letter) {
1922            return true;
1923        }
1924    }
1925
1926    let one_letter = &s[..1];
1927    ELEMENTS.contains(&one_letter)
1928}
1929
1930/// LaTeX chemical/variable subscript formatting (mirrors `formatChemicalLatex`
1931/// in pretty-print.ts). Greek and raw-LaTeX names are handled by the caller.
1932fn format_chemical_latex(variable: &str) -> String {
1933    // A trailing ionic charge is a superscript, not a subscript
1934    // (`Ca2+` → `Ca^{2+}`, `SO4-2` → `\mathrm{SO_4}^{2-}`).
1935    let (charge_body, charge) = split_charge(variable);
1936    if !charge.is_empty() {
1937        return format!("{}^{{{charge}}}", format_chemical_latex(charge_body));
1938    }
1939
1940    // Mixed variable: non-element prefix + element-bearing suffix.
1941    if let Some((prefix, suffix)) = get_chemical_suffix(variable) {
1942        let prefix_multi = prefix.chars().count() > 1;
1943        if suffix.contains('_') {
1944            let segments: Vec<&str> = suffix.split('_').collect();
1945            // Split into per-segment subscripts when the first segment is a
1946            // complete formula (ends in a digit) or the prefix is multi-char;
1947            // otherwise the whole suffix stays one `\mathrm{...}` block.
1948            let should_split = segments[0]
1949                .chars()
1950                .last()
1951                .is_some_and(|c| c.is_ascii_digit())
1952                || prefix_multi;
1953            if should_split {
1954                let mut result = if prefix_multi {
1955                    format!("\\mathrm{{{prefix}}}")
1956                } else {
1957                    prefix.clone()
1958                };
1959                for seg in &segments {
1960                    if has_element_pattern(seg) {
1961                        result.push_str(&format!("_{{\\mathrm{{{}}}}}", latex_chemical_inner(seg)));
1962                    } else {
1963                        result.push_str(&format!("_\\mathrm{{{seg}}}"));
1964                    }
1965                }
1966                return result;
1967            }
1968        }
1969        let inner = format_chemical_suffix_inner(&suffix);
1970        let formatted_prefix = if prefix_multi {
1971            format!("\\mathrm{{{prefix}}}")
1972        } else {
1973            prefix
1974        };
1975        return format!("{formatted_prefix}_{{\\mathrm{{{inner}}}}}");
1976    }
1977
1978    if has_element_pattern(variable) {
1979        // A bare element symbol without digits (e.g. "C", "Ca") is a variable.
1980        if ELEMENTS.contains(&variable) && !variable.chars().any(|c| c.is_ascii_digit()) {
1981            return variable.to_string();
1982        }
1983        // Pure chemical formula: digit runs → subscripts, wrapped in `\mathrm`.
1984        return format!("\\mathrm{{{}}}", latex_chemical_inner(variable));
1985    }
1986
1987    // Regular (non-chemical) variable.
1988    if let Some((letter, digits)) = single_letter_digits(variable) {
1989        return if digits.chars().count() == 1 {
1990            format!("{letter}_{digits}")
1991        } else {
1992            format!("{letter}_{{{digits}}}")
1993        };
1994    }
1995    if variable.chars().count() == 1 {
1996        return variable.to_string();
1997    }
1998    if variable.contains('_') {
1999        let parts: Vec<&str> = variable.split('_').collect();
2000        if parts.iter().any(|p| has_element_pattern(p)) {
2001            let base = parts[0];
2002            let mut result =
2003                if base.chars().count() == 1 && base.chars().all(|c| c.is_ascii_alphabetic()) {
2004                    base.to_string()
2005                } else if has_element_pattern(base) {
2006                    format_chemical_latex(base)
2007                } else {
2008                    format!("\\mathrm{{{base}}}")
2009                };
2010            for part in &parts[1..] {
2011                if has_element_pattern(part) {
2012                    result.push_str(&format!("_{{\\mathrm{{{}}}}}", latex_chemical_inner(part)));
2013                } else {
2014                    result.push_str(&format!("_\\mathrm{{{part}}}"));
2015                }
2016            }
2017            return result;
2018        }
2019        // No chemical segment → plain multi-word variable, underscores escaped.
2020        return format!("\\mathrm{{{}}}", variable.replace('_', "\\_"));
2021    }
2022    // A symbol with no lowercase letters (e.g. "RT", "-E") is a math variable,
2023    // not a descriptive name — leave it italic instead of wrapping in `\mathrm`.
2024    if !variable.chars().any(|c| c.is_ascii_lowercase()) {
2025        return variable.to_string();
2026    }
2027    format!("\\mathrm{{{variable}}}")
2028}
2029
2030/// Render `target` as plain ASCII. See [`to_unicode`] for the accepted domain.
2031pub fn to_ascii<T: TextRenderable + ?Sized>(target: &T) -> String {
2032    target.render_ascii()
2033}
2034
2035/// ASCII rendering with the parent operator's precedence threaded through.
2036///
2037/// The ASCII printer previously ignored precedence entirely, so `(a+b)*c`
2038/// rendered as the mathematically wrong `a + b * c`; it now applies the
2039/// same parenthesization rule as the reference Unicode printer.
2040fn to_ascii_prec(expr: &Expr, parent_prec: i32) -> String {
2041    match expr {
2042        Expr::Integer(n) => format_number_ascii(*n as f64),
2043        Expr::Number(n) => format_number_ascii(*n),
2044        // ASCII spells Greek characters out by name (e.g. `θ` → `theta`).
2045        Expr::Variable(name) => {
2046            nonfinite_string(name, Fmt::Ascii).unwrap_or_else(|| greek_to_ascii(name))
2047        }
2048        Expr::Operator(node) => format_operator(node, Fmt::Ascii, parent_prec),
2049    }
2050}
2051
2052impl fmt::Display for Model {
2053    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2054        let name = self.name.as_deref().unwrap_or("Unnamed");
2055        let param_count = self
2056            .variables
2057            .iter()
2058            .filter(|(_, v)| matches!(v.var_type, VariableType::Parameter))
2059            .count();
2060        let eq_count = self.equations.len();
2061
2062        writeln!(
2063            f,
2064            "    {} ({} parameters, {} equation{})",
2065            name,
2066            param_count,
2067            eq_count,
2068            if eq_count == 1 { "" } else { "s" }
2069        )?;
2070
2071        // Display equations
2072        for eq in &self.equations {
2073            writeln!(f, "      {} = {}", eq.lhs, eq.rhs)?;
2074        }
2075
2076        Ok(())
2077    }
2078}
2079
2080impl fmt::Display for ReactionSystem {
2081    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2082        let species_count = self.species.len();
2083
2084        // Count parameters
2085        let param_count = self.parameters.len();
2086
2087        let reaction_count = self.reactions.len();
2088
2089        writeln!(
2090            f,
2091            "    ReactionSystem ({} species, {} parameters, {} reaction{})",
2092            species_count,
2093            param_count,
2094            reaction_count,
2095            if reaction_count == 1 { "" } else { "s" }
2096        )?;
2097
2098        // Display reactions
2099        for (i, reaction) in self.reactions.iter().enumerate() {
2100            let default_name = format!("R{}", i + 1);
2101            let reaction_name = reaction
2102                .id
2103                .as_deref()
2104                .or(reaction.name.as_deref())
2105                .unwrap_or(&default_name);
2106
2107            // Format substrates
2108            let substrates = reaction
2109                .substrates
2110                .iter()
2111                .flatten()
2112                .map(|s| {
2113                    if s.coefficient == 1.0 {
2114                        format_chemical_subscripts(&s.species)
2115                    } else {
2116                        format!(
2117                            "{}·{}",
2118                            format_number_unicode(s.coefficient),
2119                            format_chemical_subscripts(&s.species)
2120                        )
2121                    }
2122                })
2123                .collect::<Vec<_>>()
2124                .join(" + ");
2125
2126            // Format products
2127            let products = reaction
2128                .products
2129                .iter()
2130                .flatten()
2131                .map(|p| {
2132                    if p.coefficient == 1.0 {
2133                        format_chemical_subscripts(&p.species)
2134                    } else {
2135                        format!(
2136                            "{}·{}",
2137                            format_number_unicode(p.coefficient),
2138                            format_chemical_subscripts(&p.species)
2139                        )
2140                    }
2141                })
2142                .collect::<Vec<_>>()
2143                .join(" + ");
2144
2145            writeln!(
2146                f,
2147                "      {}: {} → {}    rate: {}",
2148                reaction_name, substrates, products, reaction.rate
2149            )?;
2150        }
2151
2152        Ok(())
2153    }
2154}
2155
2156impl fmt::Display for EsmFile {
2157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2158        let name = self.metadata.name.as_deref().unwrap_or("Unnamed");
2159        let description = self.metadata.description.as_deref().unwrap_or("");
2160        let authors = self
2161            .metadata
2162            .authors
2163            .as_ref()
2164            .map(|a| a.join(", "))
2165            .unwrap_or_else(|| "Unknown".to_string());
2166
2167        writeln!(f, "ESM v{}: {}", self.esm, name)?;
2168        if !description.is_empty() {
2169            writeln!(f, "  \"{description}\"")?;
2170        }
2171        writeln!(f, "  Authors: {authors}")?;
2172        writeln!(f)?;
2173
2174        // Display reaction systems
2175        if let Some(ref reaction_systems) = self.reaction_systems
2176            && !reaction_systems.is_empty()
2177        {
2178            writeln!(f, "  Reaction Systems:")?;
2179            for system in reaction_systems.values() {
2180                write!(f, "{system}")?;
2181                writeln!(f)?;
2182            }
2183        }
2184
2185        // Display models
2186        if let Some(ref models) = self.models
2187            && !models.is_empty()
2188        {
2189            writeln!(f, "  Models:")?;
2190            for model in models.values() {
2191                write!(f, "{model}")?;
2192                writeln!(f)?;
2193            }
2194        }
2195
2196        // Display data sources. From esm 1.0.0 a source exposes no variables of
2197        // its own — the consuming parameter names it — so what is worth showing
2198        // is where it reads from and whether it is time-varying.
2199        if let Some(ref data_sources) = self.data_sources
2200            && !data_sources.is_empty()
2201        {
2202            writeln!(f, "  Data Sources:")?;
2203            for (name, source) in data_sources {
2204                let kind = match source.kind {
2205                    crate::DataSourceKind::Grid => "grid",
2206                    crate::DataSourceKind::Points => "points",
2207                    crate::DataSourceKind::Static => "static",
2208                };
2209                writeln!(
2210                    f,
2211                    "    {}: [{}] {}{}",
2212                    name,
2213                    kind,
2214                    source.source.url_template,
2215                    if source.temporal.is_some() {
2216                        " (temporal)"
2217                    } else {
2218                        ""
2219                    },
2220                )?;
2221            }
2222            writeln!(f)?;
2223        }
2224
2225        // Display coupling
2226        if let Some(ref coupling) = self.coupling
2227            && !coupling.is_empty()
2228        {
2229            writeln!(f, "  Coupling:")?;
2230            for (i, entry) in coupling.iter().enumerate() {
2231                match entry {
2232                    CouplingEntry::OperatorCompose { systems, .. } => {
2233                        if systems.len() >= 2 {
2234                            writeln!(
2235                                f,
2236                                "    {}. operator_compose: {} + {}",
2237                                i + 1,
2238                                systems[0],
2239                                systems[1]
2240                            )?;
2241                        }
2242                    }
2243                    CouplingEntry::VariableMap { from, to, .. } => {
2244                        writeln!(f, "    {}. variable_map: {} → {}", i + 1, from, to)?;
2245                    }
2246                    _ => {
2247                        writeln!(f, "    {}. {:?}", i + 1, entry)?;
2248                    }
2249                }
2250            }
2251            writeln!(f)?;
2252        }
2253
2254        // Display domain information (EsmFile.domain is the single shared domain)
2255        if let Some(ref domain) = self.domain {
2256            write!(f, "  Domain: ")?;
2257
2258            let mut domain_parts = Vec::new();
2259            if domain.temporal.is_some() {
2260                domain_parts.push("temporal".to_string());
2261            }
2262
2263            if domain_parts.is_empty() {
2264                writeln!(f, "[Domain information]")?;
2265            } else {
2266                writeln!(f, "{}", domain_parts.join(", "))?;
2267            }
2268        }
2269
2270        Ok(())
2271    }
2272}
2273
2274#[cfg(test)]
2275mod tests {
2276    use super::*;
2277    use indexmap::IndexMap;
2278
2279    #[test]
2280    fn test_chemical_subscripts() {
2281        assert_eq!(format_chemical_subscripts("O3"), "O₃");
2282        assert_eq!(format_chemical_subscripts("NO2"), "NO₂");
2283        assert_eq!(format_chemical_subscripts("CH4"), "CH₄");
2284        assert_eq!(format_chemical_subscripts("C2H6"), "C₂H₆");
2285        assert_eq!(format_chemical_subscripts("H2O2"), "H₂O₂");
2286    }
2287
2288    #[test]
2289    fn test_number_formatting_unicode() {
2290        assert_eq!(format_number_unicode(42.0), "42");
2291        assert_eq!(format_number_unicode(3.15), "3.15");
2292        // Mantissa is full-precision (no forced trailing zero): 1.8, not 1.80.
2293        assert_eq!(format_number_unicode(1.8e-12), "1.8×10⁻¹²");
2294        assert_eq!(format_number_unicode(2.46e19), "2.46×10¹⁹");
2295
2296        // Zero prints as a bare "0" (never "0.0") in every backend.
2297        assert_eq!(format_number_unicode(0.0), "0");
2298        assert_eq!(format_number_unicode(-0.0), "0");
2299
2300        // Other integers display without a decimal point; Unicode uses U+2212.
2301        assert_eq!(format_number_unicode(1.0), "1");
2302        assert_eq!(format_number_unicode(-1.0), "−1");
2303    }
2304
2305    #[test]
2306    fn test_zero_formatting_all_formats() {
2307        // Zero renders as a bare "0" in every backend (RENDERING_CONTRACT.md).
2308        let zero_expr = Expr::Number(0.0);
2309        let one_expr = Expr::Number(1.0);
2310        let neg_zero_expr = Expr::Number(-0.0);
2311
2312        // Unicode formatting
2313        assert_eq!(to_unicode(&zero_expr), "0");
2314        assert_eq!(to_unicode(&neg_zero_expr), "0");
2315        assert_eq!(to_unicode(&one_expr), "1");
2316
2317        // LaTeX formatting
2318        assert_eq!(to_latex(&zero_expr), "0");
2319        assert_eq!(to_latex(&neg_zero_expr), "0");
2320        assert_eq!(to_latex(&one_expr), "1");
2321
2322        // ASCII formatting
2323        assert_eq!(to_ascii(&zero_expr), "0");
2324        assert_eq!(to_ascii(&neg_zero_expr), "0");
2325        assert_eq!(to_ascii(&one_expr), "1");
2326    }
2327
2328    /// The unified Greek table and its three derived views, pinned: the 24
2329    /// letters in canonical order, name↔char round-trips, the 11 capitals
2330    /// with a distinct LaTeX command, and the deliberate asymmetries — the
2331    /// Latin-looking capitals carry no command, and uppercase Unicode chars
2332    /// pass through every backend unchanged.
2333    #[test]
2334    fn the_greek_table_drives_all_three_views() {
2335        let names: Vec<&str> = GREEK_TABLE.iter().map(|&(n, _, _)| n).collect();
2336        assert_eq!(
2337            names,
2338            [
2339                "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota",
2340                "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", "rho", "sigma", "tau",
2341                "upsilon", "phi", "chi", "psi", "omega",
2342            ]
2343        );
2344        for &(n, c, _) in GREEK_TABLE {
2345            assert_eq!(greek_name_to_char(n), Some(c), "name → char for {n}");
2346            assert_eq!(greek_char_to_name(c), Some(n), "char → name for {c}");
2347            assert_eq!(greek_to_latex(n).unwrap(), format!("\\{n}"));
2348            assert_eq!(greek_to_latex(&c.to_string()).unwrap(), format!("\\{n}"));
2349        }
2350        let capitals: Vec<&str> = GREEK_TABLE
2351            .iter()
2352            .filter(|&&(_, _, cap)| cap)
2353            .map(|&(n, _, _)| n)
2354            .collect();
2355        assert_eq!(
2356            capitals,
2357            [
2358                "gamma", "delta", "theta", "lambda", "xi", "pi", "sigma", "upsilon", "phi", "psi",
2359                "omega",
2360            ]
2361        );
2362        assert_eq!(greek_to_latex("Theta").as_deref(), Some("\\Theta"));
2363        assert_eq!(greek_to_latex("Omega").as_deref(), Some("\\Omega"));
2364        // A capital that looks like a Latin letter has no LaTeX command, an
2365        // uppercase Unicode char is recognized nowhere, and neither the
2366        // all-caps nor a non-Greek spelling is a Greek name.
2367        for miss in ["Alpha", "Δ", "GAMMA", "Deltas", "x"] {
2368            assert_eq!(greek_to_latex(miss), None, "{miss} must not match");
2369        }
2370        assert_eq!(greek_to_ascii("θ_Δx"), "theta_Δx");
2371        assert_eq!(format_chemical_subscripts("Delta"), "Delta");
2372    }
2373
2374    #[test]
2375    fn test_scientific_all_backends() {
2376        // Full-precision mantissa, U+2212 minus / `\times` / `e`, no `+` sign.
2377        assert_eq!(format_number_unicode(1.8e-12), "1.8×10⁻¹²");
2378        assert_eq!(format_number_unicode(2.46e19), "2.46×10¹⁹");
2379        assert_eq!(format_number_latex(1.8e-12), "1.8 \\times 10^{-12}");
2380        assert_eq!(format_number_ascii(1.8e-12), "1.8e-12");
2381        // Non-finite values render as symbols, never stringified.
2382        assert_eq!(format_number_unicode(f64::INFINITY), "∞");
2383        assert_eq!(format_number_latex(f64::NEG_INFINITY), "-\\infty");
2384        assert_eq!(format_number_ascii(f64::NAN), "NaN");
2385    }
2386
2387    #[test]
2388    fn test_expr_display() {
2389        let expr = Expr::Variable("O3".to_string());
2390        assert_eq!(format!("{expr}"), "O₃");
2391
2392        let expr = Expr::Number(1.8e-12);
2393        assert_eq!(format!("{expr}"), "1.8×10⁻¹²");
2394    }
2395
2396    #[test]
2397    fn test_operator_precedence() {
2398        let add = Expr::operator(ExpressionNode {
2399            op: "+".to_string(),
2400            args: vec![
2401                Expr::operator(ExpressionNode {
2402                    op: "*".to_string(),
2403                    args: vec![
2404                        Expr::Variable("a".to_string()),
2405                        Expr::Variable("b".to_string()),
2406                    ],
2407                    wrt: None,
2408                    dim: None,
2409                    ..Default::default()
2410                }),
2411                Expr::Variable("c".to_string()),
2412            ],
2413            wrt: None,
2414            dim: None,
2415            ..Default::default()
2416        });
2417        assert_eq!(format!("{add}"), "a·b + c");
2418
2419        let mul = Expr::operator(ExpressionNode {
2420            op: "*".to_string(),
2421            args: vec![
2422                Expr::operator(ExpressionNode {
2423                    op: "+".to_string(),
2424                    args: vec![
2425                        Expr::Variable("a".to_string()),
2426                        Expr::Variable("b".to_string()),
2427                    ],
2428                    wrt: None,
2429                    dim: None,
2430                    ..Default::default()
2431                }),
2432                Expr::Variable("c".to_string()),
2433            ],
2434            wrt: None,
2435            dim: None,
2436            ..Default::default()
2437        });
2438        assert_eq!(format!("{mul}"), "(a + b)·c");
2439    }
2440
2441    #[test]
2442    fn test_model_summary_display() {
2443        // Create minimal ESM file for testing display
2444        let metadata = Metadata {
2445            name: Some("TestModel".to_string()),
2446            description: Some("Test description".to_string()),
2447            authors: Some(vec!["Test Author".to_string()]),
2448            ..Default::default()
2449        };
2450
2451        // Create a simple reaction system
2452        let mut parameters = IndexMap::new();
2453        parameters.insert(
2454            "k1".to_string(),
2455            Parameter {
2456                default: Some(1.8e-12),
2457                units: Some("cm3/molec/s".to_string()),
2458                description: Some("Rate constant".to_string()),
2459                ..Default::default()
2460            },
2461        );
2462
2463        let reactions = vec![Reaction {
2464            id: Some("R1".to_string()),
2465            name: Some("R1".to_string()),
2466            substrates: Some(vec![StoichiometricEntry {
2467                species: "A".to_string(),
2468                coefficient: 1.0,
2469            }]),
2470            products: Some(vec![StoichiometricEntry {
2471                species: "B".to_string(),
2472                coefficient: 1.0,
2473            }]),
2474            rate: Expr::Variable("k1".to_string()),
2475            reference: None,
2476        }];
2477
2478        let mut species = IndexMap::new();
2479        species.insert(
2480            "A".to_string(),
2481            Species {
2482                default: Some(1e-9),
2483                units: Some("molec/cm3".to_string()),
2484                description: None,
2485                constant: None,
2486                ..Default::default()
2487            },
2488        );
2489        species.insert(
2490            "B".to_string(),
2491            Species {
2492                default: Some(0.0),
2493                units: Some("molec/cm3".to_string()),
2494                description: None,
2495                constant: None,
2496                ..Default::default()
2497            },
2498        );
2499
2500        let reaction_system = ReactionSystem {
2501            species,
2502            parameters,
2503            reactions,
2504            ..Default::default()
2505        };
2506
2507        let mut reaction_systems = IndexMap::new();
2508        reaction_systems.insert("TestReactions".to_string(), reaction_system);
2509
2510        // Create ESM file
2511        let esm_file = EsmFile {
2512            esm: "0.1.0".to_string(),
2513            metadata,
2514            reaction_systems: Some(reaction_systems),
2515            ..Default::default()
2516        };
2517
2518        // Test the display output
2519        let output = format!("{esm_file}");
2520
2521        // Check key components are present in the expected format
2522        assert!(output.contains("ESM v0.1.0: TestModel"));
2523        assert!(output.contains("\"Test description\""));
2524        assert!(output.contains("Authors: Test Author"));
2525        assert!(output.contains("Reaction Systems:"));
2526        assert!(output.contains("(2 species, 1 parameters, 1 reaction)"));
2527        assert!(output.contains("R1: A → B    rate: k1"));
2528
2529        // API_SPEC.md §8 item 18: the three renderers accept CONTAINERS, not
2530        // just expressions. A container renders its §6.3 summary, the same
2531        // text in all three formats (the peer bindings' summary helpers take
2532        // a format and ignore it).
2533        assert_eq!(to_unicode(&esm_file), output);
2534        assert_eq!(to_latex(&esm_file), output);
2535        assert_eq!(to_ascii(&esm_file), output);
2536
2537        let rs = &esm_file.reaction_systems.as_ref().unwrap()["TestReactions"];
2538        assert_eq!(to_unicode(rs), rs.to_string());
2539        assert_eq!(to_ascii(rs), rs.to_string());
2540    }
2541
2542    /// §8 item 18: an `Equation` renders as `lhs = rhs` in each format, the
2543    /// shape Python and TypeScript already produce.
2544    #[test]
2545    fn equations_render_in_all_three_formats() {
2546        let eq = Equation {
2547            lhs: Expr::Variable("alpha".to_string()),
2548            rhs: Expr::operator(ExpressionNode {
2549                op: "*".to_string(),
2550                args: vec![Expr::Variable("beta".to_string()), Expr::Number(2.0)],
2551                ..Default::default()
2552            }),
2553            ..Default::default()
2554        };
2555        assert_eq!(to_unicode(&eq), "α = β·2");
2556        assert_eq!(to_ascii(&eq), "alpha = beta * 2");
2557        assert_eq!(to_latex(&eq), "\\alpha = \\beta \\cdot 2");
2558    }
2559
2560    #[test]
2561    fn test_pre_operator_formatting() {
2562        use crate::types::*;
2563
2564        // Test Unicode formatting for Pre operator with x⁻ notation
2565        let pre_expr = Expr::operator(ExpressionNode {
2566            op: "Pre".to_string(),
2567            args: vec![Expr::Variable("x".to_string())],
2568            wrt: None,
2569            dim: None,
2570            ..Default::default()
2571        });
2572
2573        // `Pre` renders as a call form (cross-language rendering contract).
2574        assert_eq!(to_unicode(&pre_expr), "Pre(x)");
2575        assert_eq!(to_latex(&pre_expr), "\\mathrm{Pre}(x)");
2576        assert_eq!(to_ascii(&pre_expr), "Pre(x)");
2577
2578        // Test with complex expression as argument
2579        let complex_pre = Expr::operator(ExpressionNode {
2580            op: "Pre".to_string(),
2581            args: vec![Expr::operator(ExpressionNode {
2582                op: "+".to_string(),
2583                args: vec![
2584                    Expr::Variable("a".to_string()),
2585                    Expr::Variable("b".to_string()),
2586                ],
2587                wrt: None,
2588                dim: None,
2589                ..Default::default()
2590            })],
2591            wrt: None,
2592            dim: None,
2593            ..Default::default()
2594        });
2595
2596        assert_eq!(to_unicode(&complex_pre), "Pre(a + b)");
2597        assert_eq!(to_latex(&complex_pre), "\\mathrm{Pre}(a + b)");
2598        assert_eq!(to_ascii(&complex_pre), "Pre(a + b)");
2599
2600        // Test with multiple arguments (should fall back to Pre(...) format)
2601        let multi_arg_pre = Expr::operator(ExpressionNode {
2602            op: "Pre".to_string(),
2603            args: vec![
2604                Expr::Variable("x".to_string()),
2605                Expr::Variable("y".to_string()),
2606            ],
2607            wrt: None,
2608            dim: None,
2609            ..Default::default()
2610        });
2611
2612        assert_eq!(to_unicode(&multi_arg_pre), "Pre(x, y)");
2613        assert_eq!(to_latex(&multi_arg_pre), "\\mathrm{Pre}(x, y)");
2614        assert_eq!(to_ascii(&multi_arg_pre), "Pre(x, y)");
2615    }
2616
2617    /// Build an operator node for the precedence tests below.
2618    fn op_node(op: &str, args: Vec<Expr>) -> Expr {
2619        Expr::operator(ExpressionNode {
2620            op: op.to_string(),
2621            args,
2622            wrt: None,
2623            dim: None,
2624            ..Default::default()
2625        })
2626    }
2627
2628    fn var(name: &str) -> Expr {
2629        Expr::Variable(name.to_string())
2630    }
2631
2632    #[test]
2633    fn test_precedence_parens_all_printers() {
2634        // Regression test: the LaTeX and ASCII printers previously ignored
2635        // precedence, so (a + b) * c rendered as the mathematically wrong
2636        // `a + b \cdot c` / `a + b * c`.
2637        let mul = op_node("*", vec![op_node("+", vec![var("a"), var("b")]), var("c")]);
2638        assert_eq!(to_unicode(&mul), "(a + b)·c");
2639        assert_eq!(to_latex(&mul), "(a + b) \\cdot c");
2640        assert_eq!(to_ascii(&mul), "(a + b) * c");
2641
2642        // No spurious parentheses when precedence already binds correctly.
2643        let add = op_node("+", vec![var("a"), op_node("*", vec![var("b"), var("c")])]);
2644        assert_eq!(to_unicode(&add), "a + b·c");
2645        assert_eq!(to_latex(&add), "a + b \\cdot c");
2646        assert_eq!(to_ascii(&add), "a + b * c");
2647
2648        // Left-associative subtraction: the right operand keeps parentheses.
2649        let sub = op_node("-", vec![var("a"), op_node("-", vec![var("b"), var("c")])]);
2650        assert_eq!(to_unicode(&sub), "a − (b − c)");
2651        assert_eq!(to_latex(&sub), "a - (b - c)");
2652        assert_eq!(to_ascii(&sub), "a - (b - c)");
2653
2654        // Powers parenthesize a lower-precedence base in every backend
2655        // (`{a + b}^{2}` would typeset as `a + b²` in LaTeX).
2656        let pow = op_node(
2657            "^",
2658            vec![op_node("+", vec![var("a"), var("b")]), Expr::Number(2.0)],
2659        );
2660        assert_eq!(to_unicode(&pow), "(a + b)²");
2661        assert_eq!(to_latex(&pow), "(a + b)^{2}");
2662        assert_eq!(to_ascii(&pow), "(a + b)^2");
2663
2664        // A same-precedence RIGHT operand keeps its parentheses unless it is
2665        // the same associative op: `a * b / c` reads back as `(a * b) / c`, so
2666        // `a * (b / c)` must print its parentheses. LaTeX `\frac` is
2667        // self-delimiting and stays bare (RENDERING_CONTRACT.md).
2668        let frac_mul = op_node("*", vec![var("a"), op_node("/", vec![var("b"), var("c")])]);
2669        assert_eq!(to_unicode(&frac_mul), "a·(b/c)");
2670        assert_eq!(to_latex(&frac_mul), "a \\cdot \\frac{b}{c}");
2671        assert_eq!(to_ascii(&frac_mul), "a * (b / c)");
2672
2673        // Comparisons and `and`/`or` bind looser than arithmetic, so as
2674        // arithmetic operands they are parenthesized.
2675        let ind = op_node(
2676            "*",
2677            vec![
2678                op_node("<=", vec![var("a"), var("b")]),
2679                op_node("<", vec![var("c"), var("d")]),
2680            ],
2681        );
2682        assert_eq!(to_ascii(&ind), "(a <= b) * (c < d)");
2683        let lt_plus = op_node("+", vec![op_node("<", vec![var("a"), var("b")]), var("c")]);
2684        assert_eq!(to_ascii(&lt_plus), "(a < b) + c");
2685        let and_mul = op_node(
2686            "*",
2687            vec![op_node("and", vec![var("a"), var("b")]), var("c")],
2688        );
2689        assert_eq!(to_ascii(&and_mul), "(a and b) * c");
2690        // ...but a comparison inside `and` needs none, and an n-ary `and`
2691        // renders infix.
2692        let and3 = op_node(
2693            "and",
2694            vec![
2695                op_node("<=", vec![var("a"), var("b")]),
2696                op_node("<", vec![var("b"), var("c")]),
2697                op_node("<", vec![var("c"), var("d")]),
2698            ],
2699        );
2700        assert_eq!(to_ascii(&and3), "a <= b and b < c and c < d");
2701        assert_eq!(to_unicode(&and3), "a ≤ b ∧ b < c ∧ c < d");
2702        let eq_lt = op_node("==", vec![var("a"), op_node("<", vec![var("b"), var("c")])]);
2703        assert_eq!(to_ascii(&eq_lt), "a == (b < c)");
2704        // A unary-minus operand that is a comparison is wrapped exactly once.
2705        let neg_lt = op_node("-", vec![op_node("<", vec![var("a"), var("b")])]);
2706        assert_eq!(to_ascii(&neg_lt), "-(a < b)");
2707        let neg_sum = op_node("-", vec![op_node("+", vec![var("a"), var("b")])]);
2708        assert_eq!(to_ascii(&neg_sum), "-(a + b)");
2709        let neg_prod = op_node("-", vec![op_node("*", vec![var("a"), var("b")])]);
2710        assert_eq!(to_ascii(&neg_prod), "-a * b");
2711    }
2712
2713    #[test]
2714    fn test_scientific_notation_unified_across_printers() {
2715        // All three printers share one float-formatting core. LaTeX and
2716        // ASCII previously collapsed large integral floats (e.g. 2.46e19)
2717        // into long digit strings instead of scientific notation.
2718        let large = Expr::Number(2.46e19);
2719        assert_eq!(to_unicode(&large), "2.46×10¹⁹");
2720        assert_eq!(to_latex(&large), "2.46 \\times 10^{19}");
2721        assert_eq!(to_ascii(&large), "2.46e19");
2722
2723        let small = Expr::Number(1.8e-12);
2724        assert_eq!(to_unicode(&small), "1.8×10⁻¹²");
2725        assert_eq!(to_latex(&small), "1.8 \\times 10^{-12}");
2726        assert_eq!(to_ascii(&small), "1.8e-12");
2727
2728        // A large-magnitude *integer* leaf uses scientific notation too.
2729        let big_int = Expr::Integer(15000);
2730        assert_eq!(to_unicode(&big_int), "1.5×10⁴");
2731        assert_eq!(to_ascii(&big_int), "1.5e4");
2732    }
2733
2734    /// Byte-for-byte guard for the unified `format_operator` path: exercises
2735    /// every per-`fmt` symbol-table entry and every shape-divergent leaf
2736    /// (LaTeX `\frac`/`\begin{cases}`/`\exp`/juxtaposition, Unicode
2737    /// superscripts and `⌊⌋`/`⌈⌉`/`|·|`/`√`, the three `D` forms, and the
2738    /// infix-vs-call symbol split for `≥`/`≤`/`≠`) across all three backends.
2739    #[test]
2740    fn test_operator_matrix_all_backends() {
2741        fn opn(op: &str, args: Vec<Expr>) -> Expr {
2742            Expr::operator(ExpressionNode {
2743                op: op.to_string(),
2744                args,
2745                ..Default::default()
2746            })
2747        }
2748        fn dop(wrt: &str, arg: Expr) -> Expr {
2749            Expr::operator(ExpressionNode {
2750                op: "D".to_string(),
2751                args: vec![arg],
2752                wrt: Some(wrt.to_string()),
2753                ..Default::default()
2754            })
2755        }
2756        let a = || var("a");
2757        let b = || var("b");
2758        let c = || var("c");
2759        let ab = || opn("+", vec![a(), b()]);
2760        // (node, unicode, latex, ascii) — expected values captured from the
2761        // three pre-unification printers, so any drift fails loudly.
2762        let chk = |e: Expr, u: &str, l: &str, s: &str| {
2763            assert_eq!(to_unicode(&e), u, "unicode");
2764            assert_eq!(to_latex(&e), l, "latex");
2765            assert_eq!(to_ascii(&e), s, "ascii");
2766        };
2767
2768        // multiplication (·/juxtaposition/*) and precedence parens
2769        chk(opn("*", vec![a(), b()]), "a·b", r"a \cdot b", "a * b");
2770        chk(
2771            opn("*", vec![ab(), c()]),
2772            "(a + b)·c",
2773            r"(a + b) \cdot c",
2774            "(a + b) * c",
2775        );
2776        chk(
2777            opn("*", vec![var("\\mathrm{O_3}"), var("\\mathrm{N_2}")]),
2778            r"\mathrm{O_3}·\mathrm{N_2}",
2779            r"\mathrm{O_3} \mathrm{N_2}",
2780            r"\mathrm{O_3} * \mathrm{N_2}",
2781        );
2782        // division (`\frac` is the LaTeX shape-divergent leaf)
2783        chk(opn("/", vec![a(), b()]), "a/b", r"\frac{a}{b}", "a / b");
2784        chk(
2785            opn("/", vec![ab(), c()]),
2786            "(a + b)/c",
2787            r"\frac{a + b}{c}",
2788            "(a + b) / c",
2789        );
2790        // A same-precedence `/` RIGHT operand of `*` keeps its parentheses in
2791        // the inline backends (`a * b / c` reads back as `(a * b) / c`);
2792        // LaTeX `\frac` is self-delimiting and stays bare.
2793        chk(
2794            opn("*", vec![a(), opn("/", vec![b(), c()])]),
2795            "a·(b/c)",
2796            r"a \cdot \frac{b}{c}",
2797            "a * (b / c)",
2798        );
2799        // powers: LaTeX braces, Unicode integer superscripts
2800        chk(opn("^", vec![a(), b()]), "a^b", r"a^{b}", "a^b");
2801        chk(opn("^", vec![a(), Expr::Integer(2)]), "a²", r"a^{2}", "a^2");
2802        chk(
2803            opn("^", vec![a(), Expr::Number(2.0)]),
2804            "a²",
2805            r"a^{2}",
2806            "a^2",
2807        );
2808        chk(
2809            opn("^", vec![a(), Expr::Number(2.5)]),
2810            "a^2.5",
2811            r"a^{2.5}",
2812            "a^2.5",
2813        );
2814        chk(
2815            opn("^", vec![ab(), Expr::Integer(2)]),
2816            "(a + b)²",
2817            r"(a + b)^{2}",
2818            "(a + b)^2",
2819        );
2820        chk(
2821            opn("^", vec![a(), Expr::Integer(-3)]),
2822            "a⁻³",
2823            r"a^{-3}",
2824            "a^-3",
2825        );
2826        // subtraction: Unicode U+2212 minus, right-operand parens, `+(-b)` sugar
2827        chk(opn("-", vec![a()]), "−a", "-a", "-a");
2828        chk(
2829            opn("-", vec![a(), opn("-", vec![b(), c()])]),
2830            "a − (b − c)",
2831            "a - (b - c)",
2832            "a - (b - c)",
2833        );
2834        chk(
2835            opn("+", vec![a(), opn("-", vec![b()])]),
2836            "a − b",
2837            "a - b",
2838            "a - b",
2839        );
2840        // derivative: three divergent forms + call fallback
2841        chk(
2842            dop("t", a()),
2843            "∂a/∂t",
2844            r"\frac{\partial a}{\partial t}",
2845            "D(a)/Dt",
2846        );
2847        chk(
2848            dop("t", ab()),
2849            "∂(a + b)/∂t",
2850            r"\frac{\partial (a + b)}{\partial t}",
2851            "D(a + b)/Dt",
2852        );
2853        // A `D` with NO `wrt` is the structural time derivative in its short
2854        // spelling (esm-spec §4.2: an absent `wrt` MEANS `t`), so it renders
2855        // exactly as `dop("t", …)` above does. This row used to expect the bare
2856        // call form `D(a)` — what Rust and Go printed while Julia, Python and
2857        // TypeScript printed `∂a/∂t` (issue #407).
2858        chk(
2859            opn("D", vec![a()]),
2860            "∂a/∂t",
2861            r"\frac{\partial a}{\partial t}",
2862            "D(a)/Dt",
2863        );
2864        // Arity, not a missing `wrt`, is what falls back to the call form.
2865        chk(opn("D", vec![a(), b()]), "D(a, b)", "D(a, b)", "D(a, b)");
2866        // comparisons: infix symbol AND the call-form symbol (they diverge)
2867        chk(opn(">", vec![a(), b()]), "a > b", "a > b", "a > b");
2868        chk(opn(">=", vec![a(), b()]), "a ≥ b", r"a \geq b", "a >= b");
2869        chk(opn(">=", vec![a()]), ">=(a)", r"\geq(a)", ">=(a)");
2870        chk(opn("<=", vec![a(), b()]), "a ≤ b", r"a \leq b", "a <= b");
2871        chk(opn("=", vec![a(), b()]), "a = b", "a = b", "a == b");
2872        chk(opn("==", vec![a()]), "=(a)", "=(a)", "==(a)");
2873        chk(opn("!=", vec![a(), b()]), "a ≠ b", r"a \neq b", "a != b");
2874        chk(opn("!=", vec![a()]), "!=(a)", r"\neq(a)", "!=(a)");
2875        // boolean ops
2876        chk(opn("and", vec![a(), b()]), "a ∧ b", r"a \land b", "a and b");
2877        chk(opn("or", vec![a(), b()]), "a ∨ b", r"a \lor b", "a or b");
2878        chk(opn("not", vec![a()]), "¬a", r"\neg a", "not a");
2879        chk(
2880            opn("not", vec![opn("==", vec![a(), b()])]),
2881            "¬(a = b)",
2882            r"\neg (a = b)",
2883            "not (a == b)",
2884        );
2885        chk(
2886            opn("not", vec![a(), b()]),
2887            "not(a, b)",
2888            r"\neg(a, b)",
2889            "not(a, b)",
2890        );
2891        // elementary functions: unicode name vs LaTeX command vs bare ASCII
2892        chk(opn("log", vec![a()]), "ln(a)", r"\ln(a)", "log(a)");
2893        chk(
2894            opn("log", vec![a(), b()]),
2895            "log(a, b)",
2896            r"\log(a, b)",
2897            "log(a, b)",
2898        );
2899        chk(
2900            opn("log10", vec![a()]),
2901            "log₁₀(a)",
2902            r"\log_{10}(a)",
2903            "log10(a)",
2904        );
2905        chk(
2906            opn("log10", vec![a(), b()]),
2907            "log10(a, b)",
2908            r"\log_{10}(a, b)",
2909            "log10(a, b)",
2910        );
2911        chk(opn("sqrt", vec![a()]), "√a", r"\sqrt{a}", "sqrt(a)");
2912        chk(
2913            opn("sqrt", vec![a(), b()]),
2914            "sqrt(a, b)",
2915            r"\sqrt{a, b}",
2916            "sqrt(a, b)",
2917        );
2918        chk(
2919            opn("sqrt", vec![ab()]),
2920            "√(a + b)",
2921            r"\sqrt{a + b}",
2922            "sqrt(a + b)",
2923        );
2924        chk(
2925            opn("asin", vec![a()]),
2926            "arcsin(a)",
2927            r"\arcsin(a)",
2928            "asin(a)",
2929        );
2930        chk(opn("abs", vec![a()]), "|a|", "|a|", "abs(a)");
2931        chk(
2932            opn("abs", vec![a(), b()]),
2933            "abs(a, b)",
2934            "|a, b|",
2935            "abs(a, b)",
2936        );
2937        chk(
2938            opn("sign", vec![a()]),
2939            "sgn(a)",
2940            r"\mathrm{sgn}(a)",
2941            "sign(a)",
2942        );
2943        chk(
2944            opn("floor", vec![a()]),
2945            "⌊a⌋",
2946            r"\lfloor a \rfloor",
2947            "floor(a)",
2948        );
2949        chk(
2950            opn("floor", vec![a(), b()]),
2951            "floor(a, b)",
2952            r"\lfloor a, b \rfloor",
2953            "floor(a, b)",
2954        );
2955        chk(opn("ceil", vec![a()]), "⌈a⌉", r"\lceil a \rceil", "ceil(a)");
2956        chk(
2957            opn("asinh", vec![a()]),
2958            "sinh⁻¹(a)",
2959            r"\sinh^{-1}(a)",
2960            "asinh(a)",
2961        );
2962        chk(opn("exp", vec![a()]), "exp(a)", r"\exp(a)", "exp(a)");
2963        chk(
2964            opn("exp", vec![ab()]),
2965            "exp(a + b)",
2966            r"\exp\left(a + b\right)",
2967            "exp(a + b)",
2968        );
2969        chk(opn("min", vec![a()]), "min(a)", r"\min(a)", "min(a)");
2970        chk(
2971            opn("atan2", vec![a()]),
2972            "atan2(a)",
2973            r"\mathrm{atan2}(a)",
2974            "atan2(a)",
2975        );
2976        chk(opn("sin", vec![a()]), "sin(a)", r"\sin(a)", "sin(a)");
2977        chk(
2978            opn("Pre", vec![a()]),
2979            "Pre(a)",
2980            r"\mathrm{Pre}(a)",
2981            "Pre(a)",
2982        );
2983        // ifelse: LaTeX `\begin{cases}` leaf vs the call fallback everywhere else
2984        chk(
2985            opn("ifelse", vec![a(), b(), c()]),
2986            "ifelse(a, b, c)",
2987            r"\begin{cases} b & \text{if } a \\ c & \text{otherwise} \end{cases}",
2988            "ifelse(a, b, c)",
2989        );
2990        chk(
2991            opn("ifelse", vec![a(), b()]),
2992            "ifelse(a, b)",
2993            r"\mathrm{ifelse}(a, b)",
2994            "ifelse(a, b)",
2995        );
2996        // unknown ops: generic fallback + LaTeX underscore escaping
2997        chk(
2998            opn("grad", vec![a()]),
2999            "grad(a)",
3000            r"\mathrm{grad}(a)",
3001            "grad(a)",
3002        );
3003        chk(
3004            opn("my_op", vec![a(), b()]),
3005            "my_op(a, b)",
3006            r"\mathrm{my\_op}(a, b)",
3007            "my_op(a, b)",
3008        );
3009    }
3010}