use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use rustyfi_backend::{
FontKey, FontMetrics, GraphicsElem, HorzBox, Length, MathGlyph, PureHorzBox,
};
use rustyfi_lang::value::Value;
use rustyfi_lang::{elaborate, eval, primitives, typecheck};
use rustyfi_loader::{LoadOptions, LoadedProgram};
fn lib_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi")
}
struct TempDoc(PathBuf);
impl TempDoc {
fn new(tag: &str, src: &str) -> TempDoc {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"rustyfi-lang-math-package-{tag}-{}-{}.saty",
std::process::id(),
n
));
fs::write(&path, src).expect("write temp fixture");
TempDoc(path)
}
}
impl Drop for TempDoc {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn as_v006(cst: rustyfi_loader::LoadedCst) -> rustyfi_syntax::cst::File {
match cst {
rustyfi_loader::LoadedCst::V0_0(f) => f,
rustyfi_loader::LoadedCst::V0_1(_) => {
unreachable!("this test's merge_program is the V0_0-only path")
}
}
}
fn merge_program(program: LoadedProgram) -> rustyfi_syntax::cst::File {
let mut files = program.files;
let entry = files.pop().expect("loader always yields the entry last");
let entry_cst = as_v006(entry.cst);
let mut prelude = Vec::new();
for lib in files {
prelude.extend(as_v006(lib.cst).prelude);
}
prelude.extend(entry_cst.prelude);
rustyfi_syntax::cst::File {
headers: Vec::new(),
prelude,
in_kw: entry_cst.in_kw,
body: entry_cst.body,
eoi: entry_cst.eoi,
}
}
struct NoFonts;
impl FontMetrics for NoFonts {
fn advance(&self, _f: FontKey, _c: char, _size: Length) -> Option<Length> {
None
}
fn ascender(&self, _f: FontKey, size: Length) -> Length {
size
}
fn descender(&self, _f: FontKey, _size: Length) -> Length {
Length::pt(0.0)
}
}
fn compile_via_loader(tag: &str, src: &str) -> Result<Value, String> {
compile_via_loader_with_metrics(tag, src, &NoFonts)
}
fn compile_via_loader_with_metrics(
tag: &str,
src: &str,
metrics: &dyn FontMetrics,
) -> Result<Value, String> {
let doc = TempDoc::new(tag, src);
let opts = LoadOptions {
lib_root: Some(lib_root()),
..Default::default()
};
let program = rustyfi_loader::load(&doc.0, &opts).map_err(|e| format!("load: {e}"))?;
let file = merge_program(program);
let env = primitives::base_env();
let store = rustyfi_lang::symbol::SymbolStore::new();
let scope = elaborate::Scope::new(&store, env.names());
let elaborated =
elaborate::elaborate_program(&file, &scope).map_err(|e| format!("elaborate: {e}"))?;
typecheck::typecheck(&elaborated).map_err(|e| format!("typecheck: {e}"))?;
let mut interp = eval::Interp::new(metrics);
interp
.eval(&env, &rustyfi_lang::ast::debrand(&elaborated.body, &store))
.map_err(|e| format!("eval: {e}"))
}
struct Mono;
impl FontMetrics for Mono {
fn advance(&self, _f: FontKey, _c: char, size: Length) -> Option<Length> {
Some(size * 0.5)
}
fn ascender(&self, _f: FontKey, size: Length) -> Length {
size * 0.75
}
fn descender(&self, _f: FontKey, size: Length) -> Length {
size * 0.25
}
}
fn run_with_big_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new()
.stack_size(256 * 1024 * 1024)
.spawn(f)
.expect("spawn big-stack thread")
.join()
.expect("big-stack thread panicked (see assertion above)");
}
fn as_int(v: Value) -> i64 {
match v {
Value::Int(n) => n,
other => panic!("expected an int, got {other:?}"),
}
}
fn as_length(v: Value) -> Length {
match v {
Value::Length(l) => l,
other => panic!("expected a length, got {other:?}"),
}
}
fn natural_width(v: Value) -> Length {
match v {
Value::Tuple(vs) if vs.len() == 3 => as_length(vs.into_iter().next().unwrap()),
other => panic!("expected a (length * length * length) tuple, got {other:?}"),
}
}
fn math_glyphs(v: Value) -> Vec<MathGlyph> {
match v {
Value::InlineBoxes(boxes) => {
assert_eq!(boxes.len(), 1, "expected exactly one box, got {boxes:?}");
match boxes.into_iter().next().unwrap() {
HorzBox::Pure(PureHorzBox::Math { glyphs, .. }) => glyphs,
other => panic!("expected a PureHorzBox::Math, got {other:?}"),
}
}
other => panic!("expected inline-boxes, got {other:?}"),
}
}
fn math_box(v: Value) -> (Vec<MathGlyph>, Vec<GraphicsElem>) {
match v {
Value::InlineBoxes(boxes) => {
assert_eq!(boxes.len(), 1, "expected exactly one box, got {boxes:?}");
match boxes.into_iter().next().unwrap() {
HorzBox::Pure(PureHorzBox::Math { glyphs, rules, .. }) => (glyphs, rules),
other => panic!("expected a PureHorzBox::Math, got {other:?}"),
}
}
other => panic!("expected inline-boxes, got {other:?}"),
}
}
fn delim_box(delim_math: &str) -> (Vec<MathGlyph>, Vec<GraphicsElem>) {
let src = format!(
"@require: math\n\
let-inline ctx \\math mm = embed-math ctx mm\n\
let ctx = get-initial-context 100pt (command \\math)\n\
in\n\
embed-math ctx {delim_math}"
);
let v = compile_via_loader_with_metrics("b3b2-identity", &src, &Mono)
.unwrap_or_else(|e| panic!("compile failed for {delim_math}: {e}"));
math_box(v)
}
#[test]
fn b3b2_paren_and_abs_draw_different_shapes_through_the_closures() {
run_with_big_stack(|| {
let (paren_g, paren_r) = delim_box(r"${\paren{x}}");
let (abs_g, abs_r) = delim_box(r"${\abs{x}}");
assert_eq!(
paren_g.len(),
1,
"paren: inner glyph only, delimiters drawn"
);
assert_eq!(abs_g.len(), 1, "abs: inner glyph only");
assert!(!paren_r.is_empty(), "paren draws its delimiters");
assert!(!abs_r.is_empty(), "abs draws its delimiters");
assert!(
paren_r.iter().all(|r| matches!(r, GraphicsElem::Fill(..))),
"paren draws filled bowls, got {paren_r:?}"
);
assert!(
abs_r.iter().any(|r| matches!(r, GraphicsElem::Stroke(..))),
"abs draws stroked bars, got {abs_r:?}"
);
});
}
#[test]
fn b3b2_brace_draws_and_differs_from_paren() {
run_with_big_stack(|| {
let (brace_g, brace_r) = delim_box(r"${\brace{x}}");
assert_eq!(brace_g.len(), 1, "brace: inner glyph only");
assert!(!brace_r.is_empty(), "brace draws its delimiters");
});
}
#[test]
fn require_math_compiles_and_evaluates() {
run_with_big_stack(|| {
let src = "@require: math
in
0";
let v = compile_via_loader("require-math", src).expect("math.satyh should compile");
assert_eq!(as_int(v), 0);
});
}
#[test]
fn require_math_join_is_reachable_across_the_module_boundary() {
run_with_big_stack(|| {
let src = "@require: math
in
match Math.join (math-char MathOrd `,`) [] with
| _ -> 1";
let v = compile_via_loader("require-math-join", src).expect("math.satyh should compile");
assert_eq!(as_int(v), 1);
});
}
#[test]
fn gap1_installed_math_command_renders_bare_embed_math_in_prose() {
run_with_big_stack(|| {
let src = "@require: math
in
let ctx = get-initial-context 200pt (command \\math) in
get-natural-metrics
(read-inline ctx {${\\alpha} + ${\\frac{1}{2}} + ${\\sum_{k=1}^{n}}})";
let v = compile_via_loader_with_metrics("gap1-e2e", src, &Mono)
.expect("installed \\math command should render prose ${...}");
let w = natural_width(v);
assert!(w.0 > 0.0, "expected positive width, got {w:?}");
});
}
#[test]
fn gap1_set_math_command_overrides_the_installed_command() {
let src = "let-inline ctx \\dummy m = inline-nil
let-inline ctx \\m0 m = inline-skip 42pt
in
let ctx0 = get-initial-context 200pt (command \\dummy) in
let ctx = set-math-command (command \\m0) ctx0 in
get-natural-metrics (read-inline ctx {${x}})";
let v = compile_via_loader("gap1-override", src)
.expect("set-math-command's installed \\m0 should run, not the dummy or any fallback");
let w = natural_width(v);
assert_eq!(
w,
Length::pt(42.0),
"expected \\m0's inline-skip 42pt, got {w:?}"
);
}
#[test]
fn gap2_pull_in_scripts_resolver_receives_the_actual_scripts() {
let src = "let-inline ctx \\math mm = embed-math ctx mm
in
let ctx = get-initial-context 100pt (command \\math) in
let resolver ms mt =
match mt with
| None -> math-char MathOrd `NN`
| Some(t) -> math-sup (math-char MathOrd `Y`) t
in
let m = math-pull-in-scripts MathOp MathOp resolver in
let w-sup = get-natural-metrics (embed-math ctx ${#m^{2}}) in
let w-bare = get-natural-metrics (embed-math ctx ${#m}) in
(w-sup, w-bare)";
let v = compile_via_loader_with_metrics("gap2-resolver-args", src, &Mono)
.expect("math-pull-in-scripts resolver should compile and evaluate");
let (w_sup, w_bare) = match v {
Value::Tuple(vs) if vs.len() == 2 => {
let mut it = vs.into_iter();
(
natural_width(it.next().unwrap()),
natural_width(it.next().unwrap()),
)
}
other => panic!("expected a 2-tuple, got {other:?}"),
};
assert_ne!(
w_sup, w_bare,
"sup-shaped ${{#m^{{2}}}} and bare ${{#m}} widths must differ \
(resolver must have received different (sub, sup) options)"
);
assert_eq!(
w_bare,
Length::pt(12.0),
"expected two full-size 'N' glyphs, got {w_bare:?}"
);
assert!(
w_sup < w_bare,
"expected the sup-shaped ${{#m^{{2}}}} (one base glyph + one script \
glyph) to be narrower than the two-full-size-glyph bare case, got \
w_sup={w_sup:?} w_bare={w_bare:?}"
);
}
#[test]
fn gap2_sum_with_sub_and_sup_renders_through_pull_in_scripts() {
run_with_big_stack(|| {
let src = "@require: math
in
let ctx = get-initial-context 200pt (command \\math) in
get-natural-metrics (embed-math ctx ${\\sum_{k=1}^{n}})";
let v = compile_via_loader_with_metrics("gap2-sum", src, &Mono)
.expect("\\sum_{k=1}^{n} should render through math-pull-in-scripts");
let w = natural_width(v);
assert!(w.0 > 0.0, "expected positive width, got {w:?}");
});
}
#[test]
fn gap2_merged_sub_sup_pair_places_scripts_side_by_side() {
let src = "let-inline ctx \\math m = embed-math ctx m
in
let ctx = get-initial-context 200pt (command \\math) in
get-natural-metrics (embed-math ctx ${x_1^2})";
let v = compile_via_loader_with_metrics("gap2-merged-sub-sup", src, &Mono)
.expect("${x_1^2} should compile and evaluate");
let w = natural_width(v);
let base = Length::pt(12.0) * 0.5; let script_size = Length::pt(12.0) * 0.7;
let script = script_size * 0.5; let expected_merged = base + script.max(script);
let sequential_if_unfixed = base + script + script;
assert_eq!(
w, expected_merged,
"expected the merged side-by-side width w(x) + max(w(1), w(2))·scale, got {w:?}"
);
assert_ne!(
w, sequential_if_unfixed,
"must NOT match the old sequential (unmerged) stacking width"
);
}
#[test]
fn gap3_align_over_bar_separated_math_lists_evaluates() {
run_with_big_stack(|| {
let src = "@require: math
in
let ctx = get-initial-context 200pt (command \\math) in
read-block ctx '<+align[${| a | b |}; ${| c | d |}];>";
let v = compile_via_loader_with_metrics("gap3-align-smoke", src, &Mono)
.expect("+align over ${| a | b |}; ${| c | d |} should compile and evaluate");
match v {
Value::BlockBoxes(_) => {}
other => panic!("expected block-boxes, got {other:?}"),
}
});
}
#[test]
fn gap6_text_command_via_math_satyh_renders_positive_width() {
run_with_big_stack(|| {
let src = "@require: math
in
let ctx = get-initial-context 200pt (command \\math) in
get-natural-metrics (embed-math ctx ${\\text!{ab}})";
let v = compile_via_loader_with_metrics("gap6-text-command", src, &Mono)
.expect("\\text!{ab} should compile and evaluate");
let w = natural_width(v);
assert!(w.0 > 0.0, "expected positive width, got {w:?}");
});
}
#[test]
fn gap5_mathrm_command_via_math_satyh_keeps_plain_ascii() {
run_with_big_stack(|| {
let src = "@require: math
in
let ctx = get-initial-context 200pt (command \\math) in
(embed-math ctx ${\\mathrm{x}}, embed-math ctx ${x})";
let v = compile_via_loader_with_metrics("gap5-mathrm-command", src, &Mono)
.expect("${\\mathrm{x}} and ${x} should compile and evaluate");
let (roman, italic) = match v {
Value::Tuple(vs) if vs.len() == 2 => {
let mut it = vs.into_iter();
(
math_glyphs(it.next().unwrap()),
math_glyphs(it.next().unwrap()),
)
}
other => panic!("expected a 2-tuple, got {other:?}"),
};
assert_eq!(roman.len(), 1);
assert_eq!(
roman[0].text, "x",
"\\mathrm{{x}} should keep the plain ascii 'x'"
);
assert_eq!(italic.len(), 1);
assert_eq!(
italic[0].text, "\u{1D465}",
"bare ${{x}} should render the default Italic Unicode remap"
);
assert_ne!(roman[0].text, italic[0].text);
});
}