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(gave_up_on(&let_chain(30)).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 gave_up_on(src: &str) -> ParseFileError {
parse_within(src, 120)
}
#[test]
fn a_long_let_chain_terminates_and_says_it_gave_up() {
let e = parse_within(&let_chain(34), 120);
assert_eq!(e.kind, ParseFailureKind::GaveUp, "{e}");
assert!(e.render().starts_with("gave up:"), "{e}");
assert!(!e.render().contains("parse error"), "{e}");
assert_eq!(e.span.start.line, 36, "{e}");
}
#[test]
fn short_let_chains_still_get_a_real_verdict() {
for n in [3, 9, 15] {
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 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());
}
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);
}