use tatara_lisp::Sexp;
use tatara_lisp_eval::Value;
use crate::erase::erase_types;
use crate::inputs::Inputs;
use crate::uses::Entry;
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error("parse error: {0}")]
Parse(String),
#[error("{} type error(s):\n{}", .0.len(), .0.join("\n"))]
Types(Vec<String>),
#[error("the emitted tatara-lisp could not be read back: {0}")]
Lower(String),
#[error("runtime error: {0}")]
Eval(String),
#[error("import error: {0}")]
Import(String),
}
#[derive(Debug)]
pub struct Run {
pub value: Value,
pub visited: usize,
pub typed_decls: usize,
pub seams: usize,
}
pub fn parse(src: &str) -> Result<Vec<Sexp>, RunError> {
parse_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
}
pub fn parse_with_depth(src: &str, max_depth: usize) -> Result<Vec<Sexp>, RunError> {
blue_lang_syntax::parse_program_with_depth(src, max_depth)
.map_err(|e| RunError::Parse(e.to_string()))
}
pub fn parse_tree_with_depth(
src: &str,
max_depth: usize,
) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
blue_lang_syntax::parse_program_tree_with_depth(src, max_depth)
.map_err(|e| RunError::Parse(e.to_string()))
}
pub fn parse_tree(src: &str) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
parse_tree_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
}
pub fn run(src: &str) -> Result<Run, RunError> {
run_with_inputs(src, Inputs::new())
}
pub fn run_with_inputs(src: &str, inputs: Inputs) -> Result<Run, RunError> {
run_with_loader(src, inputs, &crate::uses::NoLoader)
}
pub fn run_with_loader(
src: &str,
inputs: Inputs,
loader: &dyn crate::uses::Loader,
) -> Result<Run, RunError> {
run_in_surface(Entry::anonymous(src), inputs, loader, None)
}
pub fn run_in_surface(
entry: Entry<'_>,
inputs: Inputs,
loader: &dyn crate::uses::Loader,
surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
) -> Result<Run, RunError> {
let forms = match surface {
Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
.map_err(|e| RunError::Parse(e.to_string()))?,
None => parse_tree(entry.text)?,
};
let mut program = crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)?;
program.retain(|f| !crate::uses::is_test_form(f));
let outcome = blue_lang_check::check_program(program.forms());
if !outcome.ok() {
return Err(RunError::Types(
outcome
.diagnostics
.iter()
.map(|d| program.locate(d.top_level, d.span, &d.message).to_string())
.collect(),
));
}
let erased = erase_types(program.forms());
let mut interp = crate::interpreter_hostless();
crate::inputs::install_input_primitives(&mut interp, inputs);
let mut value = Value::Nil;
for (top_level, form) in erased.iter().enumerate() {
value = interp.eval_top_form(form, &mut ()).map_err(|e| {
let at = e.span().unwrap_or_else(tatara_lisp::Span::synthetic);
let message = e.short_message();
RunError::Eval(program.locate(top_level, at, &message).to_string())
})?;
}
Ok(Run {
value,
visited: outcome.stats.visited,
typed_decls: outcome.stats.typed_decls,
seams: outcome.seams.len(),
})
}
#[cfg(test)]
mod position_tests {
use super::*;
use crate::uses::{Entry, Loader};
use std::collections::BTreeMap;
use std::path::Path;
struct MemLoader(BTreeMap<&'static str, &'static str>);
impl Loader for MemLoader {
fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
self.0
.get(name)
.map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
.ok_or_else(|| format!("no bidama named \"{name}\""))
}
}
const BAKUHATSU: &str = "\
def tsukawanai_a()
1
end
def tsukawanai_b()
2
end
def bakuhatsu()
kore_wa_sonzai_shinai()
end
bakuhatsu()";
const KOTAE: &str = "def kotae()\n 42\nend";
const BAKUHATSU2: &str = "def yobu()\n kore_wa_sonzai_shinai()\nend";
fn loader() -> MemLoader {
MemLoader(BTreeMap::from([
("kotae", KOTAE),
("bakuhatsu", BAKUHATSU),
("bakuhatsu2", BAKUHATSU2),
]))
}
fn run_named(path: &str, src: &str) -> RunError {
run_in_surface(
Entry {
path: Some(Path::new(path)),
text: src,
},
Inputs::new(),
&loader(),
None,
)
.expect_err("the fixture must raise")
}
#[test]
fn a_raise_inside_an_imported_package_names_that_package() {
let err = run_named("entry.b", "use(\"kotae\")\nuse(\"bakuhatsu\")\n");
assert_eq!(
err.to_string(),
"runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
);
}
#[test]
fn line_10_column_3_of_the_fixture_is_the_failing_call() {
let line = BAKUHATSU.lines().nth(9).expect("line 10 exists");
assert_eq!(line, " kore_wa_sonzai_shinai()");
assert_eq!(
&line[2..],
"kore_wa_sonzai_shinai()",
"column 3 (1-indexed) is where the call starts"
);
let offset = BAKUHATSU.find("kore_wa_sonzai_shinai").expect("in fixture");
assert!(
offset > KOTAE.len(),
"kotae.b could answer for byte {offset}"
);
assert!(
offset > "use(\"kotae\")\nuse(\"bakuhatsu\")\n".len(),
"the entry file could answer for byte {offset}"
);
}
#[test]
fn a_raise_in_the_entry_file_names_the_entry_file() {
let err = run_named("honmono.b", "def f()\n 1\nend\n\nnani_mo_nai()\n");
assert_eq!(
err.to_string(),
"runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
);
}
#[test]
fn a_raise_inside_an_annotated_def_still_names_its_line() {
let err = run_named(
"chuu.b",
"def f(a: Int) -> Int\n mada_nai(a)\nend\n\nf(1)\n",
);
assert_eq!(
err.to_string(),
"runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
);
}
#[test]
fn a_raise_in_a_callee_from_another_file_reports_no_position_rather_than_a_wrong_one() {
let err = run_named("yobidashi.b", "use(\"bakuhatsu2\")\nyobu()\n");
assert_eq!(
err.to_string(),
"runtime error: yobidashi.b: unbound symbol `kore_wa_sonzai_shinai`",
"the caller's file is named (the stated limit) but NOT with a \
position it cannot justify"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn int(src: &str) -> i64 {
match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
Value::Int(v) => v,
other => panic!("{src:?} produced {other:?}"),
}
}
#[test]
fn annotating_buys_analysis_and_changes_nothing_else() {
let plain = run("def add(a, b)\n a + b\nend\nadd(2, 3)").expect("plain");
let typed = run("def add(a: Int, b: Int) -> Int\n a + b\nend\nadd(2, 3)").expect("typed");
assert!(matches!(plain.value, Value::Int(5)));
assert!(
matches!(typed.value, Value::Int(5)),
"the annotated program must compute the same answer"
);
assert_eq!(plain.visited, 0, "no annotations means no analysis");
assert!(
typed.visited > 0,
"an annotation must actually buy analysis, not just decorate"
);
assert_eq!(plain.typed_decls, 0);
assert_eq!(typed.typed_decls, 1);
}
#[test]
fn a_type_error_is_reported_and_the_program_does_not_run() {
let err = run("def add(a: Int, b: Int) -> Str\n a + b\nend\nadd(1, 2)")
.expect_err("a declared Str return from an Int body must be rejected");
assert!(
matches!(err, RunError::Types(ref d) if !d.is_empty()),
"expected type diagnostics, got {err}"
);
}
#[test]
fn the_same_program_without_annotations_runs() {
assert_eq!(int("def add(a, b)\n a + b\nend\nadd(1, 2)"), 3);
}
#[test]
fn a_parse_error_is_reported_as_one() {
assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
}
#[test]
fn a_runtime_error_is_reported_as_one() {
let err = run("no_such_function(1)").expect_err("unbound");
assert!(matches!(err, RunError::Eval(_)), "got {err}");
}
#[test]
fn the_pipeline_reaches_both_runtime_layers() {
assert_eq!(int("6 % 3"), 0);
assert_eq!(int("7 % 3"), 1);
assert_eq!(int("2 + 3 * 4"), 14);
}
#[test]
fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
let corpus = [
"def add(a, b)\n a + b\nend\nadd(2, 3)",
"def fact(n)\n if n < 2\n 1\n else\n n * fact(n - 1)\n end\nend\nfact(5)",
"def f(a, b)\n c = a + b\n c * 2\nend\nf(1, 2)",
"defmacro sq(e)\n quote\n unquote(e) * unquote(e)\n end\nend\nsq(2 + 3)",
"\"a string with spaces, a ( and a )\"",
"def g(a: Int) -> Int\n a + 1\nend\ng(1)",
"6 % 3",
"1.5 + 2.25",
];
for src in corpus {
let erased = crate::to_sexps(&erase_types(&parse_tree(src).expect("parse")));
let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
.iter()
.map(tatara_lisp::Spanned::to_sexp)
.collect();
assert_eq!(direct, erased, "the direct lowering must be the identity");
let text = erased
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
.unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
.iter()
.map(tatara_lisp::Spanned::to_sexp)
.collect();
assert_eq!(
round_tripped, erased,
"{src:?}: the old print-and-reparse path changed the tree"
);
}
}
#[test]
fn the_round_trip_is_not_the_identity_in_general() {
let tree = Sexp::List(vec![
Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
]);
let text = tree.to_string();
let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
.expect("it reads back cleanly — that IS the problem")
.iter()
.map(tatara_lisp::Spanned::to_sexp)
.collect();
assert_ne!(
back,
vec![tree.clone()],
"if print-then-read became inverse over symbols, the class would be \
closed upstream and this test should be deleted rather than relaxed"
);
let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
.iter()
.map(tatara_lisp::Spanned::to_sexp)
.collect();
assert_eq!(direct, vec![tree]);
}
}
#[cfg(test)]
mod macro_tests {
use super::*;
fn int(src: &str) -> i64 {
match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
Value::Int(v) => v,
other => panic!("{src:?} produced {other:?}"),
}
}
#[test]
fn a_macro_expands_and_runs() {
assert_eq!(
int("defmacro double(x)\n quote\n unquote(x) + unquote(x)\n end\nend\ndouble(21)"),
42
);
}
#[test]
fn a_macro_operates_on_syntax_not_values() {
assert_eq!(
int("defmacro sq(e)\n quote\n unquote(e) * unquote(e)\n end\nend\nsq(2 + 3)"),
25,
"the argument form `2 + 3` must be substituted twice"
);
}
#[test]
fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
let err =
run("defmacro forever(x)\n quote\n forever(unquote(x))\n end\nend\nforever(1)")
.expect_err("a self-referential macro must be rejected");
let msg = err.to_string();
assert!(
msg.contains("forever") && msg.contains("expansion"),
"the error must name the macro and the limit it hit: {msg}"
);
assert!(
msg.contains("<anonymous>:6:1"),
"and must place the call that ran away — line 6 is `forever(1)`: {msg}"
);
}
}
#[cfg(test)]
mod input_tests {
use super::*;
use crate::inputs::{Declaration, Inputs};
const SCHEMA: &[u8] = b"3";
fn with_schema(src: &str) -> Result<Run, RunError> {
let hash = Inputs::hash_of(SCHEMA);
let mut inputs = Inputs::new();
inputs
.bind(
&Declaration {
name: "schema".to_string(),
hash,
},
SCHEMA.to_vec(),
)
.expect("bind");
run_with_inputs(src, inputs)
}
fn decl_line() -> String {
let mut s = String::from("definput(\"schema\", \"");
s.push_str(&Inputs::hash_of(SCHEMA));
s.push_str("\")\n");
s
}
#[test]
fn a_macro_can_read_a_declared_build_input() {
let src = decl_line() + "input(\"schema\")";
let out = with_schema(&src).expect("run");
assert!(
matches!(out.value, Value::Str(ref s) if &**s == "3"),
"got {:?}",
out.value
);
}
#[test]
fn an_undeclared_input_is_an_error() {
let err = with_schema("input(\"not_declared\")").expect_err("must fail");
let msg = err.to_string();
assert!(msg.contains("not_declared"), "must name it: {msg}");
assert!(msg.contains("definput"), "and say how to declare it: {msg}");
}
#[cfg(not(feature = "sys"))]
#[test]
fn there_is_no_ambient_file_read() {
for attempt in [
"read_file(\"/etc/passwd\")",
"File(\"/etc/passwd\")",
"slurp(\"/etc/passwd\")",
"open(\"/etc/passwd\")",
] {
let err = with_schema(attempt).expect_err("must not resolve");
assert!(
err.to_string().contains("unbound"),
"{attempt} must be UNBOUND — a capability removed by absence, \
not guarded by a check: {err}"
);
}
}
#[cfg(feature = "sys")]
#[test]
fn sys_read_file_is_the_trusted_cli_only_exception() {
let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
assert!(err.to_string().contains("unbound"), "{err}");
assert!(
with_schema("read_file(\"/etc/passwd\")").is_ok(),
"with `sys` on, read_file is the trusted CLI surface"
);
let out = with_schema("input(\"schema\")").expect("run");
assert!(
matches!(out.value, Value::Str(ref s) if &**s == "3"),
"input() still binds beside the sys surface: {:?}",
out.value
);
}
#[test]
fn a_declared_input_with_no_bytes_supplied_fails() {
let src = decl_line() + "input(\"schema\")";
assert!(run(&src).is_err(), "no bytes were supplied");
}
}
#[cfg(test)]
mod tier2_tests {
use super::*;
use crate::inputs::{Declaration, Inputs};
#[test]
fn a_macro_generates_code_from_a_schema() {
let schema = b"7";
let mut inputs = Inputs::new();
inputs
.bind(
&Declaration {
name: "arity".to_string(),
hash: Inputs::hash_of(schema),
},
schema.to_vec(),
)
.expect("bind");
let mut src = String::from("definput(\"arity\", \"");
src.push_str(&Inputs::hash_of(schema));
src.push_str("\")\n");
src.push_str(
"defmacro from_schema()\n quote\n unquote(to_int(input(\"arity\")))\n end\nend\n\
from_schema() * 6",
);
let out = run_with_inputs(&src, inputs).expect("run");
assert!(
matches!(out.value, Value::Int(42)),
"the schema's 7 must reach the generated code: got {:?}",
out.value
);
}
}