sapphire-lang 0.8.0

Gradually typed scripting language where every value is an object and types are optional, checked at runtime
Documentation
#[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;
    }
    // If the error is past the end of the file (e.g. unexpected EOF), fall back
    // to the last line and point past its end so the caret is still meaningful.
    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>"),
        }
    }
}