#[cfg(feature = "cli")]
use rustyline::{DefaultEditor, error::ReadlineError};
use sapphire::{compiler, lexer, parser, token::TokenKind, typechecker, vm};
fn emit_error(message: &str, line: usize, column: usize, source: &str, path: &str) {
eprintln!("error: {}", message);
if line == 0 {
return;
}
let total_lines = source.lines().count();
let (show_line, show_col) = if line <= total_lines {
(line, column)
} else {
let last_len = source.lines().last().map(|l| l.len()).unwrap_or(0);
(total_lines.max(1), last_len + 1)
};
if show_col > 0 {
eprintln!(" --> {}:{}:{}", path, show_line, show_col);
} else {
eprintln!(" --> {}:{}", path, show_line);
}
if let Some(src_line) = source.lines().nth(show_line - 1) {
let ln = show_line.to_string();
let pad = " ".repeat(ln.len());
eprintln!("{} |", pad);
eprintln!("{} | {}", ln, src_line);
if show_col > 0 {
eprintln!("{} | {}^", pad, " ".repeat(show_col.saturating_sub(1)));
} else {
eprintln!("{} |", pad);
}
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.as_slice() {
[_, cmd, path, ..] if cmd == "run" => run_file(path),
[_, cmd, path] if cmd == "typecheck" => typecheck_file(path),
[_, cmd, path] if cmd == "test" => run_tests(path),
[_, cmd] if cmd == "test" => run_tests("."),
#[cfg(feature = "cli")]
[_, cmd] if cmd == "console" => run_repl(),
[_, cmd] if cmd == "version" => println!("sapphire {}", env!("CARGO_PKG_VERSION")),
_ => {
eprintln!("Usage: sapphire <command>\n");
eprintln!("Commands:");
eprintln!(" run <file.spr> Run a Sapphire file");
eprintln!(" typecheck <file.spr> Type-check a file");
eprintln!(" test [path] Run tests (file or directory)");
eprintln!(" console Start the REPL");
eprintln!(" version Print the version");
std::process::exit(1);
}
}
}
fn run_file(path: &str) {
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("error reading '{}': {}", path, e);
std::process::exit(1);
}
};
let tokens = lexer::Lexer::new(&source).scan_tokens();
let exprs = match parser::Parser::new(tokens).parse() {
Ok(s) => s,
Err(e) => {
display_parse_error(&e, &source, path);
std::process::exit(1);
}
};
if !report_type_errors(&exprs, &source, path) {
std::process::exit(1);
}
let func = match compiler::compile(&exprs) {
Ok(f) => f,
Err(e) => {
emit_error(&e.message, e.line as usize, e.column as usize, &source, path);
std::process::exit(1);
}
};
let current_dir = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(path))
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let mut vm = vm::Vm::new(func, current_dir);
if let Err(e) = vm.load_stdlib() {
eprintln!("stdlib error: {}", e);
std::process::exit(1);
}
if let Err(e) = vm.run() {
display_vm_error(&e, &source, path);
std::process::exit(1);
}
}
fn typecheck_file(path: &str) {
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("error reading '{}': {}", path, e);
std::process::exit(1);
}
};
let tokens = lexer::Lexer::new(&source).scan_tokens();
match parser::Parser::new(tokens).parse() {
Err(e) => {
display_parse_error(&e, &source, path);
std::process::exit(1);
}
Ok(exprs) => {
if report_type_errors(&exprs, &source, path) {
println!("No type errors found.");
} else {
std::process::exit(1);
}
}
}
}
fn display_parse_error(e: &sapphire::error::SapphireError, source: &str, path: &str) {
match e {
sapphire::error::SapphireError::ParseError { message, line, column } => {
emit_error(message, *line, *column, source, path);
}
other => eprintln!("{}", other),
}
}
fn display_vm_error(e: &vm::VmError, source: &str, path: &str) {
match e {
vm::VmError::TypeError { message, line } => {
emit_error(message, *line as usize, 0, source, path);
}
other => eprintln!("{}", other),
}
}
fn report_type_errors(exprs: &[sapphire::ast::Expr], source: &str, path: &str) -> bool {
let errors = typechecker::TypeChecker::check(exprs);
if errors.is_empty() {
true
} else {
for e in &errors {
emit_error(&e.message, e.line, 0, source, path);
}
false
}
}
fn collect_test_files(path: &str) -> Vec<std::path::PathBuf> {
let p = std::path::Path::new(path);
if p.is_file() {
return vec![p.to_path_buf()];
}
let mut files = Vec::new();
collect_test_files_recursive(p, &mut files);
files.sort();
files
}
fn collect_test_files_recursive(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_test_files_recursive(&path, out);
} else if path.extension().is_some_and(|e| e == "spr")
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("_test.spr"))
{
out.push(path);
}
}
}
fn run_tests(path: &str) {
let test_files = collect_test_files(path);
if test_files.is_empty() {
eprintln!("No test files found in '{}'", path);
std::process::exit(1);
}
let start_time = std::time::Instant::now();
let mut total = 0usize;
let mut failures: Vec<String> = Vec::new();
let mut dots = String::new();
for file_path in &test_files {
let source = match std::fs::read_to_string(file_path) {
Ok(s) => s,
Err(e) => {
eprintln!("error reading '{}': {}", file_path.display(), e);
std::process::exit(1);
}
};
let file_path_str = file_path.to_string_lossy();
let tokens = lexer::Lexer::new(&source).scan_tokens();
let exprs = match parser::Parser::new(tokens).parse() {
Ok(s) => s,
Err(e) => {
display_parse_error(&e, &source, &file_path_str);
std::process::exit(1);
}
};
if !report_type_errors(&exprs, &source, &file_path_str) {
std::process::exit(1);
}
let func = match compiler::compile(&exprs) {
Ok(f) => f,
Err(e) => {
emit_error(&e.message, e.line as usize, e.column as usize, &source, &file_path_str);
std::process::exit(1);
}
};
let current_dir = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf())
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let mut machine = vm::Vm::new(func, current_dir);
if let Err(e) = machine.load_stdlib() {
eprintln!("stdlib error: {}", e);
std::process::exit(1);
}
if let Err(e) = machine.run() {
display_vm_error(&e, &source, &file_path_str);
std::process::exit(1);
}
let test_classes = machine.collect_test_classes();
for (class_name, tests) in test_classes {
for (label, method) in &tests {
total += 1;
match machine.run_single_test(&class_name, method) {
Ok(()) => dots.push('.'),
Err(msg) => {
dots.push('F');
failures.push(format!(" {}#{} — {}", class_name, label, msg));
}
}
}
}
}
println!("{}\n", dots);
if !failures.is_empty() {
println!("Failures:");
for f in &failures {
println!("{}", f);
}
println!();
}
let elapsed = start_time.elapsed();
let elapsed_secs = elapsed.as_secs_f64();
let tests_per_sec = if elapsed_secs > 0.0 {
total as f64 / elapsed_secs
} else {
0.0
};
println!(
"{} {}, {} {} ({:.2}s, {:.0} tests/sec)",
total,
if total == 1 { "test" } else { "tests" },
failures.len(),
if failures.len() == 1 {
"failure"
} else {
"failures"
},
elapsed_secs,
tests_per_sec
);
if !failures.is_empty() {
std::process::exit(1);
}
}
#[cfg(feature = "cli")]
fn is_input_complete(source: &str) -> bool {
let tokens = lexer::Lexer::new(source).scan_tokens();
let mut depth: i32 = 0;
let mut begin_depth: i32 = 0;
for token in &tokens {
match &token.kind {
TokenKind::LeftBrace | TokenKind::LeftParen | TokenKind::LeftBracket => depth += 1,
TokenKind::RightBrace | TokenKind::RightParen | TokenKind::RightBracket => depth -= 1,
TokenKind::Begin => begin_depth += 1,
TokenKind::End => begin_depth -= 1,
_ => {}
}
}
depth <= 0 && begin_depth <= 0
}
#[cfg(feature = "cli")]
fn run_repl() {
println!(
"Sapphire {} — type quit, or press Ctrl+D to quit",
env!("CARGO_PKG_VERSION")
);
let mut vm = vm::Vm::new_repl();
if let Err(e) = vm.load_stdlib() {
eprintln!("stdlib error: {}", e);
std::process::exit(1);
}
let mut rl = DefaultEditor::new().expect("failed to create editor");
loop {
let first_line = match rl.readline("> ") {
Ok(line) => line,
Err(ReadlineError::Interrupted) => continue,
Err(ReadlineError::Eof) => {
println!();
break;
}
Err(e) => {
eprintln!("error: {}", e);
break;
}
};
if first_line.trim().is_empty() {
continue;
}
let mut source = first_line;
while !is_input_complete(&source) {
match rl.readline(".. ") {
Ok(line) => {
source.push('\n');
source.push_str(&line);
}
Err(_) => break,
}
}
rl.add_history_entry(&source).ok();
let trimmed = source.trim();
if trimmed == "quit" {
break;
}
let tokens = lexer::Lexer::new(trimmed).scan_tokens();
let exprs = match parser::Parser::new(tokens).parse() {
Err(e) => {
display_parse_error(&e, trimmed, "<repl>");
continue;
}
Ok(e) => e,
};
if !report_type_errors(&exprs, trimmed, "<repl>") {
continue;
}
let func = match compiler::compile_repl(&exprs) {
Err(e) => {
emit_error(&e.message, e.line as usize, e.column as usize, trimmed, "<repl>");
continue;
}
Ok(f) => f,
};
match vm.eval(func) {
Ok(Some(result)) if result.to_string() != "nil" => println!("{}", result),
Ok(_) => {}
Err(e) => display_vm_error(&e, trimmed, "<repl>"),
}
}
}