voltaic-driver 0.2.0

Incremental compilation driver for the Voltaic compiler.
Documentation
use crate::remap;

use crate::remap::RustcMessage;

use voltaic_diag::Diagnostic;

use voltaic_diag::DiagnosticCode;

use voltaic_parser::ParseFailure;

use std::path::Path;

use std::path::PathBuf;

use std::process::Command;

pub fn check(path: &Path, source: &str) -> Result<CheckOutcome, String> {
  let vlt_path = path.display().to_string();

  let mut program = match voltaic_parser::parse_source(source) {
    Ok(parsed) => parsed,
    Err(failure) => {
      return Ok(CheckOutcome {
        diagnostics: diagnose(path, source, &failure),
        failed: true,
      });
    }
  };

  let analysis = crate::annotate::analyse_at(&mut program, path);
  if !analysis.open.is_empty() {
    return Ok(CheckOutcome {
      diagnostics: crate::annotate::refusals(path, source, analysis.open.as_slice()),
      failed: true,
    });
  }

  let mut diagnostics = crate::lint(path, source, Some(&program));

  let scope = voltaic_emit::MethodScope::enter(voltaic_emit::MethodTable::from_spans(
    analysis.methods.as_slice(),
  ));
  let emitted = voltaic_emit::emit_with_map(&program, source, &vlt_path);
  drop(scope);

  let rustc = rustc_path().ok_or_else(|| "error: no rustc found. `voltaic check` compiles the generated Rust, so it needs the Rust toolchain on PATH.".to_string())?;

  let dir = ScratchDir::new("check")?;
  let vrs = dir.path().join("check.vrs");
  let vrs_path = vrs.display().to_string();
  std::fs::write(&vrs, &emitted.0)
    .map_err(|e| format!("error: cannot write {}: {}", vrs_path, e))?;

  let rustc_shown = rustc.display().to_string();
  let invoked = Command::new(&rustc)
    .args([
      "--edition",
      "2024",
      "--error-format=short",
      "--emit=metadata",
    ])
    .arg("-o")
    .arg(dir.path().join("check.rmeta"))
    .arg(&vrs)
    .output();
  let out = invoked.map_err(|e| format!("error: cannot run {}: {}", rustc_shown, e))?;

  let stderr = String::from_utf8_lossy(&out.stderr);
  let messages: Vec<RustcMessage> = stderr.lines().filter_map(RustcMessage::parse).collect();
  let failed = messages.iter().any(RustcMessage::is_error);
  let remapped = messages
    .iter()
    .map(|m| remap(m, &emitted.1, &vlt_path, source, &vrs_path, &emitted.0));
  diagnostics.extend(remapped);

  if messages.is_empty() && !out.status.success() {
    let trimmed = stderr.trim_end();
    return Err(format!("error: rustc failed:\n{}", trimmed));
  }

  Ok(CheckOutcome {
    diagnostics,
    failed,
  })
}

#[derive(Debug, Clone, Default)]
pub struct CheckOutcome {
  pub diagnostics: Vec<Diagnostic>,
  pub failed: bool,
}

pub(crate) struct ScratchDir(PathBuf);

impl ScratchDir {
  pub(crate) fn new(tag: &str) -> Result<Self, String> {
    let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let pid = std::process::id();
    let dir = std::env::temp_dir().join(format!("voltaic-{}-{}-{}", tag, pid, n));
    let shown = dir.display().to_string();
    std::fs::create_dir_all(&dir).map_err(|e| format!("error: cannot create {}: {}", shown, e))?;
    Ok(ScratchDir(dir))
  }

  pub(crate) fn path(&self) -> &Path {
    &self.0
  }
}

impl Drop for ScratchDir {
  fn drop(&mut self) {
    let _dropped = std::fs::remove_dir_all(&self.0).ok();
    ();
  }
}

pub(crate) static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);

pub fn diagnose(path: &Path, source: &str, failure: &ParseFailure) -> Vec<Diagnostic> {
  let shown = path.display().to_string();
  match failure {
    ParseFailure::Lex(e) => vec![Diagnostic {
      code: e.code.into(),
      severity: voltaic_diag::Severity::Error,
      message: e.message.clone(),
      span: e.span,
      related: Vec::new(),
      help: e.help.clone(),
      path: shown,
      source: source.to_string(),
    }],
    ParseFailure::Parse(problems) => problems
      .iter()
      .map(|e| Diagnostic {
        code: e.code.into(),
        severity: voltaic_diag::Severity::Error,
        message: e.message.clone(),
        span: e.span,
        related: Vec::new(),
        help: e.help.clone(),
        path: shown.clone(),
        source: source.to_string(),
      })
      .collect(),
  }
}

