Skip to main content

symplex/output/
latex.rs

1//! LaTeX rendering for symbolic expressions.
2//!
3//! Provides `to_latex()` methods on `Expr`, `Matrix`, and `Quaternion`.
4//! Uses direct `ExprNode` matching (no Display string parsing) for
5//! robustness against formatting changes.
6//!
7//! # Architecture
8//!
9//! Like `display.rs`, this module uses an **explicit work-stack** rather
10//! than recursive function calls, guaranteeing stack safety for
11//! arbitrarily deep expression trees (Principle 5).
12//!
13//! For nodes that require global knowledge of all children before
14//! rendering (Add sorting, Mul fraction detection), the children are
15//! inspected eagerly and the result pushed as an `Owned` string.
16
17use std::fmt;
18
19use num_bigint::BigInt;
20use num_rational::Ratio;
21use num_traits::Signed;
22use smallvec::SmallVec;
23
24use crate::base::arena::Arena;
25use crate::base::node::{ExprId, ExprNode};
26use crate::prelude::*;
27
28use super::common::{display_sort_key, extract_negative_power, is_neg_coeff_mul, is_neg_one_mul};
29
30// ═══════════════════════════════════════════════════════════════════════════
31// Greek letter table
32// ═══════════════════════════════════════════════════════════════════════════
33
34const GREEK_LETTERS: &[&str] = &[
35    "alpha",
36    "beta",
37    "gamma",
38    "delta",
39    "epsilon",
40    "zeta",
41    "eta",
42    "theta",
43    "iota",
44    "kappa",
45    "lambda",
46    "mu",
47    "nu",
48    "xi",
49    "omicron",
50    "pi",
51    "rho",
52    "sigma",
53    "tau",
54    "upsilon",
55    "phi",
56    "chi",
57    "psi",
58    "omega",
59    // Uppercase variants
60    "Alpha",
61    "Beta",
62    "Gamma",
63    "Delta",
64    "Epsilon",
65    "Zeta",
66    "Eta",
67    "Theta",
68    "Iota",
69    "Kappa",
70    "Lambda",
71    "Mu",
72    "Nu",
73    "Xi",
74    "Omicron",
75    "Pi",
76    "Rho",
77    "Sigma",
78    "Tau",
79    "Upsilon",
80    "Phi",
81    "Chi",
82    "Psi",
83    "Omega",
84    // Common variants
85    "varepsilon",
86    "varphi",
87    "vartheta",
88    "varrho",
89    "varsigma",
90];
91
92/// Convert a symbol name to LaTeX, handling Greek letters and subscripts.
93fn symbol_to_latex(name: &str) -> String {
94    // Check for subscript patterns like "alpha_1" — handle the base name
95    if let Some(idx) = name.find('_') {
96        let base = &name[..idx];
97        let sub = &name[idx + 1..];
98        let latex_base = greek_base(base).unwrap_or_else(|| base.to_string());
99        return format!("{}_{{{}}}", latex_base, sub);
100    }
101
102    if let Some(g) = greek_base(name) {
103        return g;
104    }
105
106    name.to_string()
107}
108
109/// If `name` is a Greek letter, return `\name`; otherwise None.
110fn greek_base(name: &str) -> Option<String> {
111    for &g in GREEK_LETTERS {
112        if name == g {
113            return Some(format!("\\{}", g));
114        }
115    }
116    None
117}
118
119// ═══════════════════════════════════════════════════════════════════════════
120// Work-stack items
121// ═══════════════════════════════════════════════════════════════════════════
122
123/// An item on the LaTeX work-stack.
124enum LatexItem {
125    /// A literal string to write verbatim.
126    Lit(&'static str),
127    /// A dynamically-built string to write.
128    Owned(String),
129    /// An expression that needs to be expanded.
130    Expr(ExprId),
131}
132
133// ═══════════════════════════════════════════════════════════════════════════
134// Core formatting function (iterative)
135// ═══════════════════════════════════════════════════════════════════════════
136
137/// Format an expression rooted at `id` as LaTeX into `f`.
138///
139/// **This function uses an explicit stack — it never recurses.**
140pub(crate) fn fmt_latex(arena: &Arena, f: &mut fmt::Formatter<'_>, id: ExprId) -> fmt::Result {
141    let mut stack: Vec<LatexItem> = Vec::with_capacity(32);
142    stack.push(LatexItem::Expr(id));
143
144    while let Some(item) = stack.pop() {
145        match item {
146            LatexItem::Lit(s) => write!(f, "{s}")?,
147            LatexItem::Owned(s) => write!(f, "{s}")?,
148            LatexItem::Expr(eid) => expand_latex(arena, eid, &mut stack),
149        }
150    }
151
152    Ok(())
153}
154
155/// Helper: render an ExprId to a String using `fmt_latex`.
156fn latex_to_string(arena: &Arena, id: ExprId) -> String {
157    let w = LatexWriter { arena, id };
158    format!("{}", w)
159}
160
161/// Display wrapper for converting an ExprId to a LaTeX string.
162struct LatexWriter<'a> {
163    arena: &'a Arena,
164    id: ExprId,
165}
166
167impl fmt::Display for LatexWriter<'_> {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        fmt_latex(self.arena, f, self.id)
170    }
171}
172
173// ═══════════════════════════════════════════════════════════════════════════
174// Mul rendering helpers
175// ═══════════════════════════════════════════════════════════════════════════
176
177/// Heuristic: does this rendered string look like a plain number?
178fn looks_like_number(s: &str) -> bool {
179    if s.is_empty() {
180        return false;
181    }
182    let s = s.strip_prefix('-').unwrap_or(s);
183    s.chars().all(|c| c.is_ascii_digit())
184}
185
186/// Join multiplication factors with implicit multiplication (space) or \cdot.
187fn join_mul_factors(factors: &[String]) -> String {
188    if factors.len() == 1 {
189        return factors[0].clone();
190    }
191
192    let mut result = String::new();
193    for (i, f) in factors.iter().enumerate() {
194        if i > 0 {
195            let prev = &factors[i - 1];
196            if looks_like_number(prev) && looks_like_number(f) {
197                // Two adjacent numbers need an explicit multiplication sign.
198                result.push_str(r" \cdot ");
199            } else if looks_like_number(prev) {
200                // Numeric coefficient followed by a non-numeric factor:
201                // use implicit multiplication (no separator), e.g. "3x^{2}".
202            } else {
203                result.push(' ');
204            }
205        }
206        result.push_str(f);
207    }
208    result
209}
210
211/// Render a single Mul factor, wrapping Add children in \left(...\right).
212fn render_mul_factor(arena: &Arena, id: ExprId) -> String {
213    let s = latex_to_string(arena, id);
214    if matches!(arena.node(id), ExprNode::Add(_)) {
215        format!("\\left({}\\right)", s)
216    } else {
217        s
218    }
219}
220
221/// Render a Pow base, wrapping compound expressions in \left(...\right).
222///
223/// Besides sums/products/negations this also wraps nested powers (a bare
224/// `x^{a}^{b}` is a LaTeX "double superscript" error) and negative or
225/// non-integer numeric bases (`\left(-2\right)^{x}`,
226/// `\left(\frac{2}{3}\right)^{x}`).
227fn render_pow_base(arena: &Arena, base: ExprId) -> String {
228    let s = latex_to_string(arena, base);
229    let wrap = match arena.node(base) {
230        ExprNode::Add(_) | ExprNode::Mul(_) | ExprNode::Neg(_) | ExprNode::Pow(_, _) => true,
231        ExprNode::Num(nid) => {
232            let r = arena.num(*nid);
233            r.is_negative() || !r.is_integer()
234        }
235        _ => false,
236    };
237    if wrap {
238        format!("\\left({}\\right)", s)
239    } else {
240        s
241    }
242}
243
244/// Render a Mul node to a complete LaTeX string.
245///
246/// Handles: leading -1/1, fraction rendering (negative powers → denominator),
247/// implicit multiplication.
248fn render_mul(arena: &Arena, children: &[ExprId]) -> String {
249    if children.is_empty() {
250        return "1".to_string();
251    }
252
253    // Check for leading -1 or 1
254    let first_node = arena.node(children[0]);
255    let is_neg_one = if let ExprNode::Num(nid) = first_node {
256        *arena.num(*nid) == Ratio::from(BigInt::from(-1))
257    } else {
258        false
259    };
260    let is_pos_one = if let ExprNode::Num(nid) = first_node {
261        *arena.num(*nid) == Ratio::from(BigInt::from(1))
262    } else {
263        false
264    };
265
266    let (skip_first, prefix) = if children.len() > 1 && is_neg_one {
267        (true, "-")
268    } else if children.len() > 1 && is_pos_one {
269        (true, "")
270    } else {
271        (false, "")
272    };
273
274    let start_idx = if skip_first { 1 } else { 0 };
275    let factors = &children[start_idx..];
276
277    // Separate numerator and denominator factors
278    let mut numer_factors: Vec<String> = Vec::new();
279    let mut denom_factors: Vec<String> = Vec::new();
280
281    for &factor in factors {
282        if let Some((base_id, pos_exp_str)) = extract_negative_power(arena, factor) {
283            let base_latex = render_pow_base(arena, base_id);
284            if pos_exp_str == "1" {
285                denom_factors.push(base_latex);
286            } else {
287                denom_factors.push(format!("{}^{{{}}}", base_latex, pos_exp_str));
288            }
289        } else {
290            numer_factors.push(render_mul_factor(arena, factor));
291        }
292    }
293
294    if denom_factors.is_empty() {
295        // Pure product
296        let body = if numer_factors.is_empty() {
297            "1".to_string()
298        } else {
299            join_mul_factors(&numer_factors)
300        };
301        format!("{}{}", prefix, body)
302    } else {
303        // Fraction
304        let numer_str = if numer_factors.is_empty() {
305            "1".to_string()
306        } else {
307            join_mul_factors(&numer_factors)
308        };
309        let denom_str = join_mul_factors(&denom_factors);
310        if prefix == "-" {
311            format!("-\\frac{{{}}}{{{}}}", numer_str, denom_str)
312        } else {
313            format!("\\frac{{{}}}{{{}}}", numer_str, denom_str)
314        }
315    }
316}
317
318// ═══════════════════════════════════════════════════════════════════════════
319// Add rendering helpers
320// ═══════════════════════════════════════════════════════════════════════════
321
322/// Render a Mul with leading -1 without the leading factor.
323/// Returns the LaTeX for the remaining factors.
324fn render_mul_without_neg_one(arena: &Arena, id: ExprId) -> String {
325    if let ExprNode::Mul(children) = arena.node(id) {
326        let rest = &children[1..];
327        if rest.len() == 1 {
328            return latex_to_string(arena, rest[0]);
329        }
330        // Render as a Mul of the remaining factors
331        render_mul(arena, rest)
332    } else {
333        latex_to_string(arena, id)
334    }
335}
336
337/// Render a Mul with negative leading coefficient as subtraction content.
338/// Returns the LaTeX string with the coefficient negated.
339fn render_neg_coeff_mul_as_subtraction(arena: &Arena, id: ExprId) -> String {
340    if let ExprNode::Mul(children) = arena.node(id)
341        && let Some(&first) = children.first()
342        && let ExprNode::Num(nid) = arena.node(first)
343    {
344        let r = arena.num(*nid);
345        let pos_r = -r.clone();
346
347        // Build a new children list with the positive coefficient
348        // Replace the first child in the rendering
349        let coeff_str = render_num_value(&pos_r);
350
351        let rest_factors: Vec<String> = children[1..]
352            .iter()
353            .map(|&c| render_mul_factor(arena, c))
354            .collect();
355
356        if pos_r == Ratio::from(BigInt::from(1)) {
357            // Coefficient is 1 after negation → just render rest
358            if rest_factors.is_empty() {
359                return "1".to_string();
360            }
361            return join_mul_factors(&rest_factors);
362        }
363
364        let mut all_factors = vec![coeff_str];
365        all_factors.extend(rest_factors);
366
367        // Check for fraction rendering (negative powers in rest)
368        // For simplicity, delegate to render_mul with modified children
369        // Actually, let's just join them
370        return join_mul_factors(&all_factors);
371    }
372    latex_to_string(arena, id)
373}
374
375// ═══════════════════════════════════════════════════════════════════════════
376// Number rendering helper
377// ═══════════════════════════════════════════════════════════════════════════
378
379/// Render a rational number to LaTeX.
380fn render_num_value(r: &Ratio<BigInt>) -> String {
381    if r.is_integer() {
382        format!("{}", r.numer())
383    } else if r.numer().is_negative() {
384        format!("-\\frac{{{}}}{{{}}}", -r.numer(), r.denom())
385    } else {
386        format!("\\frac{{{}}}{{{}}}", r.numer(), r.denom())
387    }
388}
389
390// ═══════════════════════════════════════════════════════════════════════════
391// Node expansion
392// ═══════════════════════════════════════════════════════════════════════════
393
394/// Push a LaTeX function call `\name\left(arg\right)` onto the stack.
395/// If `id` is a trig/hyperbolic function node, return (`\funcname`, arg).
396fn trig_func_parts(arena: &Arena, id: ExprId) -> Option<(&'static str, ExprId)> {
397    match arena.node(id) {
398        ExprNode::Sin(x) => Some((r"\sin", *x)),
399        ExprNode::Cos(x) => Some((r"\cos", *x)),
400        ExprNode::Tan(x) => Some((r"\tan", *x)),
401        ExprNode::Sinh(x) => Some((r"\sinh", *x)),
402        ExprNode::Cosh(x) => Some((r"\cosh", *x)),
403        ExprNode::Tanh(x) => Some((r"\tanh", *x)),
404        ExprNode::Asin(x) => Some((r"\arcsin", *x)),
405        ExprNode::Acos(x) => Some((r"\arccos", *x)),
406        ExprNode::Atan(x) => Some((r"\arctan", *x)),
407        ExprNode::Asinh(x) => Some((r"\operatorname{asinh}", *x)),
408        ExprNode::Acosh(x) => Some((r"\operatorname{acosh}", *x)),
409        ExprNode::Atanh(x) => Some((r"\operatorname{atanh}", *x)),
410        _ => None,
411    }
412}
413
414fn push_latex_func(name: &'static str, arg: ExprId, stack: &mut Vec<LatexItem>) {
415    stack.push(LatexItem::Lit(r"\right)"));
416    stack.push(LatexItem::Expr(arg));
417    stack.push(LatexItem::Owned(format!("{}\\left(", name)));
418}
419
420/// Push `head\left(args[0], args[1], …\right)` (reverse order).
421fn push_latex_call(head: &str, args: &[ExprId], stack: &mut Vec<LatexItem>) {
422    stack.push(LatexItem::Lit(r"\right)"));
423    for (i, &arg) in args.iter().enumerate().rev() {
424        stack.push(LatexItem::Expr(arg));
425        if i > 0 {
426            stack.push(LatexItem::Lit(", "));
427        }
428    }
429    stack.push(LatexItem::Owned(format!("{head}\\left(")));
430}
431
432/// LaTeX for the 0.9 `Apply`-based special functions, following SymPy's
433/// printer: `\operatorname{erfi}`, `\operatorname{E}_{n}`, `S`/`C`,
434/// `\gamma`/`\Gamma`, `\operatorname{Li}_{s}`, `\eta`, `\operatorname{Ai}`,
435/// `K`/`E`/`F\left(\phi\middle| m\right)`/`\Pi`, `C_{n}^{\left(a\right)}`,
436/// `\operatorname{B}_{(x_1, x_2)}`/`\operatorname{I}_{(x_1, x_2)}`, …
437///
438/// Returns `false` (pushing nothing) for any other name.
439fn push_special_09_latex(name: &str, args: &[ExprId], stack: &mut Vec<LatexItem>) -> bool {
440    use crate::base::arena::{
441        FN_AIRYAI, FN_AIRYAIPRIME, FN_AIRYBI, FN_AIRYBIPRIME, FN_ASSOC_LAGUERRE, FN_ASSOC_LEGENDRE,
442        FN_BETAINC, FN_BETAINC_REGULARIZED, FN_CHI, FN_DIRICHLET_ETA, FN_ELLIPTIC_E, FN_ELLIPTIC_F,
443        FN_ELLIPTIC_K, FN_ELLIPTIC_PI, FN_ERFCINV, FN_ERFI, FN_ERFINV, FN_EXPINT, FN_FRESNELC,
444        FN_FRESNELS, FN_GEGENBAUER, FN_JACOBI, FN_LOWERGAMMA, FN_POLYLOG, FN_SHI, FN_UPPERGAMMA,
445    };
446    // Plain `head(args)` renderings.
447    let head: Option<&str> = match (name, args.len()) {
448        (FN_ERFI, 1) => Some(r"\operatorname{erfi}"),
449        (FN_ERFINV, 1) => Some(r"\operatorname{erf}^{-1}"),
450        (FN_ERFCINV, 1) => Some(r"\operatorname{erfc}^{-1}"),
451        (FN_SHI, 1) => Some(r"\operatorname{Shi}"),
452        (FN_CHI, 1) => Some(r"\operatorname{Chi}"),
453        (FN_FRESNELS, 1) => Some("S"),
454        (FN_FRESNELC, 1) => Some("C"),
455        (FN_LOWERGAMMA, 2) => Some(r"\gamma"),
456        (FN_UPPERGAMMA, 2) => Some(r"\Gamma"),
457        (FN_DIRICHLET_ETA, 1) => Some(r"\eta"),
458        (FN_AIRYAI, 1) => Some(r"\operatorname{Ai}"),
459        (FN_AIRYBI, 1) => Some(r"\operatorname{Bi}"),
460        (FN_AIRYAIPRIME, 1) => Some(r"\operatorname{Ai}^\prime"),
461        (FN_AIRYBIPRIME, 1) => Some(r"\operatorname{Bi}^\prime"),
462        (FN_ELLIPTIC_K, 1) => Some("K"),
463        (FN_ELLIPTIC_E, 1) => Some("E"),
464        _ => None,
465    };
466    if let Some(head) = head {
467        push_latex_call(head, args, stack);
468        return true;
469    }
470    match (name, args.len()) {
471        // head_{param}\left(x\right)
472        (FN_EXPINT, 2) | (FN_POLYLOG, 2) => {
473            let head = if name == FN_EXPINT {
474                r"\operatorname{E}_{"
475            } else {
476                r"\operatorname{Li}_{"
477            };
478            stack.push(LatexItem::Lit(r"\right)"));
479            stack.push(LatexItem::Expr(args[1]));
480            stack.push(LatexItem::Lit(r"}\left("));
481            stack.push(LatexItem::Expr(args[0]));
482            stack.push(LatexItem::Lit(head));
483            true
484        }
485        // F\left(\phi\middle| m\right), \Pi\left(n\middle| m\right)
486        (FN_ELLIPTIC_F, 2) | (FN_ELLIPTIC_PI, 2) => {
487            stack.push(LatexItem::Lit(r"\right)"));
488            stack.push(LatexItem::Expr(args[1]));
489            stack.push(LatexItem::Lit(r"\middle| "));
490            stack.push(LatexItem::Expr(args[0]));
491            stack.push(LatexItem::Lit(if name == FN_ELLIPTIC_F {
492                r"F\left("
493            } else {
494                r"\Pi\left("
495            }));
496            true
497        }
498        // C_{n}^{\left(a\right)}\left(x\right), P_{n}^{\left(m\right)}, L_{n}^{\left(a\right)}
499        (FN_GEGENBAUER, 3) | (FN_ASSOC_LEGENDRE, 3) | (FN_ASSOC_LAGUERRE, 3) => {
500            let letter = match name {
501                FN_GEGENBAUER => r"C_{",
502                FN_ASSOC_LEGENDRE => r"P_{",
503                _ => r"L_{",
504            };
505            stack.push(LatexItem::Lit(r"\right)"));
506            stack.push(LatexItem::Expr(args[2]));
507            stack.push(LatexItem::Lit(r"\right)}\left("));
508            stack.push(LatexItem::Expr(args[1]));
509            stack.push(LatexItem::Lit(r"}^{\left("));
510            stack.push(LatexItem::Expr(args[0]));
511            stack.push(LatexItem::Lit(letter));
512            true
513        }
514        // P_{n}^{\left(a,b\right)}\left(x\right)
515        (FN_JACOBI, 4) => {
516            stack.push(LatexItem::Lit(r"\right)"));
517            stack.push(LatexItem::Expr(args[3]));
518            stack.push(LatexItem::Lit(r"\right)}\left("));
519            stack.push(LatexItem::Expr(args[2]));
520            stack.push(LatexItem::Lit(","));
521            stack.push(LatexItem::Expr(args[1]));
522            stack.push(LatexItem::Lit(r"}^{\left("));
523            stack.push(LatexItem::Expr(args[0]));
524            stack.push(LatexItem::Lit(r"P_{"));
525            true
526        }
527        // \operatorname{B}_{(x_1, x_2)}\left(a, b\right), \operatorname{I}_{(x_1, x_2)}\left(a, b\right)
528        (FN_BETAINC, 4) | (FN_BETAINC_REGULARIZED, 4) => {
529            stack.push(LatexItem::Lit(r"\right)"));
530            stack.push(LatexItem::Expr(args[1]));
531            stack.push(LatexItem::Lit(", "));
532            stack.push(LatexItem::Expr(args[0]));
533            stack.push(LatexItem::Lit(r")}\left("));
534            stack.push(LatexItem::Expr(args[3]));
535            stack.push(LatexItem::Lit(", "));
536            stack.push(LatexItem::Expr(args[2]));
537            stack.push(LatexItem::Lit(if name == FN_BETAINC {
538                r"\operatorname{B}_{("
539            } else {
540                r"\operatorname{I}_{("
541            }));
542            true
543        }
544        _ => false,
545    }
546}
547
548/// Expand a single expression node into LaTeX work items on the stack.
549///
550/// Items are pushed in **reverse** display order so that popping
551/// yields left-to-right output.
552fn expand_latex(arena: &Arena, id: ExprId, stack: &mut Vec<LatexItem>) {
553    let node = arena.node(id).clone();
554
555    match node {
556        // ── Atoms ──────────────────────────────────────────────────
557        ExprNode::Num(nid) => {
558            let r = arena.num(nid);
559            stack.push(LatexItem::Owned(render_num_value(r)));
560        }
561
562        ExprNode::Symbol(sid) => {
563            let name = arena.symbol_name(sid);
564            stack.push(LatexItem::Owned(symbol_to_latex(name)));
565        }
566
567        ExprNode::Pi => stack.push(LatexItem::Lit(r"\pi")),
568        ExprNode::E => stack.push(LatexItem::Lit("e")),
569        ExprNode::ImaginaryUnit => stack.push(LatexItem::Lit("i")),
570        ExprNode::EulerGamma => stack.push(LatexItem::Lit(r"\gamma")),
571        ExprNode::Catalan => stack.push(LatexItem::Lit("G")),
572        ExprNode::GoldenRatio => stack.push(LatexItem::Lit(r"\phi")),
573        ExprNode::PhysicalConstant(name_id, _) => {
574            stack.push(LatexItem::Owned(symbol_to_latex(
575                arena.symbol_name(name_id),
576            )));
577        }
578        ExprNode::Infinity => stack.push(LatexItem::Lit(r"\infty")),
579        ExprNode::NegInfinity => stack.push(LatexItem::Lit(r"-\infty")),
580        ExprNode::ComplexInfinity => stack.push(LatexItem::Lit(r"\tilde{\infty}")),
581        ExprNode::NaN => stack.push(LatexItem::Lit(r"\text{NaN}")),
582
583        // ── Add ────────────────────────────────────────────────────
584        //
585        // Rendered eagerly because we need to sort children and detect
586        // subtraction patterns across the entire child list.
587        ExprNode::Add(ref children) => {
588            let children = children.clone();
589            if children.is_empty() {
590                stack.push(LatexItem::Lit("0"));
591                return;
592            }
593
594            // Sort children by display key
595            let mut display_order: SmallVec<[ExprId; 6]> = children;
596            display_order.sort_by_key(|a| display_sort_key(arena, *a));
597
598            let mut result = String::new();
599
600            for (i, &child) in display_order.iter().enumerate() {
601                let child_node = arena.node(child);
602
603                if let ExprNode::Neg(inner) = child_node {
604                    // Neg(x) → " - x"
605                    let inner = *inner;
606                    let inner_latex = latex_to_string(arena, inner);
607                    if i == 0 {
608                        // Wrap Add children of inner in parens
609                        if matches!(arena.node(inner), ExprNode::Add(_)) {
610                            result.push_str(&format!("-\\left({}\\right)", inner_latex));
611                        } else {
612                            result.push_str(&format!("-{}", inner_latex));
613                        }
614                    } else if matches!(arena.node(inner), ExprNode::Add(_)) {
615                        result.push_str(&format!(" - \\left({}\\right)", inner_latex));
616                    } else {
617                        result.push_str(&format!(" - {}", inner_latex));
618                    }
619                } else if is_neg_one_mul(arena, child) {
620                    // Mul([-1, rest...]) → " - rest"
621                    let rest_latex = render_mul_without_neg_one(arena, child);
622                    if i == 0 {
623                        result.push_str(&format!("-{}", rest_latex));
624                    } else {
625                        result.push_str(&format!(" - {}", rest_latex));
626                    }
627                } else if is_neg_coeff_mul(arena, child) {
628                    // Mul([-n, rest...]) → " - n*rest"
629                    let sub_latex = render_neg_coeff_mul_as_subtraction(arena, child);
630                    if i == 0 {
631                        result.push_str(&format!("-{}", sub_latex));
632                    } else {
633                        result.push_str(&format!(" - {}", sub_latex));
634                    }
635                } else if i == 0 {
636                    // Check if the child is a negative number
637                    if let ExprNode::Num(nid) = arena.node(child) {
638                        let r = arena.num(*nid);
639                        if r.is_negative() && !r.is_integer() {
640                            // Negative fraction at leading position
641                            result.push_str(&render_num_value(r));
642                        } else {
643                            result.push_str(&latex_to_string(arena, child));
644                        }
645                    } else {
646                        result.push_str(&latex_to_string(arena, child));
647                    }
648                } else {
649                    // Check if child is a negative number
650                    if let ExprNode::Num(nid) = arena.node(child) {
651                        let r = arena.num(*nid);
652                        if r.is_negative() {
653                            let pos_r = -r.clone();
654                            let pos_str = render_num_value(&pos_r);
655                            result.push_str(&format!(" - {}", pos_str));
656                        } else {
657                            result.push_str(&format!(" + {}", latex_to_string(arena, child)));
658                        }
659                    } else {
660                        result.push_str(&format!(" + {}", latex_to_string(arena, child)));
661                    }
662                }
663            }
664
665            stack.push(LatexItem::Owned(result));
666        }
667
668        // ── Mul ────────────────────────────────────────────────────
669        //
670        // Rendered eagerly because fraction detection requires scanning
671        // all children.
672        ExprNode::Mul(ref children) => {
673            let children_vec: Vec<ExprId> = children.iter().copied().collect();
674            stack.push(LatexItem::Owned(render_mul(arena, &children_vec)));
675        }
676
677        // ── Pow ────────────────────────────────────────────────────
678        ExprNode::Pow(base, exp) => {
679            // Special case: exp = 1/2 → \sqrt{base}
680            if let ExprNode::Num(nid) = arena.node(exp) {
681                let r = arena.num(*nid);
682                if *r == Ratio::new(BigInt::from(1), BigInt::from(2)) {
683                    stack.push(LatexItem::Lit("}"));
684                    stack.push(LatexItem::Expr(base));
685                    stack.push(LatexItem::Lit(r"\sqrt{"));
686                    return;
687                }
688                // exp = 1/3 → \sqrt[3]{base}
689                if *r == Ratio::new(BigInt::from(1), BigInt::from(3)) {
690                    stack.push(LatexItem::Lit("}"));
691                    stack.push(LatexItem::Expr(base));
692                    stack.push(LatexItem::Lit(r"\sqrt[3]{"));
693                    return;
694                }
695                // exp = 1/n → \sqrt[n]{base}
696                if !r.is_integer() && !r.is_negative() && *r.numer() == BigInt::from(1) {
697                    let n = r.denom();
698                    stack.push(LatexItem::Lit("}"));
699                    stack.push(LatexItem::Expr(base));
700                    stack.push(LatexItem::Owned(format!("\\sqrt[{}]{{", n)));
701                    return;
702                }
703                // exp = -1 → \frac{1}{base}
704                if *r == Ratio::from(BigInt::from(-1)) {
705                    let base_latex = render_pow_base(arena, base);
706                    stack.push(LatexItem::Owned(format!("\\frac{{1}}{{{}}}", base_latex)));
707                    return;
708                }
709                // exp = -n (negative integer, not -1) → \frac{1}{base^{n}}
710                if r.is_integer() && r.is_negative() {
711                    let pos_n = -r.numer();
712                    let base_latex = render_pow_base(arena, base);
713                    stack.push(LatexItem::Owned(format!(
714                        "\\frac{{1}}{{{}^{{{}}}}}",
715                        base_latex, pos_n
716                    )));
717                    return;
718                }
719            }
720
721            // Trig function power: sin(x)^n → \sin^{n}\left(x\right)
722            if let Some((func_name, arg)) = trig_func_parts(arena, base) {
723                stack.push(LatexItem::Lit(r"\right)"));
724                stack.push(LatexItem::Expr(arg));
725                stack.push(LatexItem::Lit(r"}\left("));
726                stack.push(LatexItem::Expr(exp));
727                stack.push(LatexItem::Owned(format!("{}^{{", func_name)));
728                return;
729            }
730
731            // General case: base^{exp}
732            let base_latex = render_pow_base(arena, base);
733            stack.push(LatexItem::Lit("}"));
734            stack.push(LatexItem::Expr(exp));
735            stack.push(LatexItem::Owned(format!("{}^{{", base_latex)));
736        }
737
738        // ── Neg ────────────────────────────────────────────────────
739        ExprNode::Neg(inner) => {
740            if matches!(arena.node(inner), ExprNode::Add(_)) {
741                stack.push(LatexItem::Lit(r"\right)"));
742                stack.push(LatexItem::Expr(inner));
743                stack.push(LatexItem::Lit(r"-\left("));
744            } else {
745                stack.push(LatexItem::Expr(inner));
746                stack.push(LatexItem::Lit("-"));
747            }
748        }
749
750        // ── Trig functions ─────────────────────────────────────────
751        ExprNode::Sin(x) => push_latex_func(r"\sin", x, stack),
752        ExprNode::Cos(x) => push_latex_func(r"\cos", x, stack),
753        ExprNode::Tan(x) => push_latex_func(r"\tan", x, stack),
754        ExprNode::Asin(x) => push_latex_func(r"\arcsin", x, stack),
755        ExprNode::Acos(x) => push_latex_func(r"\arccos", x, stack),
756        ExprNode::Atan(x) => push_latex_func(r"\arctan", x, stack),
757        ExprNode::Sinh(x) => push_latex_func(r"\sinh", x, stack),
758        ExprNode::Cosh(x) => push_latex_func(r"\cosh", x, stack),
759        ExprNode::Tanh(x) => push_latex_func(r"\tanh", x, stack),
760        ExprNode::Asinh(x) => push_latex_func(r"\operatorname{asinh}", x, stack),
761        ExprNode::Acosh(x) => push_latex_func(r"\operatorname{acosh}", x, stack),
762        ExprNode::Atanh(x) => push_latex_func(r"\operatorname{atanh}", x, stack),
763
764        // ── Transcendental ─────────────────────────────────────────
765        ExprNode::Exp(x) => push_latex_func(r"\exp", x, stack),
766        ExprNode::Ln(x) => push_latex_func(r"\ln", x, stack),
767
768        // ── Special functions ──────────────────────────────────────
769        ExprNode::Sign(x) => push_latex_func(r"\operatorname{sgn}", x, stack),
770        ExprNode::Heaviside(x) => push_latex_func(r"\operatorname{H}", x, stack),
771        ExprNode::DiracDelta(x) => push_latex_func(r"\delta", x, stack),
772        ExprNode::Gamma(x) => push_latex_func(r"\Gamma", x, stack),
773        ExprNode::LogGamma(x) => push_latex_func(r"\ln \Gamma", x, stack),
774        ExprNode::Digamma(x) => push_latex_func(r"\psi", x, stack),
775        ExprNode::Erf(x) => push_latex_func(r"\operatorname{erf}", x, stack),
776        ExprNode::Erfc(x) => push_latex_func(r"\operatorname{erfc}", x, stack),
777        ExprNode::LambertW(x) => push_latex_func(r"\operatorname{W}", x, stack),
778
779        // ── Complex analysis ────────────────────────────────────────────
780        ExprNode::Re(x) => push_latex_func(r"\Re", x, stack),
781        ExprNode::Im(x) => push_latex_func(r"\Im", x, stack),
782        ExprNode::Conjugate(x) => {
783            stack.push(LatexItem::Lit("}"));
784            stack.push(LatexItem::Expr(x));
785            stack.push(LatexItem::Lit(r"\overline{"));
786        }
787        ExprNode::Arg(x) => push_latex_func(r"\arg", x, stack),
788
789        // ── Special functions (0.2) ──────────────────────────────────────
790        ExprNode::Si(x) => push_latex_func(r"\operatorname{Si}", x, stack),
791        ExprNode::Ci(x) => push_latex_func(r"\operatorname{Ci}", x, stack),
792        ExprNode::Ei(x) => push_latex_func(r"\operatorname{Ei}", x, stack),
793        ExprNode::Li(x) => push_latex_func(r"\operatorname{li}", x, stack),
794        ExprNode::Zeta(x) => push_latex_func(r"\zeta", x, stack),
795        ExprNode::Polygamma(n, x) => {
796            // \psi^{(n)}\left(x\right)
797            stack.push(LatexItem::Lit(r"\right)"));
798            stack.push(LatexItem::Expr(x));
799            stack.push(LatexItem::Lit(r")}\left("));
800            stack.push(LatexItem::Expr(n));
801            stack.push(LatexItem::Lit(r"\psi^{("));
802        }
803        ExprNode::KroneckerDelta(i, j) => {
804            // \delta_{i j}
805            stack.push(LatexItem::Lit("}"));
806            stack.push(LatexItem::Expr(j));
807            stack.push(LatexItem::Lit(" "));
808            stack.push(LatexItem::Expr(i));
809            stack.push(LatexItem::Lit(r"\delta_{"));
810        }
811
812        // ── Abs: \left|x\right| ───────────────────────────────────
813        ExprNode::Abs(x) => {
814            stack.push(LatexItem::Lit(r"\right|"));
815            stack.push(LatexItem::Expr(x));
816            stack.push(LatexItem::Lit(r"\left|"));
817        }
818
819        // ── Floor / Ceiling ────────────────────────────────────────
820        ExprNode::Floor(x) => {
821            stack.push(LatexItem::Lit(r"\rfloor"));
822            stack.push(LatexItem::Expr(x));
823            stack.push(LatexItem::Lit(r"\lfloor "));
824        }
825        ExprNode::Ceiling(x) => {
826            stack.push(LatexItem::Lit(r"\rceil"));
827            stack.push(LatexItem::Expr(x));
828            stack.push(LatexItem::Lit(r"\lceil "));
829        }
830
831        // ── Factorial: n! ──────────────────────────────────────────
832        ExprNode::Factorial(x) => {
833            if arena.node(x).is_atom() {
834                stack.push(LatexItem::Lit("!"));
835                stack.push(LatexItem::Expr(x));
836            } else {
837                stack.push(LatexItem::Lit(r"\right)!"));
838                stack.push(LatexItem::Expr(x));
839                stack.push(LatexItem::Lit(r"\left("));
840            }
841        }
842
843        // ── Binomial: \binom{n}{k} ────────────────────────────────
844        ExprNode::Binomial(n, k) => {
845            stack.push(LatexItem::Lit("}"));
846            stack.push(LatexItem::Expr(k));
847            stack.push(LatexItem::Lit("}{"));
848            stack.push(LatexItem::Expr(n));
849            stack.push(LatexItem::Lit(r"\binom{"));
850        }
851
852        // ── Beta: \mathrm{B}(a, b) ────────────────────────────────
853        ExprNode::Beta(a, b) => {
854            stack.push(LatexItem::Lit(r"\right)"));
855            stack.push(LatexItem::Expr(b));
856            stack.push(LatexItem::Lit(", "));
857            stack.push(LatexItem::Expr(a));
858            stack.push(LatexItem::Lit(r"\mathrm{B}\left("));
859        }
860
861        // ── Atan2 ──────────────────────────────────────────────────
862        ExprNode::Atan2(y, x) => {
863            stack.push(LatexItem::Lit(r"\right)"));
864            stack.push(LatexItem::Expr(x));
865            stack.push(LatexItem::Lit(", "));
866            stack.push(LatexItem::Expr(y));
867            stack.push(LatexItem::Lit(r"\operatorname{atan2}\left("));
868        }
869
870        // ── Min / Max ──────────────────────────────────────────────
871        ExprNode::Min(ref args) => {
872            let args = args.clone();
873            stack.push(LatexItem::Lit(r"\right)"));
874            for (i, &arg) in args.iter().enumerate().rev() {
875                stack.push(LatexItem::Expr(arg));
876                if i > 0 {
877                    stack.push(LatexItem::Lit(", "));
878                }
879            }
880            stack.push(LatexItem::Lit(r"\min\left("));
881        }
882        ExprNode::Max(ref args) => {
883            let args = args.clone();
884            stack.push(LatexItem::Lit(r"\right)"));
885            for (i, &arg) in args.iter().enumerate().rev() {
886                stack.push(LatexItem::Expr(arg));
887                if i > 0 {
888                    stack.push(LatexItem::Lit(", "));
889                }
890            }
891            stack.push(LatexItem::Lit(r"\max\left("));
892        }
893
894        // ── Sum / Product ──────────────────────────────────────────
895        ExprNode::Sum(body, var, lo, hi) => {
896            stack.push(LatexItem::Expr(body));
897            stack.push(LatexItem::Lit("} "));
898            stack.push(LatexItem::Expr(hi));
899            stack.push(LatexItem::Lit("^{"));
900            stack.push(LatexItem::Expr(lo));
901            stack.push(LatexItem::Lit("="));
902            stack.push(LatexItem::Expr(var));
903            stack.push(LatexItem::Lit(r"\sum_{"));
904        }
905        ExprNode::Product_(body, var, lo, hi) => {
906            stack.push(LatexItem::Expr(body));
907            stack.push(LatexItem::Lit("} "));
908            stack.push(LatexItem::Expr(hi));
909            stack.push(LatexItem::Lit("^{"));
910            stack.push(LatexItem::Expr(lo));
911            stack.push(LatexItem::Lit("="));
912            stack.push(LatexItem::Expr(var));
913            stack.push(LatexItem::Lit(r"\prod_{"));
914        }
915
916        // ── Derivative: \frac{d}{dvar} body ───────────────────────
917        ExprNode::Derivative(body, var) => {
918            stack.push(LatexItem::Expr(body));
919            stack.push(LatexItem::Lit("} "));
920            stack.push(LatexItem::Expr(var));
921            stack.push(LatexItem::Lit(r"\frac{d}{d"));
922        }
923
924        // ── Integral: \int body \, dvar ───────────────────────────
925        ExprNode::Integral(body, var) => {
926            stack.push(LatexItem::Expr(var));
927            stack.push(LatexItem::Lit(r"\, d"));
928            stack.push(LatexItem::Expr(body));
929            stack.push(LatexItem::Lit(r"\int "));
930        }
931
932        // ── DefiniteIntegral: \int_{lo}^{hi} body \, dvar ────────────
933        ExprNode::DefiniteIntegral(body, var, lo, hi) => {
934            stack.push(LatexItem::Expr(var));
935            stack.push(LatexItem::Lit(r"\, d"));
936            stack.push(LatexItem::Expr(body));
937            stack.push(LatexItem::Lit("} "));
938            stack.push(LatexItem::Expr(hi));
939            stack.push(LatexItem::Lit("}^{"));
940            stack.push(LatexItem::Expr(lo));
941            stack.push(LatexItem::Lit(r"\int_{"));
942        }
943
944        // ── Limit: \lim_{var \to point} body ──────────────────────
945        ExprNode::Limit(body, var, point) => {
946            stack.push(LatexItem::Expr(body));
947            stack.push(LatexItem::Lit("} "));
948            stack.push(LatexItem::Expr(point));
949            stack.push(LatexItem::Lit(r" \to "));
950            stack.push(LatexItem::Expr(var));
951            stack.push(LatexItem::Lit(r"\lim_{"));
952        }
953
954        // ── Series ─────────────────────────────────────────────────
955        ExprNode::Series(body, var, point, order) => {
956            stack.push(LatexItem::Lit(r"\right)"));
957            stack.push(LatexItem::Expr(order));
958            stack.push(LatexItem::Lit(", "));
959            stack.push(LatexItem::Expr(point));
960            stack.push(LatexItem::Lit(", "));
961            stack.push(LatexItem::Expr(var));
962            stack.push(LatexItem::Lit(", "));
963            stack.push(LatexItem::Expr(body));
964            stack.push(LatexItem::Lit(r"\operatorname{Series}\left("));
965        }
966
967        // ── Laplace Transform: \mathcal{L}\left\{body\right\} ────
968        ExprNode::LaplaceTransform(body, _t, _s) => {
969            stack.push(LatexItem::Lit(r"\right\}"));
970            stack.push(LatexItem::Expr(body));
971            stack.push(LatexItem::Lit(r"\mathcal{L}\left\{"));
972        }
973
974        // ── Inverse Laplace Transform ──────────────────────────────
975        ExprNode::InverseLaplaceTransform(body, _s, _t) => {
976            stack.push(LatexItem::Lit(r"\right\}"));
977            stack.push(LatexItem::Expr(body));
978            stack.push(LatexItem::Lit(r"\mathcal{L}^{-1}\left\{"));
979        }
980
981        // ── Residue: \operatorname{Res}_{var=point} body ──────────
982        ExprNode::Residue(body, var, point) => {
983            stack.push(LatexItem::Expr(body));
984            stack.push(LatexItem::Lit("} "));
985            stack.push(LatexItem::Expr(point));
986            stack.push(LatexItem::Lit("="));
987            stack.push(LatexItem::Expr(var));
988            stack.push(LatexItem::Lit(r"\operatorname{Res}_{"));
989        }
990
991        // ── RootOf ─────────────────────────────────────────────────
992        ExprNode::RootOf(poly, index) => {
993            stack.push(LatexItem::Lit(r"\right)"));
994            stack.push(LatexItem::Expr(index));
995            stack.push(LatexItem::Lit(", "));
996            stack.push(LatexItem::Expr(poly));
997            stack.push(LatexItem::Lit(r"\operatorname{RootOf}\left("));
998        }
999
1000        // ── DSolve ─────────────────────────────────────────────────
1001        ExprNode::DSolve(expr, func, var) => {
1002            stack.push(LatexItem::Lit(r"\right)"));
1003            stack.push(LatexItem::Expr(var));
1004            stack.push(LatexItem::Lit(", "));
1005            stack.push(LatexItem::Expr(func));
1006            stack.push(LatexItem::Lit(", "));
1007            stack.push(LatexItem::Expr(expr));
1008            stack.push(LatexItem::Lit(r"\operatorname{DSolve}\left("));
1009        }
1010
1011        // ── RootSum ────────────────────────────────────────────────
1012        // Display as: \operatorname{RootSum}\left(poly,\, sumvar \mapsto body\right)
1013        ExprNode::RootSum(poly, body, sumvar) => {
1014            stack.push(LatexItem::Lit(r"\right)"));
1015            stack.push(LatexItem::Expr(body));
1016            stack.push(LatexItem::Lit(r" \mapsto "));
1017            stack.push(LatexItem::Expr(sumvar));
1018            stack.push(LatexItem::Lit(r",\, "));
1019            stack.push(LatexItem::Expr(poly));
1020            stack.push(LatexItem::Lit(r"\operatorname{RootSum}\left("));
1021        }
1022
1023        // ── ConditionSet: \left\{var \mid condition\right\} ───────
1024        ExprNode::ConditionSet(var, condition) => {
1025            stack.push(LatexItem::Lit(r"\right\}"));
1026            stack.push(LatexItem::Expr(condition));
1027            stack.push(LatexItem::Lit(r" \mid "));
1028            stack.push(LatexItem::Expr(var));
1029            stack.push(LatexItem::Lit(r"\left\{"));
1030        }
1031
1032        // ── Apply (user-defined function) ──────────────────────────
1033        ExprNode::Apply(sym_id, ref args) => {
1034            let name = arena.symbol_name(sym_id).to_owned();
1035            let args = args.clone();
1036            if push_special_09_latex(&name, &args, stack) {
1037                return;
1038            }
1039            stack.push(LatexItem::Lit(r"\right)"));
1040            for (i, &arg) in args.iter().enumerate().rev() {
1041                stack.push(LatexItem::Expr(arg));
1042                if i > 0 {
1043                    stack.push(LatexItem::Lit(", "));
1044                }
1045            }
1046            stack.push(LatexItem::Owned(format!("{}\\left(", name)));
1047        }
1048
1049        // ── Piecewise ──────────────────────────────────────────────
1050        ExprNode::Piecewise(ref pieces) => {
1051            let pieces = pieces.clone();
1052            let mut result = String::from("\\begin{cases}\n");
1053            for (i, &(val, cond)) in pieces.iter().enumerate() {
1054                let val_latex = latex_to_string(arena, val);
1055                let cond_latex = latex_to_string(arena, cond);
1056                result.push_str(&format!("  {} & \\text{{if }} {}", val_latex, cond_latex));
1057                if i + 1 < pieces.len() {
1058                    result.push_str(" \\\\\n");
1059                } else {
1060                    result.push('\n');
1061                }
1062            }
1063            result.push_str("\\end{cases}");
1064            stack.push(LatexItem::Owned(result));
1065        }
1066
1067        // ── Boolean atoms ──────────────────────────────────────────
1068        ExprNode::BoolTrue => stack.push(LatexItem::Lit(r"\text{True}")),
1069        ExprNode::BoolFalse => stack.push(LatexItem::Lit(r"\text{False}")),
1070
1071        // ── Relational operators ───────────────────────────────────
1072        ExprNode::Gt(a, b) => {
1073            stack.push(LatexItem::Expr(b));
1074            stack.push(LatexItem::Lit(" > "));
1075            stack.push(LatexItem::Expr(a));
1076        }
1077        ExprNode::Ge(a, b) => {
1078            stack.push(LatexItem::Expr(b));
1079            stack.push(LatexItem::Lit(r" \geq "));
1080            stack.push(LatexItem::Expr(a));
1081        }
1082        ExprNode::Eq_(a, b) => {
1083            stack.push(LatexItem::Expr(b));
1084            stack.push(LatexItem::Lit(" = "));
1085            stack.push(LatexItem::Expr(a));
1086        }
1087        ExprNode::Ne(a, b) => {
1088            stack.push(LatexItem::Expr(b));
1089            stack.push(LatexItem::Lit(r" \neq "));
1090            stack.push(LatexItem::Expr(a));
1091        }
1092
1093        // ── Logical connectives ────────────────────────────────────
1094        ExprNode::And(ref children) => {
1095            let children = children.clone();
1096            for (i, &child) in children.iter().enumerate().rev() {
1097                stack.push(LatexItem::Expr(child));
1098                if i > 0 {
1099                    stack.push(LatexItem::Lit(r" \land "));
1100                }
1101            }
1102        }
1103        ExprNode::Or(ref children) => {
1104            let children = children.clone();
1105            for (i, &child) in children.iter().enumerate().rev() {
1106                stack.push(LatexItem::Expr(child));
1107                if i > 0 {
1108                    stack.push(LatexItem::Lit(r" \lor "));
1109                }
1110            }
1111        }
1112        ExprNode::Not(x) => {
1113            stack.push(LatexItem::Expr(x));
1114            stack.push(LatexItem::Lit(r"\lnot "));
1115        }
1116
1117        // ── Set atoms ──────────────────────────────────────────────
1118        ExprNode::EmptySet => stack.push(LatexItem::Lit(r"\emptyset")),
1119        ExprNode::UniversalSet => stack.push(LatexItem::Lit(r"\mathbb{R}")),
1120
1121        // ── Set constructors — fallback to Display ─────────────────
1122        ExprNode::Interval(a, b, flags) => {
1123            let left = if flags & crate::base::node::INTERVAL_LEFT_OPEN != 0 {
1124                "("
1125            } else {
1126                "["
1127            };
1128            let right = if flags & crate::base::node::INTERVAL_RIGHT_OPEN != 0 {
1129                ")"
1130            } else {
1131                "]"
1132            };
1133            let a_latex = latex_to_string(arena, a);
1134            let b_latex = latex_to_string(arena, b);
1135            stack.push(LatexItem::Owned(format!(
1136                "{}{}, {}{}",
1137                left, a_latex, b_latex, right
1138            )));
1139        }
1140
1141        ExprNode::FiniteSet(ref elems) => {
1142            let elems = elems.clone();
1143            stack.push(LatexItem::Lit("\\}"));
1144            for (i, &elem) in elems.iter().enumerate().rev() {
1145                stack.push(LatexItem::Expr(elem));
1146                if i > 0 {
1147                    stack.push(LatexItem::Lit(", "));
1148                }
1149            }
1150            stack.push(LatexItem::Lit("\\{"));
1151        }
1152
1153        ExprNode::SetUnion(ref sets) => {
1154            let sets = sets.clone();
1155            for (i, &set) in sets.iter().enumerate().rev() {
1156                stack.push(LatexItem::Expr(set));
1157                if i > 0 {
1158                    stack.push(LatexItem::Lit(r" \cup "));
1159                }
1160            }
1161        }
1162
1163        ExprNode::SetIntersection(ref sets) => {
1164            let sets = sets.clone();
1165            for (i, &set) in sets.iter().enumerate().rev() {
1166                stack.push(LatexItem::Expr(set));
1167                if i > 0 {
1168                    stack.push(LatexItem::Lit(r" \cap "));
1169                }
1170            }
1171        }
1172
1173        ExprNode::SetComplement(a, b) => {
1174            stack.push(LatexItem::Expr(b));
1175            stack.push(LatexItem::Lit(r" \setminus "));
1176            stack.push(LatexItem::Expr(a));
1177        }
1178    }
1179}
1180
1181// ═══════════════════════════════════════════════════════════════════════════
1182// Public API on Expr<S>
1183// ═══════════════════════════════════════════════════════════════════════════
1184
1185impl<S: Sort> Expr<S> {
1186    /// Render this expression as a LaTeX math string (no delimiters).
1187    ///
1188    /// Uses direct `ExprNode` matching for robustness.
1189    ///
1190    /// # Examples
1191    ///
1192    /// ```
1193    /// use symplex::prelude::*;
1194    ///
1195    /// let ctx = Context::new();
1196    /// let x = ctx.symbol("x");
1197    /// assert_eq!(x.powi(2).to_latex(), r"x^{2}");
1198    /// assert_eq!(x.sin().to_latex(), r"\sin\left(x\right)");
1199    /// ```
1200    pub fn to_latex(&self) -> String {
1201        let inner = self.inner.read();
1202        let w = LatexWriter {
1203            arena: &inner.arena,
1204            id: self.raw_id(),
1205        };
1206        format!("{}", w)
1207    }
1208
1209    /// Render as an inline LaTeX expression with `$` delimiters.
1210    ///
1211    /// # Examples
1212    ///
1213    /// ```
1214    /// use symplex::prelude::*;
1215    ///
1216    /// let ctx = Context::new();
1217    /// let x = ctx.symbol("x");
1218    /// assert_eq!(x.to_latex_inline(), "$x$");
1219    /// ```
1220    pub fn to_latex_inline(&self) -> String {
1221        format!("${}$", self.to_latex())
1222    }
1223
1224    /// Render as a display LaTeX expression with `$$` delimiters.
1225    ///
1226    /// # Examples
1227    ///
1228    /// ```
1229    /// use symplex::prelude::*;
1230    ///
1231    /// let ctx = Context::new();
1232    /// let x = ctx.symbol("x");
1233    /// assert_eq!(x.to_latex_display(), "$$x$$");
1234    /// ```
1235    pub fn to_latex_display(&self) -> String {
1236        format!("$${}$$", self.to_latex())
1237    }
1238}
1239
1240// ═══════════════════════════════════════════════════════════════════════════
1241// Tests
1242// ═══════════════════════════════════════════════════════════════════════════
1243
1244#[cfg(test)]
1245mod tests {
1246    use crate::prelude::*;
1247
1248    // ── Number tests ───────────────────────────────────────────────
1249
1250    #[test]
1251    fn latex_integer() {
1252        assert_eq!(crate::api::context::Context::new().int(42).to_latex(), "42");
1253    }
1254
1255    #[test]
1256    fn latex_zero() {
1257        assert_eq!(crate::api::context::Context::new().int(0).to_latex(), "0");
1258    }
1259
1260    #[test]
1261    fn latex_negative_integer() {
1262        let neg = crate::api::context::Context::new().int(-3);
1263        let latex = neg.to_latex();
1264        assert!(latex == "-3", "got: {latex}");
1265    }
1266
1267    #[test]
1268    fn latex_fraction() {
1269        let half = crate::api::context::Context::new().rational(1, 2);
1270        assert_eq!(half.to_latex(), r"\frac{1}{2}");
1271    }
1272
1273    #[test]
1274    fn latex_negative_fraction() {
1275        let neg_frac = crate::api::context::Context::new().rational(-5, 7);
1276        let latex = neg_frac.to_latex();
1277        assert!(
1278            latex == r"-\frac{5}{7}" || latex == r"\frac{-5}{7}",
1279            "got: {latex}"
1280        );
1281    }
1282
1283    // ── Symbol tests ───────────────────────────────────────────────
1284
1285    #[test]
1286    fn latex_symbol() {
1287        assert_eq!(
1288            crate::api::context::Context::new().symbol("x").to_latex(),
1289            "x"
1290        );
1291    }
1292
1293    #[test]
1294    fn latex_symbol_multichar() {
1295        assert_eq!(
1296            crate::api::context::Context::new().symbol("foo").to_latex(),
1297            "foo"
1298        );
1299    }
1300
1301    #[test]
1302    fn latex_greek_theta() {
1303        assert_eq!(
1304            crate::api::context::Context::new()
1305                .symbol("theta")
1306                .to_latex(),
1307            r"\theta"
1308        );
1309    }
1310
1311    #[test]
1312    fn latex_greek_alpha() {
1313        assert_eq!(
1314            crate::api::context::Context::new()
1315                .symbol("alpha")
1316                .to_latex(),
1317            r"\alpha"
1318        );
1319    }
1320
1321    #[test]
1322    fn latex_greek_omega() {
1323        assert_eq!(
1324            crate::api::context::Context::new()
1325                .symbol("omega")
1326                .to_latex(),
1327            r"\omega"
1328        );
1329    }
1330
1331    #[test]
1332    fn latex_greek_lambda() {
1333        assert_eq!(
1334            crate::api::context::Context::new()
1335                .symbol("lambda")
1336                .to_latex(),
1337            r"\lambda"
1338        );
1339    }
1340
1341    #[test]
1342    fn latex_symbol_subscript() {
1343        let x1 = crate::api::context::Context::new().symbol("x_1");
1344        assert_eq!(x1.to_latex(), "x_{1}");
1345    }
1346
1347    // ── Constant tests ─────────────────────────────────────────────
1348
1349    #[test]
1350    fn latex_pi() {
1351        assert_eq!(crate::api::context::Context::new().pi().to_latex(), r"\pi");
1352    }
1353
1354    #[test]
1355    fn latex_e() {
1356        assert_eq!(crate::api::context::Context::new().e().to_latex(), "e");
1357    }
1358
1359    #[test]
1360    fn latex_imaginary() {
1361        assert_eq!(crate::api::context::Context::new().i_unit().to_latex(), "i");
1362    }
1363
1364    #[test]
1365    fn latex_infinity() {
1366        assert_eq!(
1367            crate::api::context::Context::new().infinity().to_latex(),
1368            r"\infty"
1369        );
1370    }
1371
1372    #[test]
1373    fn latex_neg_infinity() {
1374        assert_eq!(
1375            crate::api::context::Context::new()
1376                .neg_infinity()
1377                .to_latex(),
1378            r"-\infty"
1379        );
1380    }
1381
1382    // ── Pow tests ──────────────────────────────────────────────────
1383
1384    #[test]
1385    fn latex_power() {
1386        let x = crate::api::context::Context::new().symbol("x");
1387        let expr = x.powi(2);
1388        assert_eq!(expr.to_latex(), r"x^{2}");
1389    }
1390
1391    #[test]
1392    fn latex_power_cube() {
1393        let x = crate::api::context::Context::new().symbol("x");
1394        let expr = x.powi(3);
1395        assert_eq!(expr.to_latex(), r"x^{3}");
1396    }
1397
1398    #[test]
1399    fn latex_sqrt() {
1400        let x = crate::api::context::Context::new().symbol("x");
1401        let expr = x.sqrt();
1402        assert_eq!(expr.to_latex(), r"\sqrt{x}");
1403    }
1404
1405    #[test]
1406    fn latex_cbrt() {
1407        let x = crate::api::context::Context::new().symbol("x");
1408        let expr = x.cbrt();
1409        assert_eq!(expr.to_latex(), r"\sqrt[3]{x}");
1410    }
1411
1412    #[test]
1413    fn latex_inverse() {
1414        let x = crate::api::context::Context::new().symbol("x");
1415        let expr = x.powi(-1);
1416        let latex = expr.to_latex();
1417        assert_eq!(latex, r"\frac{1}{x}");
1418    }
1419
1420    // ── Neg tests ──────────────────────────────────────────────────
1421
1422    #[test]
1423    fn latex_neg_symbol() {
1424        let x = crate::api::context::Context::new().symbol("x");
1425        let expr = -&x;
1426        assert_eq!(expr.to_latex(), "-x");
1427    }
1428
1429    // ── Add tests ──────────────────────────────────────────────────
1430
1431    #[test]
1432    fn latex_add_simple() {
1433        let x = crate::api::context::Context::new().symbol("x");
1434        let expr = &x + 1;
1435        let latex = expr.to_latex();
1436        assert!(latex == "x + 1" || latex == "1 + x", "got: {latex}");
1437    }
1438
1439    #[test]
1440    fn latex_add_power_and_const() {
1441        let x = crate::api::context::Context::new().symbol("x");
1442        let expr = x.powi(2) + 1;
1443        let latex = expr.to_latex();
1444        assert!(
1445            latex == r"x^{2} + 1" || latex == r"1 + x^{2}",
1446            "got: {latex}"
1447        );
1448    }
1449
1450    // ── Mul tests ──────────────────────────────────────────────────
1451
1452    #[test]
1453    fn latex_mul_coefficient() {
1454        let x = crate::api::context::Context::new().symbol("x");
1455        let expr = &x * 2;
1456        let latex = expr.to_latex();
1457        assert!(
1458            latex == "2 x" || latex == "x \\cdot 2" || latex == "2x",
1459            "got: {latex}"
1460        );
1461    }
1462
1463    // ── Trig function tests ────────────────────────────────────────
1464
1465    #[test]
1466    fn latex_sin() {
1467        let x = crate::api::context::Context::new().symbol("x");
1468        let expr = x.sin();
1469        assert_eq!(expr.to_latex(), r"\sin\left(x\right)");
1470    }
1471
1472    #[test]
1473    fn latex_cos() {
1474        let x = crate::api::context::Context::new().symbol("x");
1475        let expr = x.cos();
1476        assert_eq!(expr.to_latex(), r"\cos\left(x\right)");
1477    }
1478
1479    #[test]
1480    fn latex_tan() {
1481        let x = crate::api::context::Context::new().symbol("x");
1482        let expr = x.tan();
1483        assert_eq!(expr.to_latex(), r"\tan\left(x\right)");
1484    }
1485
1486    #[test]
1487    fn latex_sinh() {
1488        let x = crate::api::context::Context::new().symbol("x");
1489        let expr = x.sinh();
1490        assert_eq!(expr.to_latex(), r"\sinh\left(x\right)");
1491    }
1492
1493    #[test]
1494    fn latex_cosh() {
1495        let x = crate::api::context::Context::new().symbol("x");
1496        let expr = x.cosh();
1497        assert_eq!(expr.to_latex(), r"\cosh\left(x\right)");
1498    }
1499
1500    // ── Exp/Ln tests ───────────────────────────────────────────────
1501
1502    #[test]
1503    fn latex_exp() {
1504        let x = crate::api::context::Context::new().symbol("x");
1505        let expr = x.exp();
1506        assert_eq!(expr.to_latex(), r"\exp\left(x\right)");
1507    }
1508
1509    #[test]
1510    fn latex_ln() {
1511        let x = crate::api::context::Context::new().symbol("x");
1512        let expr = x.ln();
1513        assert_eq!(expr.to_latex(), r"\ln\left(x\right)");
1514    }
1515
1516    // ── Abs test ───────────────────────────────────────────────────
1517
1518    #[test]
1519    fn latex_abs() {
1520        let x = crate::api::context::Context::new().symbol("x");
1521        let expr = x.abs();
1522        assert_eq!(expr.to_latex(), r"\left|x\right|");
1523    }
1524
1525    // ── Inverse trig ───────────────────────────────────────────────
1526
1527    #[test]
1528    fn latex_asin() {
1529        let x = crate::api::context::Context::new().symbol("x");
1530        let expr = x.asin();
1531        assert_eq!(expr.to_latex(), r"\arcsin\left(x\right)");
1532    }
1533
1534    #[test]
1535    fn latex_acos() {
1536        let x = crate::api::context::Context::new().symbol("x");
1537        let expr = x.acos();
1538        assert_eq!(expr.to_latex(), r"\arccos\left(x\right)");
1539    }
1540
1541    #[test]
1542    fn latex_atan() {
1543        let x = crate::api::context::Context::new().symbol("x");
1544        let expr = x.atan();
1545        assert_eq!(expr.to_latex(), r"\arctan\left(x\right)");
1546    }
1547
1548    // ── Derivative test ────────────────────────────────────────────
1549
1550    #[test]
1551    fn latex_derivative() {
1552        let x = crate::api::context::Context::new().symbol("x");
1553        let expr = x.powi(2).formal_diff(&x);
1554        let latex = expr.to_latex();
1555        assert_eq!(latex, r"\frac{d}{dx} x^{2}");
1556    }
1557
1558    // ── Integral test ──────────────────────────────────────────────
1559
1560    #[test]
1561    fn latex_integral() {
1562        let x = crate::api::context::Context::new().symbol("x");
1563        let expr = x.sin().sin();
1564        let integral = expr.integrate(&x);
1565        let latex = integral.to_latex();
1566        if integral.expr_type() == crate::api::expr::ExprType::Integral {
1567            assert!(latex.starts_with(r"\int"), "got: {latex}");
1568            assert!(latex.ends_with(r"\, dx"), "got: {latex}");
1569        }
1570        assert!(!latex.is_empty());
1571    }
1572
1573    #[test]
1574    fn latex_definite_integral() {
1575        let ctx = crate::api::context::Context::new();
1576        let x = ctx.symbol("x");
1577        let node = x.sin().definite_integral_node(&x, &ctx.int(0), &ctx.pi());
1578        assert_eq!(node.to_latex(), r"\int_{0}^{\pi} \sin\left(x\right)\, dx");
1579    }
1580
1581    // ── Compound expressions ───────────────────────────────────────
1582
1583    #[test]
1584    fn latex_sin_squared() {
1585        let x = crate::api::context::Context::new().symbol("x");
1586        let expr = x.sin().powi(2);
1587        let latex = expr.to_latex();
1588        assert_eq!(latex, r"\sin^{2}\left(x\right)");
1589    }
1590
1591    #[test]
1592    fn latex_nested_function() {
1593        let x = crate::api::context::Context::new().symbol("x");
1594        let expr = x.sin().exp();
1595        let latex = expr.to_latex();
1596        assert_eq!(latex, r"\exp\left(\sin\left(x\right)\right)");
1597    }
1598
1599    // ── Delimiter tests ────────────────────────────────────────────
1600
1601    #[test]
1602    fn latex_inline_delimiters() {
1603        let x = crate::api::context::Context::new().symbol("x");
1604        assert_eq!(x.to_latex_inline(), "$x$");
1605    }
1606
1607    #[test]
1608    fn latex_display_delimiters() {
1609        let x = crate::api::context::Context::new().symbol("x");
1610        assert_eq!(x.to_latex_display(), "$$x$$");
1611    }
1612
1613    // ── Boolean / Relational ───────────────────────────────────────
1614
1615    #[test]
1616    fn latex_relational_gt() {
1617        let ctx = crate::api::context::Context::new();
1618        let x = ctx.symbol("x");
1619        let y = ctx.symbol("y");
1620        let expr = x.gt(&y);
1621        assert_eq!(expr.to_latex(), "x > y");
1622    }
1623
1624    #[test]
1625    fn latex_relational_eq() {
1626        let ctx = crate::api::context::Context::new();
1627        let x = ctx.symbol("x");
1628        let expr = x.eq_expr(&ctx.int(0));
1629        assert_eq!(expr.to_latex(), "x = 0");
1630    }
1631
1632    // ── Deep expression (stack safety) ─────────────────────────────
1633
1634    #[test]
1635    fn latex_deep_expression_no_stack_overflow() {
1636        let x = crate::api::context::Context::new().symbol("x");
1637        let mut expr = x.clone();
1638        for _ in 0..1000 {
1639            expr = expr.sin();
1640        }
1641        let latex = expr.to_latex();
1642        assert!(latex.starts_with(r"\sin\left("));
1643        assert!(latex.ends_with(r"\right)"));
1644    }
1645
1646    // ── Add with subtraction ───────────────────────────────────────
1647
1648    #[test]
1649    fn latex_add_with_neg_term() {
1650        let ctx = crate::api::context::Context::new();
1651        let x = ctx.symbol("x");
1652        let y = ctx.symbol("y");
1653        let expr = &x - &y;
1654        let latex = expr.to_latex();
1655        assert!(latex == "x - y" || latex == "-y + x", "got: {latex}");
1656    }
1657
1658    // ── Binomial ───────────────────────────────────────────────────
1659
1660    #[test]
1661    fn latex_binomial() {
1662        let ctx = Context::new();
1663        let n = ctx.symbol("n");
1664        let k = ctx.symbol("k");
1665        let expr = n.binomial(&k);
1666        let latex = expr.to_latex();
1667        assert_eq!(latex, r"\binom{n}{k}");
1668    }
1669
1670    // ── Factorial ──────────────────────────────────────────────────
1671
1672    #[test]
1673    fn latex_factorial() {
1674        let ctx = Context::new();
1675        let n = ctx.symbol("n");
1676        let expr = n.factorial();
1677        let latex = expr.to_latex();
1678        assert_eq!(latex, "n!");
1679    }
1680
1681    // ── Mul coefficient spacing ────────────────────────────────────
1682
1683    #[test]
1684    fn latex_coefficient_no_space() {
1685        let x = crate::api::context::Context::new().symbol("x");
1686        let expr = &x * 3; // 3*x
1687        let latex = expr.to_latex();
1688        // Should be "3x" — no "\cdot" for integer coefficient times variable
1689        assert!(
1690            !latex.contains(r"\cdot"),
1691            "coefficient times variable shouldn't use cdot: {latex}"
1692        );
1693    }
1694
1695    // ── 0.2 nodes ──────────────────────────────────────────────────────────
1696
1697    #[test]
1698    fn latex_named_constants() {
1699        let ctx = crate::api::context::Context::new();
1700        assert_eq!(ctx.euler_gamma().to_latex(), r"\gamma");
1701        assert_eq!(ctx.catalan().to_latex(), "G");
1702        assert_eq!(ctx.golden_ratio().to_latex(), r"\phi");
1703    }
1704
1705    #[test]
1706    fn latex_complex_nodes() {
1707        let ctx = crate::api::context::Context::new();
1708        let z = ctx.symbol("z");
1709        assert_eq!(z.re().to_latex(), r"\Re\left(z\right)");
1710        assert_eq!(z.im().to_latex(), r"\Im\left(z\right)");
1711        assert_eq!(z.conjugate().to_latex(), r"\overline{z}");
1712        assert_eq!(z.arg().to_latex(), r"\arg\left(z\right)");
1713        let sum = &z + 1;
1714        assert_eq!(sum.conjugate().to_latex(), r"\overline{z} + 1");
1715    }
1716
1717    #[test]
1718    fn latex_special_functions() {
1719        let ctx = crate::api::context::Context::new();
1720        let x = ctx.symbol("x");
1721        let n = ctx.symbol("n");
1722        assert_eq!(x.si().to_latex(), r"\operatorname{Si}\left(x\right)");
1723        assert_eq!(x.ci().to_latex(), r"\operatorname{Ci}\left(x\right)");
1724        assert_eq!(x.ei().to_latex(), r"\operatorname{Ei}\left(x\right)");
1725        assert_eq!(x.li().to_latex(), r"\operatorname{li}\left(x\right)");
1726        assert_eq!(x.zeta().to_latex(), r"\zeta\left(x\right)");
1727        assert_eq!(x.polygamma(&n).to_latex(), r"\psi^{(n)}\left(x\right)");
1728        assert_eq!(x.kronecker_delta(&n).to_latex(), r"\delta_{n x}");
1729    }
1730}