voltaic-cli 0.1.0

The Voltaic compiler: .sdx in, .xrs containing ordinary Rust out.
use crate::run::usage;

use voltaic_driver::diagnose;

use std::ffi::OsString;

use std::path::Path;

use std::path::PathBuf;

pub(crate) fn build_command(args: &[String]) -> Result<(), String> {
  let parsed = parse_build_args(args)?;
  let shown = parsed.input.display().to_string();
  let source = std::fs::read_to_string(&parsed.input)
    .map_err(|e| format!("error: cannot read {}: {}", shown, e))?;

  let program = voltaic_parser::parse_source(&source).map_err(|failure| {
    voltaic_diag::render(&diagnose(&parsed.input, &source, &failure), parsed.plain)
  })?;
  let warnings = voltaic_driver::lint(&parsed.input, &source, Some(&program));
  if !warnings.is_empty() {
    let rendered = voltaic_diag::render(&warnings, parsed.plain);
    eprint!("{}", rendered);
  }

  let emitted = voltaic_emit::emit_with_map(&program, &source, &shown);

  let out_shown = parsed.output.display().to_string();
  std::fs::write(&parsed.output, &emitted.0)
    .map_err(|e| format!("error: cannot write {}: {}", out_shown, e))?;

  let sidecar = map_path(&parsed.output);
  let sidecar_shown = sidecar.display().to_string();
  std::fs::write(&sidecar, voltaic_diag::render_map(&emitted.1))
    .map_err(|e| format!("error: cannot write {}: {}", sidecar_shown, e))?;
  Ok(())
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BuildArgs {
  pub(crate) input: PathBuf,
  pub(crate) output: PathBuf,
  pub(crate) plain: bool,
}

pub(crate) fn default_output(input: &Path) -> PathBuf {
  if input.extension().is_some_and(|e| e == "sdx") {
    return input.with_extension("xrs");
  }
  let mut name = input.as_os_str().to_os_string();
  name.push(".xrs");
  PathBuf::from(name)
}

pub(crate) fn map_path(output: &Path) -> PathBuf {
  let mut name = output
    .file_name()
    .map_or_else(OsString::new, OsString::from);
  name.push(".map");
  output.with_file_name(name)
}

pub(crate) fn parse_build_args(args: &[String]) -> Result<BuildArgs, String> {
  let mut input: Option<PathBuf> = None;
  let mut output: Option<PathBuf> = None;
  let mut plain = false;
  let mut i = 0;
  while i < args.len() {
    match args[i].as_str() {
      "-o" | "--output" => {
        let path = args
          .get(i + 1)
          .ok_or_else(|| "error: -o requires a path".to_string())?;
        output = Some(PathBuf::from(path));
        i += 2;
      }
      "--plain" => {
        plain = true;
        i += 1;
      }
      flag if flag.starts_with('-') => {
        let help = usage();
        return Err(format!("error: unknown flag `{}`\n\n{}", flag, help));
      }
      path => {
        if input.is_some() {
          return Err(format!("error: unexpected second input `{}`", path));
        }
        input = Some(PathBuf::from(path));
        i += 1;
      }
    };
  }
  let Some(resolved) = input else {
    let help = usage();
    return Err(format!("error: no input file\n\n{}", help));
  };
  let out = output.unwrap_or_else(|| default_output(&resolved));
  Ok(BuildArgs {
    input: resolved,
    output: out,
    plain,
  })
}

#[cfg(test)]
mod __rdx_tests {
  use super::*;

  #[test]
  fn maps_sdx_to_xrs() {
    assert_eq!(
      default_output(Path::new("main.sdx")),
      PathBuf::from("main.xrs")
    );
    assert_eq!(
      default_output(Path::new("a/b/main.sdx")),
      PathBuf::from("a/b/main.xrs")
    );
  }

  #[test]
  fn appends_to_an_unexpected_extension_rather_than_replacing_it() {
    assert_eq!(
      default_output(Path::new("notes.txt")),
      PathBuf::from("notes.txt.xrs")
    );
  }

  #[test]
  fn appends_rather_than_replacing() {
    assert_eq!(map_path(Path::new("main.xrs")), Path::new("main.xrs.map"));
  }

  #[test]
  fn keeps_the_directory() {
    assert_eq!(
      map_path(Path::new("/tmp/out/main.xrs")),
      Path::new("/tmp/out/main.xrs.map")
    );
  }

  #[test]
  fn handles_a_name_with_no_extension() {
    assert_eq!(map_path(Path::new("out/main")), Path::new("out/main.map"));
  }

  #[test]
  fn rejects_an_unknown_flag() {
    let args = ["--nope".to_string()];
    let err = parse_build_args(&args).expect_err("should fail");
    assert!(err.contains("unknown flag"), "{}", err);
  }

  #[test]
  fn rejects_a_missing_input() {
    let empty: Vec<String> = Vec::new();
    let err = parse_build_args(&empty).expect_err("should fail");
    assert!(err.contains("no input file"), "{}", err);
  }

  #[test]
  fn rejects_a_second_input() {
    let args = ["a.sdx".to_string(), "b.sdx".to_string()];
    let err = parse_build_args(&args).expect_err("should fail");
    assert!(err.contains("second input"), "{}", err);
  }

  #[test]
  fn rejects_a_dangling_output_flag() {
    let args = ["-o".to_string()];
    let err = parse_build_args(&args).expect_err("should fail");
    assert!(err.contains("requires a path"), "{}", err);
  }

  #[test]
  fn accepts_the_plain_flag_before_or_after_the_input() {
    let before = ["--plain".to_string(), "a.sdx".to_string()];
    let after = ["a.sdx".to_string(), "--plain".to_string()];
    for args in [before, after] {
      let parsed = parse_build_args(&args).expect("should parse");
      assert!(parsed.plain);
      assert_eq!(parsed.input.to_str(), Some("a.sdx"));
    }
  }

  #[test]
  fn defaults_to_graphical_rendering() {
    let args = ["a.sdx".to_string()];
    let parsed = parse_build_args(&args).expect("should parse");
    assert!(!parsed.plain);
  }
}