guartcl 0.6.0-alpha1

Enhanced Jim Tcl.
Documentation
use std::{
    fs, io,
    path::{Path, PathBuf},
    process::Command,
};

use jimtcl::{Interp, JimObject, JimResult, args::ProcArgParser, tcl_error};
use log::*;
use tempfile::tempdir;
use thiserror::Error;

/// Errors resulting from compilation.
#[derive(Error, Debug)]
pub enum CompileError {
    #[error("I/O error: {0}")]
    IO(#[from] io::Error),
    #[error("Cargo build failed")]
    BuildFailed,
    #[error("Preparation failed: {0}")]
    PrepFailed(String),
    #[error("Preparation script failed: {0}")]
    PrepScriptError(#[from] jimtcl::JimError),
    #[error("Compilation output is missing")]
    OutputMissing,
}

/// Struct for compiling scripts.
pub struct ScriptCompiler<'interp> {
    interp: &'interp Interp,
    work_dir: Option<PathBuf>,
    tcl_source: Option<PathBuf>,
    debug: bool,
}

impl<'interp> ScriptCompiler<'interp> {
    /// Create a compiler in the specified interpreter.
    pub fn new(interp: &'interp Interp) -> ScriptCompiler<'interp> {
        ScriptCompiler {
            interp,
            work_dir: None,
            tcl_source: None,
            debug: false,
        }
    }

    /// Override the working directory for this build.
    pub fn working_dir<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
        self.work_dir = Some(path.as_ref().to_owned());
        self
    }

    /// Override the Tcl source directory.
    pub fn guardian_source_dir<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
        self.tcl_source = Some(path.as_ref().to_owned());
        self
    }

    /// Compile te Tcl script into a self-contained executable.
    pub fn compile(&self, src: &Path, dst: &Path) -> Result<(), CompileError> {
        if let Some(dir) = &self.work_dir {
            info!("using existing working directory {}", dir.display());
            self.compile_in_dir(dir, src, dst)
        } else {
            debug!("using temporary working directory");
            let workdir = tempdir()?;
            self.compile_in_dir(workdir.path(), src, dst)
        }
    }

    fn compile_in_dir(&self, work: &Path, src: &Path, dst: &Path) -> Result<(), CompileError> {
        debug!("preparing compilation in {}", work.display());
        let name = if let Some(name) = dst.file_name() {
            name
        } else {
            return Err(CompileError::PrepFailed("output has no filename".into()));
        };

        let str_name = name
            .to_str()
            .ok_or(CompileError::PrepFailed("name not valid UTF-8".into()))?;

        self.prepare_source(work, str_name)?;
        self.compile_source(work, src, str_name)?;

        let out_path = if self.debug {
            work.join("target/debug").join(name)
        } else {
            work.join("target/release").join(name)
        };
        if !out_path.exists() {
            error!("compile output does not exist: {}", out_path.display());
        }

        debug!("copying executable to destination");
        fs::copy(&out_path, dst)?;

        Ok(())
    }

    fn prepare_source(&self, path: &Path, name: &str) -> Result<(), CompileError> {
        debug!(
            "preparing project for {} and version {}",
            name,
            super::GUARTCL_VERSION
        );
        debug!("loading compiler script");
        self.interp
            .eval_source("compile.tcl", 1, include_str!("compile.tcl"))?;
        let command = self.interp.new_object();
        command.list_append("guardian::compiler::prepare");
        command.list_append(path.as_os_str());
        let info = self.interp.new_object();
        info.dict_set("name", name)?;
        if let Some(path) = &self.tcl_source {
            let dir = path.canonicalize()?;
            debug!("using Guardian source from {}", dir.display());
            info.dict_set("guardian_path", dir.as_os_str())?;
        }
        command.list_append(info);
        debug!("executing compiler command {}", command);
        self.interp.eval_object(&command)?;
        debug!("script source prepared");

        Ok(())
    }

    fn compile_source(&self, work: &Path, src: &Path, name: &str) -> Result<(), CompileError> {
        debug!("preparing to build {} in {}", src.display(), work.display());
        let mut cmd = Command::new("cargo");
        cmd.current_dir(work);
        cmd.args(["build"]);
        if !self.debug {
            cmd.arg("--release");
        }
        cmd.env("GUARDIAN_SCRIPT_NAME", name);
        let src = src
            .canonicalize()
            .inspect_err(|_| error!("{}: cannot normalize path", src.display()))?;
        debug!("full source path: {}", src.display());
        cmd.env("GUARDIAN_SCRIPT_SOURCE", src.as_os_str());
        info!("invoking Rust compiler");
        let mut child = cmd.spawn()?;
        let ec = child.wait()?;
        if ec.success() {
            Ok(())
        } else {
            Err(CompileError::BuildFailed)
        }
    }
}

/// Compile TCL command.
pub(crate) fn compile_command(interp: &Interp, args: &[JimObject<'_>]) -> JimResult<PathBuf> {
    let mut parser = ProcArgParser::new();
    parser.add_option("-guardian-src", "dir");
    parser.add_flag("-debug");
    parser.add_option("-out", "exe");
    parser.add_required_arg("script");
    let args = parser.parse(interp, args)?;

    let mut compiler = ScriptCompiler::new(interp);
    let src = args.require_arg("script")?.as_str()?;
    let src = PathBuf::from(src);
    let dst: PathBuf = if let Some(out) = args.get_option("-out") {
        out.as_str()?.into()
    } else {
        src.with_extension("")
    };
    if let Some(tcl) = args.get_option("-guardian-src") {
        let path = PathBuf::from(tcl.as_str()?);
        compiler.guardian_source_dir(&path);
    }
    if args.get_flag("-debug") {
        compiler.debug = true;
    }

    info!(
        "compiling Tcl script {} to {}",
        src.display(),
        dst.display()
    );
    compiler
        .compile(&src, &dst)
        .map_err(|e| tcl_error!("compilation failed: {}", e))?;

    Ok(dst)
}