use rustyfi_backend::{FontKey, FontMetrics, HorzBox, Length, MathGlyph, PureHorzBox};
use rustyfi_lang::value::Value;
use rustyfi_lang::{elaborate, eval, primitives, typecheck, CompileError};
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
}
}
struct AsciiMono;
impl FontMetrics for AsciiMono {
fn advance(&self, _f: FontKey, c: char, size: Length) -> Option<Length> {
if c.is_ascii() {
Some(size * 0.5)
} else {
None
}
}
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(src: &str, metrics: &dyn FontMetrics) -> Result<Value, CompileError> {
let file = rustyfi_syntax::parse_file(src)?;
let env = primitives::base_env();
let store = rustyfi_lang::symbol::SymbolStore::new();
let scope = elaborate::Scope::new(&store, env.names());
let program = elaborate::elaborate_program(&file, &scope)?;
typecheck::typecheck(&program)?;
let mut interp = eval::Interp::new(metrics);
Ok(interp.eval(&env, &rustyfi_lang::ast::debrand(&program.body, &store))?)
}
fn run(src: &str) -> Result<Value, CompileError> {
run_with(src, &Mono)
}
fn as_int(v: Value) -> i64 {
match v {
Value::Int(n) => n,
other => panic!("expected an int, got {other:?}"),
}
}
fn math_box(v: Value) -> (Length, 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 { width, glyphs, .. }) => (width, glyphs),
other => panic!("expected a PureHorzBox::Math, got {other:?}"),
}
}
other => panic!("expected inline-boxes, got {other:?}"),
}
}
fn with_ctx(body: &str) -> String {
format!(
"let-inline ctx \\dummy m = inline-nil\n\
in\n\
let ctx = get-initial-context 200pt (command \\dummy) in\n\
{body}"
)
}
#[test]
fn gap5_minus_reclassified_as_bin_with_minus_sign_glyph() {
let src = with_ctx("embed-math ctx ${a-b}");
let v = run(&src).expect("${a-b} should compile and evaluate");
let (width, glyphs) = math_box(v);
assert_eq!(
glyphs.len(),
3,
"expected 3 glyphs (a, -, b), got {glyphs:?}"
);
assert_eq!(
glyphs[1].text, "\u{2212}",
"middle glyph should be U+2212 MINUS SIGN"
);
let glyph_w = Length::pt(12.0) * 0.5;
let bin_space = Length::pt(12.0) * 0.25;
let expected = glyph_w + bin_space + glyph_w + bin_space + glyph_w;
assert_eq!(
width, expected,
"expected 3 glyphs + 2 Bin spaces, got {width:?}"
);
}
#[test]
fn gap5_multi_char_symbol_run_splits_into_per_char_atoms() {
let src = with_ctx("embed-math ctx ${a->b}");
let v = run(&src).expect("${a->b} should compile and evaluate");
let (width, glyphs) = math_box(v);
assert_eq!(
glyphs.len(),
4,
"expected 4 glyphs (a, -, >, b), got {glyphs:?}"
);
assert_eq!(
glyphs[1].text, "\u{2212}",
"the `-` gets its own class-map hit now that it is its own token"
);
let glyph_w = Length::pt(12.0) * 0.5;
let rel_space = Length::pt(12.0) * 0.375;
assert_eq!(
width,
glyph_w * 4.0 + rel_space * 2.0,
"expected 4 glyphs + Rel spacing either side of `>` (the `-` is \
normalized to Ord before a Rel), got {width:?}"
);
}
#[test]
fn adjacent_relations_get_no_space_between_them() {
let src = with_ctx("embed-math ctx ${a:=b}");
let v = run(&src).expect("${a:=b} should compile and evaluate");
let (width, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 4, "expected 4 glyphs (a, :, =, b)");
let glyph_w = Length::pt(12.0) * 0.5;
let rel_space = Length::pt(12.0) * 0.375;
assert_eq!(
width,
glyph_w * 4.0 + rel_space * 2.0,
"expected 4 glyphs + exactly TWO Rel spaces, got {width:?}"
);
}
#[test]
fn leading_binary_is_normalized_to_ordinary() {
let src = with_ctx("embed-math ctx ${-a}");
let v = run(&src).expect("${-a} should compile and evaluate");
let (width, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 2, "expected 2 glyphs (-, a)");
assert_eq!(
width,
Length::pt(12.0) * 0.5 * 2.0,
"expected 2 glyphs and NO Bin space, got {width:?}"
);
}
#[test]
fn a_run_of_minuses_sets_tight_as_all_minus_signs() {
let src = with_ctx("embed-math ctx ${-------}");
let v = run(&src).expect("${-------} should compile and evaluate");
let (width, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 7, "expected 7 glyphs, got {glyphs:?}");
assert!(
glyphs.iter().all(|g| g.text == "\u{2212}"),
"every one should be U+2212 MINUS SIGN, got {glyphs:?}"
);
assert_eq!(
width,
Length::pt(12.0) * 0.5 * 7.0,
"expected 7 glyphs and no inter-atom spacing, got {width:?}"
);
}
#[test]
fn gap5_colon_reclassified_as_rel() {
let src = with_ctx("embed-math ctx ${a:b}");
let v = run(&src).expect("${a:b} should compile and evaluate");
let (width, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 3);
let glyph_w = Length::pt(12.0) * 0.5;
let rel_space = Length::pt(12.0) * 0.375;
let expected = glyph_w + rel_space + glyph_w + rel_space + glyph_w;
assert_eq!(width, expected, "expected Rel spacing on both sides of ':'");
assert!(
width > Length::pt(18.0),
"must be strictly wider than the old Punct-spacing width"
);
}
#[test]
fn gap5_default_char_class_is_italic() {
let src = with_ctx("embed-math ctx ${x}");
let v = run(&src).expect("${x} should compile and evaluate");
let (_, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 1);
assert_eq!(glyphs[0].text, "\u{1D465}");
}
#[test]
fn gap5_change_char_class_to_roman_keeps_plain_ascii() {
let src = with_ctx("embed-math ctx (math-char-class MathRoman ${x})");
let v = run(&src).expect("math-char-class MathRoman ${x} should compile and evaluate");
let (_, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 1);
assert_eq!(glyphs[0].text, "x");
}
#[test]
fn gap5_change_char_class_to_bold_italic() {
let src = with_ctx("embed-math ctx (math-char-class MathBoldItalic ${x})");
let v = run(&src).expect("math-char-class MathBoldItalic ${x} should compile and evaluate");
let (_, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 1);
assert_eq!(glyphs[0].text, "\u{1D499}");
}
#[test]
fn gap5_ascii_only_font_falls_back_to_source_char() {
let src = with_ctx("embed-math ctx ${x}");
let v = run_with(&src, &AsciiMono).expect("${x} should compile and evaluate under AsciiMono");
let (_, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 1);
assert_eq!(
glyphs[0].text, "x",
"must fall back to the source char under an ASCII-only font"
);
}
#[test]
fn gap7_set_math_variant_char_overrides_default_remap() {
let src = with_ctx(
"let ctx2 = set-math-variant-char MathItalic 0x78 0x79 ctx in\n\
embed-math ctx2 ${x}",
);
let v = run(&src).expect("set-math-variant-char should compile and evaluate");
let (_, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 1);
assert_eq!(glyphs[0].text, "y");
}
#[test]
fn gap7_get_left_math_class_on_leading_minus() {
let src = with_ctx(
"match get-left-math-class ctx ${-x} with\n\
| Some(MathBin) -> 1\n\
| _ -> 0",
);
let v = run(&src).expect("get-left-math-class should compile and evaluate");
assert_eq!(as_int(v), 1);
}
#[test]
fn gap7_get_right_math_class_on_trailing_minus() {
let src = with_ctx(
"match get-right-math-class ctx ${x-} with\n\
| Some(MathBin) -> 1\n\
| _ -> 0",
);
let v = run(&src).expect("get-right-math-class should compile and evaluate");
assert_eq!(as_int(v), 1);
}
#[test]
fn gap7_get_left_math_class_on_empty_math_is_none() {
let src = with_ctx(
"match get-left-math-class ctx ${} with\n\
| None -> 1\n\
| _ -> 0",
);
let v = run(&src).expect("get-left-math-class on ${} should compile and evaluate");
assert_eq!(as_int(v), 1);
}
#[test]
fn gap6_text_in_math_renders_through_read_inline() {
let src = with_ctx("embed-math ctx (text-in-math MathOrd (fun c -> read-inline c {ab}))");
let v = run(&src).expect("text-in-math should no longer error");
let (width, glyphs) = math_box(v);
assert_eq!(
width,
Length::pt(12.0),
"expected the embedded \"ab\" word's own 12pt width"
);
let joined: String = glyphs.iter().map(|g| g.text.as_str()).collect();
assert!(
joined.contains("ab"),
"expected a glyph whose text contains \"ab\", got {glyphs:?}"
);
}
fn as_string(v: Value) -> String {
match v {
Value::Str(s) => s,
other => panic!("expected a string, got {other:?}"),
}
}
#[test]
fn convert_string_for_math_italic_maps_abc_to_1d44e_block() {
let src = with_ctx("convert-string-for-math ctx MathItalic `abc`");
let v = run(&src).expect("convert-string-for-math should compile and evaluate");
assert_eq!(as_string(v), "\u{1D44E}\u{1D44F}\u{1D450}");
}
#[test]
fn convert_string_for_math_italic_passthrough_and_h_exception() {
let src = with_ctx("convert-string-for-math ctx MathItalic `h 7`");
let v = run(&src).expect("convert-string-for-math should compile and evaluate");
assert_eq!(as_string(v), "\u{210E} 7");
}
#[test]
fn convert_string_for_math_whole_token_class_map_minus() {
let src = with_ctx("convert-string-for-math ctx MathItalic `-`");
let v = run(&src).expect("convert-string-for-math should compile and evaluate");
assert_eq!(as_string(v), "\u{2212}");
}
#[test]
fn convert_string_for_math_uses_passed_class() {
let src = with_ctx("convert-string-for-math ctx MathBoldRoman `A`");
let v = run(&src).expect("convert-string-for-math should compile and evaluate");
assert_eq!(as_string(v), "\u{1D400}");
}
fn math_box_full(v: Value) -> (Length, Vec<MathGlyph>, Vec<rustyfi_backend::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 {
width,
glyphs,
rules,
..
}) => (width, glyphs, rules),
other => panic!("expected a PureHorzBox::Math, got {other:?}"),
}
}
other => panic!("expected inline-boxes, got {other:?}"),
}
}
#[test]
fn text_in_math_descends_into_a_line_stacked_embedded_block() {
let src = with_ctx(
"let braced = embed-math ctx ${abc} in\n\
let brace = inline-graphics 10pt 3pt 0pt (fun (x, y) ->\n\
[fill (Gray(0.0)) (start-path (x, y) |> line-to (x +' 10pt, y)\n\
|> close-with-line)]) in\n\
let stacked = line-stack-bottom [brace; braced] in\n\
embed-math ctx (text-in-math MathOrd (fun _ -> stacked))",
);
let v = run(&src).expect("the `\\overbrace` shape should compile and evaluate");
let (width, glyphs, rules) = math_box_full(v);
assert_eq!(
glyphs.len(),
3,
"the stacked `${{abc}}` line's three glyphs must survive, got {glyphs:?}"
);
assert!(
!rules.is_empty(),
"the stacked `inline-graphics` brace must survive as a rule"
);
assert!(width > Length::ZERO, "the box keeps its width");
let braced_line_dy = glyphs[0].dy;
assert_eq!(
braced_line_dy,
Length::ZERO,
"the anchored (last) line sits on the math baseline"
);
let rule_top = rules
.iter()
.filter_map(rustyfi_backend::graphics_bbox)
.map(|((_, _), (_, max_y))| max_y)
.fold(Length::ZERO, |a, b| if b > a { b } else { a });
assert!(
rule_top > Length::ZERO,
"the brace line stacks ABOVE the anchored line, got top {rule_top:?}"
);
}
#[test]
fn a_stacked_math_line_above_the_anchor_keeps_its_vertical_offset() {
let src = with_ctx(
"let braced = embed-math ctx ${abc} in\n\
let anchor = inline-skip 10pt in\n\
let stacked = line-stack-bottom [braced; anchor] in\n\
embed-math ctx (text-in-math MathOrd (fun _ -> stacked))",
);
let v = run(&src).expect("a two-line stack should compile and evaluate");
let (_, glyphs, _) = math_box_full(v);
assert_eq!(glyphs.len(), 3, "expected 3 glyphs, got {glyphs:?}");
assert!(
glyphs.iter().all(|g| g.dy > Length::ZERO),
"every glyph of the non-anchored line sits above the baseline, got {glyphs:?}"
);
}