pub(crate) fn io_diagnostic(path: &str, message: String) -> Diagnostic {
  Diagnostic {
    code: DiagnosticCode::Foreign("io".to_string()),
    severity: voltaic_diag::Severity::Error,
    message,
    span: (0, 0),
    related: Vec::new(),
    help: None,
    path: path.to_string(),
    source: String::new(),
  }
}

pub fn rustc_path() -> Option<PathBuf> {
  let from_path = std::env::var_os("PATH").into_iter().flat_map(|paths| {
    let dirs: Vec<PathBuf> = std::env::split_paths(&paths)
      .map(|dir| dir.join("rustc"))
      .collect();
    dirs
  });
  let baked = std::iter::once(PathBuf::from(env!("VOLTAIC_RUSTC")));
  let from_env = std::env::var_os("RUSTC").map(PathBuf::from).into_iter();

  from_path
    .chain(baked)
    .chain(from_env)
    .find(|candidate| is_real_rustc(candidate))
}

pub(crate) fn is_real_rustc(candidate: &Path) -> bool {
  if !candidate.is_file() {
    return false;
  }
  let probed = Command::new(candidate).arg("--version").output();
  probed
    .is_ok_and(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).starts_with("rustc "))
}

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

  #[test]
  fn creates_and_removes_itself() {
    let gone = {
      let dir = ScratchDir::new("test").expect("should create");
      let live = dir.path().to_path_buf();
      assert!(live.is_dir());
      live
    };
    assert!(
      !gone.exists(),
      "the directory should be gone after the drop"
    );
  }

  #[test]
  fn two_directories_do_not_collide() {
    let a = ScratchDir::new("test").expect("should create");
    let b = ScratchDir::new("test").expect("should create");
    assert_ne!(a.path(), b.path());
  }

  #[test]
  fn carries_a_lex_errors_code_and_help_across() {
    let source = "main : ()\nmain =\n\tprint 1\n";
    let failure = voltaic_parser::parse_source(source).expect_err("a tab is an error");
    let diagnostics = diagnose(Path::new("a.vlt"), source, &failure);
    assert_eq!(diagnostics.len(), 1);
    assert_eq!(diagnostics[0].code.text(), "VLT0001");
    assert_eq!(diagnostics[0].path, "a.vlt");
    assert!(diagnostics[0].help.is_some(), "the lexer's help survives");
  }

  #[test]
  fn reports_every_recovered_parse_error() {
    let source = "a : ()\na =\n  + 1\n\nb : ()\nb =\n  * \n";
    let failure = voltaic_parser::parse_source(source).expect_err("should fail");
    let diagnostics = diagnose(Path::new("a.vlt"), source, &failure);
    let count = diagnostics.len();
    assert!(count >= 2, "expected a diagnostic per error, got {}", count);
    assert!(diagnostics.iter().all(|d| d.source == source));
  }

  #[test]
  fn renders_without_a_snippet() {
    let d = io_diagnostic("a.vlt", "cannot read a.vlt: missing".to_string());
    assert_eq!(d.code.text(), "io");
    assert_eq!(d.path, "a.vlt");
    let out = voltaic_diag::render(std::slice::from_ref(&d), true);
    assert!(out.contains("cannot read"), "{}", out);
    assert!(!out.contains("  |"), "no snippet should be drawn:\n{}", out);
  }

  #[test]
  fn finds_a_rustc() {
    let found = rustc_path().expect("the toolchain should provide a rustc");
    let shown = found.display();
    assert!(is_real_rustc(&found), "{} is not rustc", shown);
  }

  #[test]
  fn rejects_a_program_that_is_not_rustc() {
    assert!(!is_real_rustc(Path::new("/bin/sh")));
  }

  #[test]
  fn rejects_a_path_that_does_not_exist() {
    assert!(!is_real_rustc(Path::new("/nonexistent/rustc")));
  }

  #[test]
  fn rejects_a_directory() {
    assert!(!is_real_rustc(Path::new("/tmp")));
  }
}