zuzu-rust 0.4.0

Rust implementation of ZuzuScript
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use zuzu_rust::parse_program;

fn main() -> ExitCode {
    match run() {
        Ok(all_ok) => {
            if all_ok {
                ExitCode::SUCCESS
            } else {
                ExitCode::from(1)
            }
        }
        Err(message) => {
            eprintln!("{message}");
            ExitCode::from(2)
        }
    }
}

fn run() -> Result<bool, String> {
    let args: Vec<String> = env::args().skip(1).collect();
    if args
        .iter()
        .any(|arg| matches!(arg.as_str(), "-h" | "--help"))
    {
        print_help();
        return Ok(true);
    }

    let targets = parse_args(&args)?;
    if targets.is_empty() {
        return Err("usage: zuzu-rust-parse-files [options] <file-or-directory> [...]".to_owned());
    }

    let mut files = Vec::new();
    for target in &targets {
        collect_targets(target, &mut files)?;
    }
    files.sort();
    files.dedup();

    let mut all_ok = true;
    let mut passed = 0usize;
    let mut failed = 0usize;
    for file in files {
        let display = file.display().to_string();
        match fs::read_to_string(&file) {
            Ok(source) => match parse_program(&source) {
                Ok(_) => {
                    println!("{display}");
                    passed += 1;
                }
                Err(_) => {
                    println!("{display}");
                    failed += 1;
                    all_ok = false;
                }
            },
            Err(_) => {
                println!("{display}");
                failed += 1;
                all_ok = false;
            }
        }
    }

    if passed + failed > 1 {
        if failed == 0 {
            println!("✅  total: {passed} passed, {failed} failed");
        } else {
            println!("❌  total: {passed} passed, {failed} failed");
        }
    }

    Ok(all_ok)
}

fn print_help() {
    println!(
        "\
Usage: zuzu-rust-parse-files [options] <file-or-directory> [...]
Options:
  -d[=N]                 set debug level (accepted for CLI parity)
  -I/path/to/lib         add module include directory (accepted for CLI parity)
  --deny=CAP             deny runtime capability (accepted for CLI parity)
  --denymodule=MODULE    deny a specific module (accepted for CLI parity)
  -h, --help             show this help"
    );
}

fn parse_args(args: &[String]) -> Result<Vec<PathBuf>, String> {
    let mut targets = Vec::new();
    let mut index = 0;
    while index < args.len() {
        let arg = &args[index];
        match arg.as_str() {
            "--" => {
                targets.extend(args[index + 1..].iter().map(PathBuf::from));
                break;
            }
            "-I" => {
                index += 1;
                expect_arg(args, index, "-I")?;
            }
            "--deny" => {
                index += 1;
                let value = expect_arg(args, index, "--deny")?;
                let _ = split_csv(value);
            }
            "--denymodule" => {
                index += 1;
                let value = expect_arg(args, index, "--denymodule")?;
                let _ = split_csv(value);
            }
            "-d" => {
                let _ = parse_debug_level("")?;
            }
            _ if arg.starts_with("-I") && arg.len() > 2 => {}
            _ if arg.starts_with("-d=") => {
                let _ = parse_debug_level(&arg[3..])?;
            }
            _ if is_debug_short_option(arg) => {
                let _ = parse_debug_level(&arg[2..])?;
            }
            _ if arg.starts_with("--deny=") => {
                let _ = split_csv(&arg["--deny=".len()..]);
            }
            _ if arg.starts_with("--denymodule=") => {
                let _ = split_csv(&arg["--denymodule=".len()..]);
            }
            _ if arg.starts_with('-') => {
                return Err(format!("unsupported option: {arg}"));
            }
            _ => targets.push(PathBuf::from(arg)),
        }
        index += 1;
    }
    Ok(targets)
}

fn expect_arg<'a>(args: &'a [String], index: usize, option: &str) -> Result<&'a str, String> {
    args.get(index)
        .map(String::as_str)
        .ok_or_else(|| format!("expected value after {option}"))
}

fn split_csv(value: &str) -> Vec<String> {
    value
        .split(',')
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(str::to_owned)
        .collect()
}

fn is_debug_short_option(arg: &str) -> bool {
    let Some(value) = arg.strip_prefix("-d") else {
        return false;
    };
    !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit())
}

fn parse_debug_level(value: &str) -> Result<u32, String> {
    if value.is_empty() {
        return Ok(1);
    }
    value
        .parse::<u32>()
        .map_err(|_| "debug level must be a non-negative integer".to_owned())
}

fn collect_targets(path: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
    let metadata =
        fs::metadata(path).map_err(|err| format!("failed to stat {}: {err}", path.display()))?;

    if metadata.is_file() {
        if is_zuzu_file(path) {
            out.push(path.to_path_buf());
        }
        return Ok(());
    }

    if metadata.is_dir() {
        collect_dir(path, out)?;
        return Ok(());
    }

    Ok(())
}

fn collect_dir(path: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
    let entries = fs::read_dir(path)
        .map_err(|err| format!("failed to read directory {}: {err}", path.display()))?;

    for entry in entries {
        let entry = entry.map_err(|err| {
            format!(
                "failed to read directory entry in {}: {err}",
                path.display()
            )
        })?;
        let child = entry.path();
        let metadata = entry
            .metadata()
            .map_err(|err| format!("failed to stat {}: {err}", child.display()))?;

        if metadata.is_dir() {
            collect_dir(&child, out)?;
        } else if metadata.is_file() && is_zuzu_file(&child) {
            out.push(child);
        }
    }

    Ok(())
}

fn is_zuzu_file(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|ext| ext.to_str()),
        Some("zzs") | Some("zzm")
    )
}