use std::path::{Path, PathBuf};
use std::process::ExitCode;
use tatara_lisp::{read_spanned, Spanned, SpannedForm};
use tatara_lisp_eval::{Interpreter, Value};
use tatara_lisp_script::{install_stdlib, ScriptCtx};
use tatara_lisp_source::{FileCache, Resolver, Source};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("--repl") => run_repl(args[1..].to_vec()),
Some("--test") => {
if let Some(path) = args.get(1) {
run_test_mode(path, args[2..].to_vec())
} else {
eprintln!("usage: tatara-script --test <script.tlisp>");
ExitCode::from(2)
}
}
Some("--help" | "-h") => {
print_help();
ExitCode::SUCCESS
}
Some("lint") => run_lint(&args[1..]),
Some(path) if path.starts_with("--") => {
eprintln!("tatara-script: unknown flag {path:?}; see --help");
ExitCode::from(2)
}
Some(path) => run_script(path, args[1..].to_vec()),
None => {
print_help();
ExitCode::from(2)
}
}
}
fn print_help() {
eprintln!(
"tatara-script — pleme-io Lisp scripting\n\
\n\
Usage:\n \
tatara-script <path-or-url> [arg ...] run a script\n \
tatara-script --test <path-or-url> collect + run (deftest …) forms\n \
tatara-script lint [path ...] semantic lint (.tlisp); no paths = walk cwd\n \
tatara-script --repl interactive read-eval-print loop\n \
tatara-script --help this banner\n\
\n\
<path-or-url> can be:\n \
./local/path.tlisp file path\n \
github:owner/repo/path/...[?ref=tag] GitHub source\n \
gitlab:owner/repo/path[?ref=main] GitLab source\n \
codeberg:owner/repo/path Codeberg source\n \
https://example.com/...[#blake3=hex] direct fetch + optional pin\n\
\n\
URLs cache at ~/.cache/tatara/sources keyed by BLAKE3.\n\
See the tatara-lisp-script crate stdlib docs for the full primitive list."
);
}
fn resolve_input(input: &str) -> Result<(String, PathBuf), String> {
let source = Source::parse(input).map_err(|e| format!("parse source {input:?}: {e}"))?;
if let Source::Local { path } = &source {
let bytes =
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
return Ok((bytes, path.clone()));
}
let cache_root = dirs_cache_root().join("tatara").join("sources");
let cache = FileCache::new(&cache_root)
.map_err(|e| format!("open cache {}: {e}", cache_root.display()))?;
let mut resolver = Resolver::new(cache);
let resolved = resolver
.resolve_source(&source)
.map_err(|e| format!("{e}"))?;
let text = String::from_utf8(resolved.bytes).map_err(|e| format!("source not utf-8: {e}"))?;
let pseudo = cache_root
.join("rendered")
.join(format!("{}.tlisp", resolved.blake3));
Ok((text, pseudo))
}
fn dirs_cache_root() -> PathBuf {
if let Ok(s) = std::env::var("XDG_CACHE_HOME") {
return PathBuf::from(s);
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".cache");
}
std::env::temp_dir()
}
fn install_canonical_loader(interp: &mut Interpreter<ScriptCtx>, script_path: &Path) {
let base = script_path
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let mut search_paths: Vec<PathBuf> = Vec::new();
if let Ok(extra) = std::env::var("TATARA_PATH") {
for s in extra.split(':') {
if !s.is_empty() {
search_paths.push(PathBuf::from(s));
}
}
}
let loader = tatara_lisp_eval::FilesystemLoader::new(base).with_search_paths(search_paths);
interp.set_loader(std::sync::Arc::new(loader));
}
fn run_lint(args: &[String]) -> ExitCode {
let warn_only = args.iter().any(|a| a == "--warn");
let explicit: Vec<PathBuf> = args
.iter()
.filter(|a| !a.starts_with("--"))
.map(PathBuf::from)
.collect();
let mut files = Vec::new();
if explicit.is_empty() {
collect_tlisp_files(Path::new("."), &mut files);
} else {
for path in &explicit {
if path.is_dir() {
collect_tlisp_files(path, &mut files);
} else {
files.push(path.clone());
}
}
}
files.sort();
files.dedup();
let rules = tatara_lisp_lint::default_rules();
let mut violations = 0usize;
let mut errors = 0usize;
let mut scanned = 0usize;
for file in &files {
let src = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("tatara-script lint: read {}: {e}", file.display());
errors += 1;
continue;
}
};
scanned += 1;
match tatara_lisp_lint::lint_source(&src, &rules) {
Ok(found) => {
for v in found {
violations += 1;
println!(
"{}:{}:{}: [{}] {}",
file.display(),
v.line,
v.col,
v.rule,
v.message
);
}
}
Err(e) => {
eprintln!("{}: parse error: {e}", file.display());
errors += 1;
}
}
}
eprintln!(
"tatara-script lint: {scanned} file(s) scanned, {violations} violation(s), {errors} parse error(s)"
);
if (violations > 0 || errors > 0) && !warn_only {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn collect_tlisp_files(dir: &Path, acc: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if matches!(name.as_ref(), ".git" | "target" | "node_modules" | ".direnv") {
continue;
}
collect_tlisp_files(&path, acc);
} else if path.extension().is_some_and(|e| e == "tlisp") {
acc.push(path);
}
}
}
fn run_script(script_path: &str, rest: Vec<String>) -> ExitCode {
let mut interp: Interpreter<ScriptCtx> = Interpreter::new();
let mut ctx = ScriptCtx::with_argv(rest);
install_stdlib(&mut interp, &mut ctx);
let (raw_src, path) = match resolve_input(script_path) {
Ok(r) => r,
Err(e) => {
eprintln!("tatara-script: {e}");
return ExitCode::from(2);
}
};
install_canonical_loader(&mut interp, &path);
ctx.current_file = Some(path.clone());
let src = if raw_src.starts_with("#!") {
let newline = raw_src.find('\n').map_or(raw_src.len(), |i| i + 1);
let mut s = String::with_capacity(raw_src.len());
s.push('\n');
s.push_str(&raw_src[newline..]);
s
} else {
raw_src
};
let forms = match read_spanned(&src) {
Ok(f) => f,
Err(e) => {
eprintln!("tatara-script: parse error in {script_path}: {e:?}");
return ExitCode::from(1);
}
};
match eval_forms_with_require(&mut interp, &src, &forms, &mut ctx, &path) {
Ok(_) => ExitCode::SUCCESS,
Err(msg) => {
eprintln!("tatara-script: {msg}");
ExitCode::from(1)
}
}
}
fn run_test_mode(script_path: &str, rest: Vec<String>) -> ExitCode {
let mut interp: Interpreter<ScriptCtx> = Interpreter::new();
let mut ctx = ScriptCtx::with_argv(rest);
install_stdlib(&mut interp, &mut ctx);
let (src, path) = match resolve_input(script_path) {
Ok(r) => r,
Err(e) => {
eprintln!("tatara-script --test: {e}");
return ExitCode::from(2);
}
};
install_canonical_loader(&mut interp, &path);
ctx.current_file = Some(path.clone());
let forms = match read_spanned(&src) {
Ok(f) => f,
Err(e) => {
eprintln!("tatara-script --test: parse error in {script_path}: {e:?}");
return ExitCode::from(1);
}
};
if let Err(msg) = eval_forms_with_require(&mut interp, &src, &forms, &mut ctx, &path) {
eprintln!("tatara-script --test: top-level error: {msg}");
return ExitCode::from(1);
}
let tests = std::mem::take(&mut ctx.tests);
if tests.is_empty() {
eprintln!("tatara-script --test: no (deftest …) forms found in {script_path}");
return ExitCode::from(2);
}
let total = tests.len();
let mut passed = 0;
for test in tests {
match interp.eval_program(&test.body, &mut ctx) {
Ok(_) => {
println!(" ✓ {}", test.name);
passed += 1;
}
Err(e) => {
eprintln!(" ✘ {}", test.name);
for line in e.render(&src).lines() {
eprintln!(" {line}");
}
}
}
}
println!("\n{passed}/{total} passed");
if passed == total {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn run_repl(_rest: Vec<String>) -> ExitCode {
use std::io::{BufRead, Write};
let mut interp: Interpreter<ScriptCtx> = Interpreter::new();
let mut ctx = ScriptCtx::with_argv(Vec::<String>::new());
install_stdlib(&mut interp, &mut ctx);
eprintln!("tatara-script REPL — ^D to exit");
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut buffer = String::new();
loop {
buffer.clear();
print!("λ ");
stdout.flush().ok();
loop {
let mut line = String::new();
let n = match stdin.lock().read_line(&mut line) {
Ok(n) => n,
Err(_) => return ExitCode::SUCCESS,
};
if n == 0 {
println!();
return ExitCode::SUCCESS;
}
buffer.push_str(&line);
if parens_balanced(&buffer) {
break;
}
print!("… ");
stdout.flush().ok();
}
if buffer.trim().is_empty() {
continue;
}
match read_spanned(&buffer) {
Ok(forms) => {
for form in &forms {
match interp.eval_spanned(form, &mut ctx) {
Ok(v) => println!("{}", render_value(&v)),
Err(e) => eprintln!("{}", e.render(&buffer)),
}
}
}
Err(e) => eprintln!("parse error: {e:?}"),
}
}
}
fn eval_forms_with_require(
interp: &mut Interpreter<ScriptCtx>,
src: &str,
forms: &[Spanned],
ctx: &mut ScriptCtx,
current: &std::path::Path,
) -> Result<Value, String> {
let prior_file = ctx.current_file.replace(current.to_path_buf());
let mut last = Value::Nil;
for form in forms {
last = dispatch_top_form(interp, src, form, ctx)?;
}
ctx.current_file = prior_file;
Ok(last)
}
fn dispatch_top_form(
interp: &mut Interpreter<ScriptCtx>,
src: &str,
form: &Spanned,
ctx: &mut ScriptCtx,
) -> Result<Value, String> {
if let SpannedForm::List(items) = &form.form {
if let Some(head) = items.first().and_then(Spanned::as_symbol) {
if head == "deftest" {
return dispatch_deftest(items, ctx);
}
}
}
interp.eval_top_form(form, ctx).map_err(|e| e.render(src))
}
fn dispatch_deftest(items: &[Spanned], ctx: &mut ScriptCtx) -> Result<Value, String> {
if items.len() < 3 {
return Err("deftest: expected (deftest NAME BODY …)".to_string());
}
let name = match &items[1].form {
SpannedForm::Atom(tatara_lisp::Atom::Str(s)) => s.as_str().to_string(),
SpannedForm::Atom(tatara_lisp::Atom::Symbol(s)) => s.as_str().to_string(),
_ => return Err("deftest: NAME must be a string or symbol".to_string()),
};
ctx.tests.push(tatara_lisp_script::script_ctx::TestCase {
name,
body: items[2..].to_vec(),
});
Ok(Value::Nil)
}
fn parens_balanced(s: &str) -> bool {
let mut depth: i32 = 0;
let mut in_str = false;
let mut escape = false;
for c in s.chars() {
if escape {
escape = false;
continue;
}
if in_str {
if c == '\\' {
escape = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => in_str = true,
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
';' => {
break;
}
_ => {}
}
}
depth <= 0
}
fn render_value(v: &Value) -> String {
match v {
Value::Nil => "nil".to_string(),
Value::Bool(b) => b.to_string(),
Value::Int(n) => n.to_string(),
Value::Float(n) => n.to_string(),
Value::Str(s) => format!("{:?}", s.as_ref()),
Value::Symbol(s) => s.as_ref().to_string(),
Value::Keyword(s) => format!(":{}", s.as_ref()),
Value::List(xs) => {
let parts: Vec<String> = xs.iter().map(render_value).collect();
format!("({})", parts.join(" "))
}
other => format!("{other:?}"),
}
}