use std::path::{Path, PathBuf};
use rustyfi_syntax::stream::{AtomStream, Budget};
use rustyfi_syntax::{ParseFailureKind, ParseFileError};
fn v006_error_on_line_5() -> &'static str {
"@require: stdjabook\n\
let a = 1 in\n\
let b = 2 in\n\
let c = 3 in\n\
let d = in\n\
document (| title = `t` |) '<\n\
\x20 +p { x }\n\
>\n"
}
fn v01_error_on_line_5() -> &'static str {
"@require: basic\n\
module M = struct\n\
\x20 val a = 1\n\
\x20 val b = 2\n\
\x20 val d = = 4\n\
\x20 val e = 5\n\
end\n"
}
const RUNS_OFF_THE_END_V1: &str = "@require: basic\n\
module M = struct\n\
\x20 val a = 1\n";
fn let_chain(n: usize) -> String {
let mut s = String::from("@require: stdjabook\n");
for i in 0..n {
s.push_str(&format!("let v{i} = {i} in\n"));
}
s.push_str("let bad = in\n");
s.push_str("document (| title = `t` |) '<\n +p { x }\n>\n");
s
}
fn err_of(src: &str) -> ParseFileError {
rustyfi_syntax::parse_file(src).expect_err("must not parse")
}
fn err_of_v1(src: &str) -> ParseFileError {
rustyfi_syntax::parse_file_v1(src).expect_err("must not parse")
}
#[test]
fn an_error_deep_in_a_0_0_6_document_reports_its_own_line() {
let e = err_of(v006_error_on_line_5());
assert_eq!(e.span.start.line, 5, "{e}");
assert_eq!(e.kind, ParseFailureKind::Syntax, "{e}");
}
#[test]
fn an_error_in_a_0_1_library_is_not_reported_on_the_module_head() {
let e = err_of_v1(v01_error_on_line_5());
assert_eq!(e.span.start.line, 5, "{e}");
assert_eq!(e.kind, ParseFailureKind::Syntax, "{e}");
}
#[test]
fn an_error_deep_in_a_0_1_document_reports_its_own_line() {
let src = "@require: basic\n\
let a = 1 in\n\
let b = 2 in\n\
let c = = 3 in\n\
a\n";
let e = err_of_v1(src);
assert_eq!(e.span.start.line, 4, "{e}");
}
#[test]
fn an_error_on_the_first_construct_still_reports_there() {
let src = "@require: stdjabook\nlet a = ] in\na\n";
let e = err_of(src);
assert_eq!(e.span.start.line, 2, "{e}");
assert_eq!(e.span.start.col, 8, "the `]` itself: {e}");
}
#[test]
fn no_message_contains_a_debug_dump() {
let mut messages: Vec<String> = vec![
err_of(v006_error_on_line_5()).to_string(),
err_of_v1(v01_error_on_line_5()).to_string(),
err_of("@require: stdjabook\nlet a = ] in\na\n").to_string(),
err_of("let x = `unterminated").to_string(),
err_of_v1("module M = struct\n val a = = 1\nend\n").to_string(),
err_of_v1(RUNS_OFF_THE_END_V1).to_string(),
];
messages.push(parse_with_budget(&let_chain(30), Budget::exactly(50)).to_string());
for m in &messages {
assert!(!m.contains("Loc {"), "Debug dump in: {m}");
assert!(!m.contains("Span {"), "Debug dump in: {m}");
assert!(
m.len() < 400,
"message is a wall of text ({} B): {m}",
m.len()
);
assert_eq!(m.lines().count(), 1, "message is not one line: {m}");
}
}
#[test]
fn the_message_says_something_about_the_failure() {
let e = err_of(v006_error_on_line_5());
assert!(
e.message.contains("expected") || e.message.contains("unexpected"),
"{e}"
);
let e = err_of_v1(v01_error_on_line_5());
assert!(
e.message.contains("expected") || e.message.contains("unexpected"),
"{e}"
);
}
#[test]
fn a_failure_at_end_of_input_names_the_token_that_would_have_finished_it() {
let e = err_of_v1(RUNS_OFF_THE_END_V1);
assert_eq!(e.kind, ParseFailureKind::Syntax, "{e}");
assert_eq!(e.message, "expected 'end'", "{e}");
}
#[test]
fn running_out_of_input_is_reported_as_running_out() {
let e = err_of("@require: stdjabook\nlet x = 1 in\nlet y =\n");
assert_eq!(e.kind, ParseFailureKind::Syntax, "{e}");
assert!(e.message.contains("end of input"), "{e}");
}
#[test]
fn a_lex_error_keeps_its_own_message_and_span() {
for (src, line) in [
("let x = `unterminated", 1),
("@require: stdjabook\nlet x = 1 in\nlet y = `oops\n", 3),
] {
let raw = rustyfi_syntax::lex(src).expect_err("must not lex");
let e = err_of(src);
assert_eq!(e.kind, ParseFailureKind::Lex, "{e}");
assert_eq!(e.span.start.line, line, "{e}");
assert_eq!(e.message, raw.msg, "{e}");
assert_eq!(e.span, raw.span, "{e}");
assert!(!e.message.is_empty(), "{e}");
}
}
#[test]
fn a_lex_error_is_a_lex_error_in_0_1_too() {
let e = err_of_v1("module M = struct\n val x = `oops\nend\n");
assert_eq!(e.kind, ParseFailureKind::Lex, "{e}");
assert_eq!(e.span.start.line, 2, "{e}");
}
fn parse_within(src: &str, secs: u64) -> ParseFileError {
let (tx, rx) = std::sync::mpsc::channel();
let owned = src.to_string();
std::thread::spawn(move || {
let _ = tx.send(rustyfi_syntax::parse_file(&owned).err());
});
match rx.recv_timeout(std::time::Duration::from_secs(secs)) {
Ok(Some(e)) => e,
Ok(None) => panic!("expected a parse failure"),
Err(_) => panic!("the parse did not terminate within {secs}s"),
}
}
fn parse_with_budget(src: &str, budget: Budget) -> ParseFileError {
use syan::parse::Parse;
let atoms = rustyfi_syntax::lex(src).expect("lexes");
let mut stream = AtomStream::with_budget(atoms, budget);
let err = <rustyfi_syntax::cst::File as Parse<_>>::parse(&mut stream)
.err()
.expect("must not parse");
rustyfi_syntax::parse_error::locate(src, &stream, &err)
}
fn serves(src: &str, v: rustyfi_syntax::RustyfiVersion) -> u64 {
use syan::parse::Parse;
let atoms = rustyfi_syntax::lex_with_version(src, v).expect("lexes");
let mut stream = AtomStream::with_budget(atoms, Budget::unlimited());
let _ = match v {
rustyfi_syntax::RustyfiVersion::V0_1 => {
<rustyfi_syntax::FileV1 as Parse<_>>::parse(&mut stream).is_ok()
}
_ => <rustyfi_syntax::cst::File as Parse<_>>::parse(&mut stream).is_ok(),
};
stream.served()
}
#[test]
fn a_long_let_chain_terminates_with_a_real_verdict() {
let e = parse_within(&let_chain(34), 120);
assert_eq!(e.kind, ParseFailureKind::Syntax, "{e}");
assert!(e.render().starts_with("parse error:"), "{e}");
assert!(!e.render().contains("gave up"), "{e}");
assert_eq!(e.span.start.line, 36, "{e}");
}
#[test]
fn a_very_long_broken_let_chain_still_gets_a_real_verdict() {
let e = parse_within(&let_chain(400), 60);
assert_eq!(e.kind, ParseFailureKind::Syntax, "{e}");
assert_eq!(e.span.start.line, 402, "{e}");
}
#[test]
fn let_chains_of_every_depth_get_a_real_verdict() {
for n in [3, 9, 15, 16, 40, 120] {
let src = let_chain(n);
let e = parse_within(&src, 60);
assert_eq!(e.kind, ParseFailureKind::Syntax, "chain of {n}: {e}");
assert_eq!(e.span.start.line, n as u32 + 2, "chain of {n}: {e}");
}
}
#[test]
fn one_more_let_costs_a_constant_number_of_serves() {
for v in [
rustyfi_syntax::RustyfiVersion::V0_0,
rustyfi_syntax::RustyfiVersion::V0_1,
] {
let at = |n: usize| serves(&let_chain(n), v);
let step = at(6) - at(3);
assert!(step > 0, "{v:?}: a longer chain must cost something");
for (a, b) in [(3, 6), (6, 9), (9, 12), (12, 15), (15, 18), (18, 21)] {
assert_eq!(
at(b) - at(a),
step,
"{v:?}: cost from {a} to {b} `let`s is not the constant {step}"
);
}
}
}
#[test]
fn an_exhausted_budget_is_still_reported_as_a_give_up() {
let e = parse_with_budget(&let_chain(34), Budget::exactly(50));
assert_eq!(e.kind, ParseFailureKind::GaveUp, "{e}");
assert!(e.render().starts_with("gave up:"), "{e}");
assert!(!e.render().contains("parse error"), "{e}");
}
#[test]
fn a_long_valid_let_chain_parses() {
let mut src = String::from("@require: stdjabook\n");
for i in 0..400 {
src.push_str(&format!("let v{i} = {i} in\n"));
}
src.push_str("document (| title = `t` |) '<\n +p { x }\n>\n");
assert!(rustyfi_syntax::parse_file(&src).is_ok());
}
#[test]
fn a_let_chain_with_one_missing_in_names_the_offending_let() {
let mut src = String::from("@require: stdjabook\n");
for i in 0..20 {
src.push_str(&format!("let v{i} = {i} in\n"));
}
src.push_str("let oops = 1\n");
src.push_str("let fine = 2 in\n");
src.push_str("document (| title = `t` |) '<\n +p { x }\n>\n");
let e = parse_within(&src, 60);
assert_eq!(e.kind, ParseFailureKind::Syntax, "{e}");
assert_eq!(e.span.start.line, 23, "{e}");
assert!(e.render().contains("let"), "{e}");
}
#[test]
fn every_let_target_shape_still_parses() {
let body = "document (| title = `t` |) '<\n +p { x }\n>\n";
for target in [
"x", "_", "(a, b)", "(a)", "[a; b]", "Some a", "None", "a :: rest", "x as y", "(a, b) as p", ] {
let src = format!("@require: stdjabook\nlet {target} = e in\n{body}");
assert!(
rustyfi_syntax::parse_file(&src).is_ok(),
"expression-level `let {target} = …` no longer parses"
);
let src = format!("@require: stdjabook\nlet {target} = e\nin\n{body}");
assert!(
rustyfi_syntax::parse_file(&src).is_ok(),
"top-level `let {target} = …` no longer parses"
);
}
}
#[test]
fn every_let_target_shape_still_parses_in_0_1() {
for target in ["x", "_", "(a, b)", "Some a", "None", "a :: rest", "x as y"] {
let src = format!("module M = struct\n val f = let {target} = e in 0\nend\n");
assert!(
rustyfi_syntax::parse_file_v1(&src).is_ok(),
"0.1 `let {target} = …` no longer parses"
);
}
}
#[test]
fn each_let_target_still_picks_the_variant_it_used_to() {
use rustyfi_syntax::cst;
let doc = "document (| title = `t` |) '<\n +p { x }\n>\n";
let f = rustyfi_syntax::parse_file(&format!(
"@require: stdjabook\nlet x = 1\nlet (a, b) = p\nin\n{doc}"
))
.expect("parses");
assert!(
matches!(f.prelude[0], cst::TopBinding::Let(_)),
"a bare-variable top-level `let` did not parse as `Let`"
);
assert!(
matches!(f.prelude[1], cst::TopBinding::LetPattern { .. }),
"a destructuring top-level `let` did not parse as `LetPattern`"
);
for (target, is_pattern) in [("x", false), ("(a, b)", true)] {
let f = rustyfi_syntax::parse_file(&format!(
"@require: stdjabook\nlet outer = 0 in\nlet {target} = 1 in\n{doc}"
))
.expect("parses");
let body = f.body.expect("has a body");
let got_pattern = matches!(body, cst::ast::Expr::LetPatternIn { .. });
assert!(
matches!(body, cst::ast::Expr::LetIn { .. }) || got_pattern,
"`let {target} = …` parsed as neither `let` variant"
);
assert_eq!(
got_pattern, is_pattern,
"`let {target} = …` picked the wrong `let` variant"
);
}
}
fn cost(src: &str, v: rustyfi_syntax::RustyfiVersion) -> Option<(u64, usize)> {
use syan::parse::Parse;
let atoms = rustyfi_syntax::lex_with_version(src, v).ok()?;
let n = atoms.len();
let mut stream = AtomStream::with_budget(atoms, Budget::unlimited());
let ok = match v {
rustyfi_syntax::RustyfiVersion::V0_1 => {
<rustyfi_syntax::cst_v1::FileV1 as Parse<_>>::parse(&mut stream).is_ok()
}
_ => <rustyfi_syntax::cst::File as Parse<_>>::parse(&mut stream).is_ok(),
};
ok.then(|| (stream.served(), n))
}
fn bundled(sub: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../lib-rustyfi")
.join(sub)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if matches!(
p.extension().and_then(|x| x.to_str()),
Some("saty" | "satyh" | "satyg")
) {
out.push(p);
}
}
}
#[test]
fn the_bundled_corpus_stays_far_under_the_per_atom_budget() {
let mut files: Vec<(PathBuf, rustyfi_syntax::RustyfiVersion)> = Vec::new();
for (sub, v) in [
("dist", rustyfi_syntax::RustyfiVersion::V0_0),
("dist-v01", rustyfi_syntax::RustyfiVersion::V0_1),
] {
let mut found = Vec::new();
walk(&bundled(sub), &mut found);
assert!(
found.len() > 20,
"{sub} is missing — is the checkout complete?"
);
found.sort();
files.extend(found.into_iter().map(|p| (p, v)));
}
let mut worst = (0f64, String::new(), 0u64, 0usize);
let mut measured = 0usize;
for (f, v) in &files {
let Ok(src) = std::fs::read_to_string(f) else {
continue;
};
let Some((served, atoms)) = cost(&src, *v) else {
continue;
};
measured += 1;
let ratio = served as f64 / atoms.max(1) as f64;
if ratio > worst.0 {
worst = (ratio, f.display().to_string(), served, atoms);
}
}
assert!(measured > 40, "only {measured} bundled files parsed");
eprintln!(
"worst serves/atom over {measured} bundled files: {:.1} ({} serves, {} atoms) in {}",
worst.0, worst.2, worst.3, worst.1
);
let ceiling = Budget::PER_ATOM as f64 / 10.0;
assert!(
worst.0 < ceiling,
"{} costs {:.1} serves/atom, over a tenth of Budget::PER_ATOM ({})",
worst.1,
worst.0,
Budget::PER_ATOM
);
}
#[test]
fn the_budget_scales_with_the_input() {
assert_eq!(Budget::for_atoms(0).serves(), Budget::FLOOR);
let big = Budget::for_atoms(100_000).serves();
assert_eq!(big, 100_000 * Budget::PER_ATOM);
assert!(big > Budget::FLOOR);
let _ = Budget::for_atoms(usize::MAX);
}