use rustyfi_syntax::cst::{self, parse_file};
use rustyfi_syntax::token::{Atom, Token};
use rustyfi_syntax::lex;
use syan::parse::Unparse;
fn assert_roundtrip(src: &str) {
let file = parse_file(src).unwrap_or_else(|e| panic!("parse failed on {src:?}: {e}"));
let mut out = Vec::<Atom>::new();
file.unparse(&mut (&mut out)).unwrap();
let orig: Vec<Token> = lex(src).unwrap().into_iter().map(|a| a.slot).collect();
let re: Vec<Token> = out.into_iter().map(|a| a.slot).collect();
assert_eq!(orig, re, "token round-trip mismatch for {src:?}");
}
#[test]
fn minimal_expression_file() {
assert_roundtrip("3");
assert_roundtrip("`hello`");
assert_roundtrip("f x y");
assert_roundtrip("(f 1) 2.5 3pt");
}
#[test]
fn stage_headers() {
assert_roundtrip("@stage: persistent\nlet x = 1 in x");
assert_roundtrip("@stage: 0\nlet x = 1 in x");
assert_roundtrip("@stage: 1\nlet x = 1 in x");
assert_roundtrip("@require: list\n@stage: persistent\nlet x = 1 in x");
}
#[test]
fn headers_and_prelude() {
assert_roundtrip("@require: stdjabook\nlet x = 1 in x");
assert_roundtrip("let f a b = a in f 1 2");
assert_roundtrip("let x = 1\nlet y = 2\nin y");
}
#[test]
fn non_recursive_let_accepts_a_leading_bar() {
assert_roundtrip("let f : int -> int = 1 in f");
assert_roundtrip("let f : int -> int | x = x in f 1");
assert_roundtrip("let f | x = x in f 1");
assert_roundtrip("let f x = x in f 1");
assert_roundtrip("let f : int -> int | x = x\nin f 1");
}
#[test]
fn records_lists_functions() {
assert_roundtrip("(| title = {T}; size = 3pt |)");
assert_roundtrip("(| |)");
assert_roundtrip("[1; 2; 3]");
assert_roundtrip("[]");
assert_roundtrip("fun x -> x");
assert_roundtrip("let apply = fun f x -> f x in apply");
assert_roundtrip("fun (a, b) x -> a");
assert_roundtrip("()");
}
#[test]
fn inline_text() {
assert_roundtrip("{ Hello, world! }");
assert_roundtrip(r"{ Hello \emph{strong} text }");
assert_roundtrip(r"{ nested \emph{a \emph{b} c} here }");
assert_roundtrip(r"{\skip(3pt);gap}");
assert_roundtrip("{}");
}
#[test]
fn block_text() {
assert_roundtrip("'< +p { one } +p { two } >");
assert_roundtrip("'<>");
assert_roundtrip("'<+clear;>");
assert_roundtrip("'<+sec(1)<+p{a}>>");
}
#[test]
fn document_shape() {
assert_roundtrip(
"@require: stdjabook\n\
let title-str = `Milestone`\n\
in\n\
document (| title = {T}; author = {me} |) '<\n\
+p { Hello, world! }\n\
+p { Second \\emph{paragraph} here. }\n\
>",
);
}
#[test]
fn cmd_args_are_application_chains() {
let file = parse_file(r"{\cmd(1)(2);}").unwrap();
let Some(cst::ast::Expr::Ops(chain)) = file.body else {
panic!("expected Ops body");
};
let cst::ast::Atomic::InlineText { elems, .. } = chain.head.head else {
panic!("expected inline text");
};
assert_eq!(elems.len(), 1);
let cst::ast::InlineElem::Cmd { tail, .. } = &elems[0] else {
panic!("expected command");
};
let cst::ast::CmdTail::Args { first, rest, semi } = tail else {
panic!("expected args tail");
};
assert!(semi.is_some());
assert!(matches!(&**first, cst::ast::AppArg::Atom { .. }));
assert_eq!(rest.len(), 1, "head + one more argument");
}
#[test]
fn deep_nesting_is_unbounded() {
let depth = 64;
let mut src = String::from("{");
for _ in 0..depth {
src.push_str(r"\emph{");
}
src.push('x');
for _ in 0..depth {
src.push('}');
}
src.push('}');
std::thread::Builder::new()
.stack_size(64 * 1024 * 1024)
.spawn(move || assert_roundtrip(&src))
.unwrap()
.join()
.unwrap();
}
#[test]
fn parse_errors_have_positions() {
let err = parse_file("let x = in x").unwrap_err();
assert!(err.span.start.line >= 1);
assert_roundtrip("${x}");
let err = parse_file("${x").unwrap_err();
assert!(err.span.start.line >= 1);
}
#[test]
fn deep_parse_error_span_reaches_past_first_line() {
let err = parse_file("let x = 1\nlet y = )").unwrap_err();
assert!(err.span.end.line >= 2);
}
#[test]
fn operator_chains() {
assert_roundtrip("1 + 2 * 3 - 4");
assert_roundtrip("a +. b");
assert_roundtrip("x ^ y");
assert_roundtrip("1 :: 2 :: []");
assert_roundtrip("a mod b");
}
#[test]
fn if_then_else() {
assert_roundtrip("if x then 1 else 2");
assert_roundtrip("if a then if b then 1 else 2 else 3");
}
#[test]
fn let_and_let_rec_local() {
assert_roundtrip("let x = 1 in x + 1");
assert_roundtrip("let-rec f n = f n in f");
assert_roundtrip("let-rec even n = odd n and odd n = even n in even 4");
}
#[test]
fn destructuring_let() {
assert_roundtrip("let (a, b) = (1, 2) in a");
assert_roundtrip("let (_, acc) = (1, 2) in acc");
assert_roundtrip("let Some (x) = y in x");
}
#[test]
fn multi_clause_pattern_let_rec() {
assert_roundtrip("let-rec map | f (None) = None | f (Some(v)) = Some(f v) in map");
assert_roundtrip(
"let-rec map\n\
| f [] = []\n\
| f (x :: xs) = (f x) :: map f xs\n\
in map",
);
assert_roundtrip("let-rec filter | _ [] = [] | p (x :: xs) = filter p xs in filter");
assert_roundtrip("let-rec first (x :: xs) = x in first");
}
#[test]
fn match_expressions() {
assert_roundtrip("match x with | 0 -> `a` | n when n -> `b` | _ -> `c`");
assert_roundtrip("match l with | [] -> 0 | x :: rest -> x");
}
#[test]
fn tuples_and_parens() {
assert_roundtrip("(1, 2)");
assert_roundtrip("(1, 2, 3)");
assert_roundtrip("(1)");
}
#[test]
fn constructors() {
assert_roundtrip("Some 1");
assert_roundtrip("None");
assert_roundtrip("match o with | Some x -> x | None -> 0");
}
#[test]
fn let_inline_top_level() {
assert_roundtrip("let-inline ctx \\bold inner = x in y");
}
#[test]
fn text_embeds() {
assert_roundtrip("{ a #name; b }");
assert_roundtrip("'< #content; >");
}
#[test]
fn type_declaration() {
assert_roundtrip("type t = | A | B of int\nin 0");
}
#[test]
fn bar_is_not_a_binop() {
assert!(parse_file("1 + | 2").is_err());
}
#[test]
fn unary_minus() {
assert_roundtrip("- x");
assert_roundtrip("1 - - 2");
}
#[test]
fn pattern_shapes() {
assert_roundtrip("match x with | (a, b) -> a");
assert_roundtrip("match x with | y as z -> z");
assert_roundtrip("match x with | [a; b] -> a | _ -> b");
assert_roundtrip("match x with | Some (a, b) -> a | None -> c");
}
#[test]
fn type_declaration_shapes() {
assert_roundtrip("type 'a opt = | N | S of 'a\nin 0");
assert_roundtrip("type t = A\nin 0");
assert_roundtrip("type f = | F of int -> int\nin 0");
}
#[test]
fn applied_and_product_types_in_signatures() {
assert_roundtrip(
"module M : sig\n\
val f : ('a -> 'b) -> 'a option -> 'b option\n\
end = struct\n\
let f g x = g x\n\
end",
);
assert_roundtrip(
"module M : sig\n\
val g : ('a -> 'a -> bool) -> 'a -> ('a * 'b) list -> 'b option\n\
end = struct\n\
let g eq a l = None\n\
end",
);
assert_roundtrip(
"module M : sig\n\
val h : ('a list) list -> 'a list\n\
end = struct\n\
let h l = []\n\
end",
);
}
#[test]
fn module_qualified_type_application() {
assert_roundtrip(
"module M : sig\n\
type 'a t\n\
val make : 'a -> 'a t\n\
end = struct\n\
type 'a t = 'a\n\
let make x = x\n\
end\n\
module N : sig\n\
val x : int M.t\n\
end = struct\n\
let x = M.make 1\n\
end\n\
in 0",
);
assert_roundtrip(
"module M : sig\n\
type 'a t\n\
val make : 'a -> 'a t\n\
end = struct\n\
type 'a t = 'a\n\
let make x = x\n\
end\n\
module N : sig\n\
val x : 'content M.t\n\
end = struct\n\
let x = M.make x\n\
end\n\
in 0",
);
assert_roundtrip(
"module M : sig\n\
type t\n\
val x : M.t\n\
end = struct\n\
type t = int\n\
let x = 1\n\
end\n\
in 0",
);
}
#[test]
fn module_qualified_type_application_shape() {
let file = parse_file(
"module Ordering : sig\n\
val eq : ordering Eq.t\n\
end = struct\n\
let eq = 0\n\
end",
)
.unwrap();
let [cst::TopBinding::Module { sig: Some(sig), .. }] = file.prelude.as_slice() else {
panic!("expected a single `module .. : sig .. end` prelude binding, got {:?}", file.prelude);
};
let [cst::SigItem::Val { ty, .. }] = sig.items.as_slice() else {
panic!("expected a single `val` sig item, got {:?}", sig.items);
};
let cst::ast::TypeExpr::Atom(prod) = ty else {
panic!("expected a bare (arrow-less) type, got {ty:?}");
};
let cst::ast::TypeApp { head, rest } = &prod.first;
assert!(matches!(head, cst::ast::TypeAtom::Name(n) if n.name == "ordering"));
assert_eq!(rest.len(), 1);
let cst::ast::TypeAtom::NameMod(ctor) = &rest[0] else {
panic!("expected a NameMod constructor, got {:?}", rest[0]);
};
assert_eq!(ctor.mods, vec!["Eq".to_string()]);
assert_eq!(ctor.name, "t");
}
#[test]
fn multi_argument_postfix_type_application_parses() {
let file = parse_file(
"module M : sig\n\
val x : 'a 'e result\n\
end = struct\n\
let x = 0\n\
end",
)
.expect("N-ary postfix type application ('a 'e result) must parse");
let [cst::TopBinding::Module { sig: Some(sig), .. }] = file.prelude.as_slice() else {
panic!("expected a `module .. : sig .. end`, got {:?}", file.prelude);
};
let [cst::SigItem::Val { ty, .. }] = sig.items.as_slice() else {
panic!("expected a single `val`, got {:?}", sig.items);
};
let cst::ast::TypeExpr::Atom(prod) = ty else {
panic!("expected a bare type, got {ty:?}");
};
assert!(matches!(&prod.first.head, cst::ast::TypeAtom::Var(_)), "{:?}", prod.first.head);
assert_eq!(prod.first.rest.len(), 2, "{:?}", prod.first.rest);
assert!(matches!(&prod.first.rest[0], cst::ast::TypeAtom::Var(_)));
assert!(matches!(&prod.first.rest[1], cst::ast::TypeAtom::Name(n) if n.name == "result"));
}
#[test]
fn naming_form_bang_and_before() {
assert_roundtrip("let (!) x = x in !3");
assert_roundtrip(
"module M : sig\n\
val (!) : int -> int\n\
end = struct\n\
let (!) x = x\n\
end",
);
assert_roundtrip(
"module M : sig\n\
val (before) : int -> int -> int\n\
end = struct\n\
let (before) x y = x\n\
end",
);
assert_roundtrip("let (!) x = x in (!) 3");
assert_roundtrip("!x");
assert_roundtrip("!!x");
assert_roundtrip("a before b");
}
#[test]
fn match_binds_greedily() {
let file = parse_file("match x with | 0 -> match y with | 1 -> 1 | _ -> 3").unwrap();
let Some(cst::ast::Expr::Match { first, rest, .. }) = file.body else {
panic!("expected match");
};
assert!(rest.is_empty(), "outer match has a single arm");
let cst::ast::Expr::Match { rest: inner_rest, .. } = &*first.body.0 else {
panic!("expected nested match body");
};
assert_eq!(inner_rest.len(), 1, "inner match took the remaining arm");
}
#[test]
fn mutables_and_sequencing() {
assert_roundtrip("let-mutable c <- 0 in c");
assert_roundtrip("c <- 1 + 2");
assert_roundtrip("while !c < 3 do c <- !c + 1");
assert_roundtrip("a before b");
}
#[test]
fn deref_unop() {
assert_roundtrip("!x");
assert_roundtrip("!!x");
assert_roundtrip("f !x !y");
}
#[test]
fn field_access() {
assert_roundtrip("x#y");
assert_roundtrip("x#y#z");
}
#[test]
fn record_update() {
assert_roundtrip("(| r with a = 1 |)");
assert_roundtrip("(| r with a = 1; b = 2 |)");
}
#[test]
fn optional_application_args() {
assert_roundtrip("f ?:(1) x");
assert_roundtrip("f ?* x");
}
#[test]
fn command_call_with_leading_optional_args() {
assert_roundtrip("{ \\ref?:(x){text} }");
assert_roundtrip("{ \\ref?*{text} }");
assert_roundtrip("{ \\ref?:(x)?:(y){text} }");
}
#[test]
fn optional_argument_type_grammar() {
assert_roundtrip(
"module M : sig\n\
val f : 'a -> config ?-> block-text -> document\n\
end = struct\n\
let f x c bt = bt\n\
end",
);
assert_roundtrip(
"module M : sig\n\
direct +section : [string?; string?; inline-text; block-text] block-cmd\n\
end = struct\n\
end",
);
assert_roundtrip(
"module M : sig\n\
val g : [int; string?] math-cmd\n\
end = struct\n\
end",
);
}
#[test]
fn math_command_names_plain_and_qualified() {
use rustyfi_syntax::leaf::AnyMathCmdTok;
let file = parse_file(r"${\cmd{x}}").unwrap();
let Some(cst::ast::Expr::Ops(chain)) = file.body else {
panic!("expected Ops body");
};
let cst::ast::Atomic::MathText { elems, .. } = chain.head.head else {
panic!("expected math text");
};
let cst::ast::MathBot::Cmd { name, .. } = &elems[0].base else {
panic!("expected a math command");
};
assert!(matches!(name, AnyMathCmdTok::Plain(t) if t.name == r"\cmd"));
let file = parse_file(r"${\Mod.cmd{x}}").unwrap();
let Some(cst::ast::Expr::Ops(chain)) = file.body else {
panic!("expected Ops body");
};
let cst::ast::Atomic::MathText { elems, .. } = chain.head.head else {
panic!("expected math text");
};
let cst::ast::MathBot::Cmd { name, .. } = &elems[0].base else {
panic!("expected a math command");
};
match name {
AnyMathCmdTok::Mod(t) => {
assert_eq!(t.mods, vec!["Mod".to_string()]);
assert_eq!(t.name, r"\cmd");
}
AnyMathCmdTok::Plain(_) => panic!("expected a qualified math command"),
}
}
#[test]
fn math_lists() {
assert_roundtrip("${| a | b |}");
assert_roundtrip("${|}");
assert_roundtrip("${||}");
}
#[test]
fn itemize_markers() {
let file = parse_file("{ * a ** b }").unwrap();
let Some(cst::ast::Expr::Ops(chain)) = file.body else {
panic!("expected Ops body");
};
let cst::ast::Atomic::InlineText { elems, .. } = chain.head.head else {
panic!("expected inline text");
};
assert!(matches!(elems[0], cst::ast::InlineElem::ItemBullet(_)));
assert!(matches!(elems[2], cst::ast::InlineElem::ItemBullet(_)));
assert_roundtrip("{ * a ** b }");
}
#[test]
fn math_round_trips() {
assert_roundtrip("${x^2+\\frac{a}{b}}");
assert_roundtrip("${a_1'}");
assert_roundtrip("${\\cmd!(3){x}}");
assert_roundtrip("{ a ${x+y} b }");
assert_roundtrip("${\\Mod.cmd{x}}");
}
#[test]
fn math_optional_args_round_trip() {
assert_roundtrip("${\\cmd?:{x}{y}}");
assert_roundtrip("${\\cmd?*{y}}");
assert_roundtrip("${\\cmd?:!(3){y}}");
}
#[test]
fn math_optional_args_cst_shape() {
use rustyfi_syntax::leaf::AnyMathCmdTok;
let file = parse_file(r"${\cmd?:{x}{y}}").unwrap();
let Some(cst::ast::Expr::Ops(chain)) = file.body else {
panic!("expected Ops body");
};
let cst::ast::Atomic::MathText { elems, .. } = chain.head.head else {
panic!("expected math text");
};
let cst::ast::MathBot::Cmd { name, args } = &elems[0].base else {
panic!("expected a math command");
};
assert!(matches!(name, AnyMathCmdTok::Plain(t) if t.name == r"\cmd"));
assert_eq!(args.len(), 2);
match &args[0] {
cst::ast::MathArg::Optional { body, .. } => {
assert!(matches!(body, cst::ast::MathArgBody::Math { .. }));
}
other => panic!("expected an Optional matharg, got {other:?}"),
}
match &args[1] {
cst::ast::MathArg::Plain(body) => {
assert!(matches!(body, cst::ast::MathArgBody::Math { .. }));
}
other => panic!("expected a Plain matharg, got {other:?}"),
}
let file = parse_file(r"${\cmd?*{y}}").unwrap();
let Some(cst::ast::Expr::Ops(chain)) = file.body else {
panic!("expected Ops body");
};
let cst::ast::Atomic::MathText { elems, .. } = chain.head.head else {
panic!("expected math text");
};
let cst::ast::MathBot::Cmd { args, .. } = &elems[0].base else {
panic!("expected a math command");
};
assert_eq!(args.len(), 2);
assert!(matches!(args[0], cst::ast::MathArg::Omission(_)));
match &args[1] {
cst::ast::MathArg::Plain(body) => {
assert!(matches!(body, cst::ast::MathArgBody::Math { .. }));
}
other => panic!("expected a Plain matharg, got {other:?}"),
}
}
#[test]
fn modules_and_open() {
assert_roundtrip(
"module M = struct\n\
let x = 1\n\
end\n\
open M in M.x",
);
assert_roundtrip(
"module Outer = struct\n\
module Inner = struct\n\
let y = 2\n\
end\n\
end",
);
}
#[test]
fn open_module_expression() {
assert_roundtrip(
"module M = struct\n\
let x = 3\n\
end\n\
M.(x + 1)",
);
assert_roundtrip(
"module M = struct\n\
let x = 3\n\
end\n\
M.(x, x)",
);
}
#[test]
fn library_file_has_no_body() {
let file = parse_file("let x = 1").unwrap();
assert!(file.in_kw.is_none());
assert!(file.body.is_none());
assert_roundtrip("let x = 1");
assert_roundtrip("let x = 1 in x");
assert_roundtrip("3");
}
#[test]
fn command_value() {
assert_roundtrip("let-inline \\m ctx = ctx in (command \\m)");
assert_roundtrip("get-initial-context 100pt (command \\m)");
}
#[test]
fn sig_constraint_suffix() {
assert_roundtrip(
"module M : sig\n\
val document : 'a -> config ?-> block-text -> document\n\
constraint 'a :: (| title : inline-text; author : inline-text |)\n\
end = struct\n\
let document x c bt = bt\n\
end",
);
assert_roundtrip(
"module M : sig\n\
val document : 'a -> config ?-> block-text -> document\n\
constraint 'a :: (|\n\
title : inline-text;\n\
author : inline-text;\n\
show-toc : bool;\n\
show-title : bool;\n\
|)\n\
end = struct\n\
let document x c bt = bt\n\
end",
);
}
#[test]
fn stdja_sig_block_parses() {
assert_roundtrip(
"module StdJa : sig\n\
val default-config : config\n\
val document : 'a -> config ?-> block-text -> document\n\
constraint 'a :: (|\n\
title : inline-text;\n\
author : inline-text;\n\
show-toc : bool;\n\
show-title : bool;\n\
|)\n\
val font-latin-roman : string * float * float\n\
direct \\ref : [string] inline-cmd\n\
direct \\ref-page : [string] inline-cmd\n\
direct \\figure : [inline-text; block-text] inline-cmd\n\
direct +p : [inline-text] block-cmd\n\
direct +pn : [inline-text] block-cmd\n\
direct +section : [string?; string?; inline-text; block-text] block-cmd\n\
direct +subsection : [string?; string?; inline-text; block-text] block-cmd\n\
direct \\emph : [inline-text] inline-cmd\n\
end = struct\n\
let default-config = default-config\n\
let document x c bt = bt\n\
let font-latin-roman = (`f`, 1., 0.)\n\
end",
);
}