knave 0.14.2

A Lisp programming language for humans and agents that infers types and ownership and compiles to readable Rust
Documentation
use std::fs;
use std::io;
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::thread;

use serde_json::Value;

use super::BuildError;
use super::build_plan::BuildPlan;
use super::cargo_manifest::CargoManifest;
use super::crate_lock::CrateLock;
use super::interrupt_signal::InterruptSignal;
use super::rustc_errors::RustcDiagnosticMapper;
use super::user_cache::UserCache;
use crate::emit::EmittedCrate;
use crate::source::Span;

pub struct CargoCompiler<'w> {
    directory: PathBuf,
    plan: BuildPlan,
    waiting: &'w dyn Fn(&str),
}

pub struct CargoTarget<'t> {
    pub manifest: CargoManifest<'t>,
    pub directory: PathBuf,
    pub lock_file: PathBuf,
    pub output: PathBuf,
}

struct CargoReport<'m> {
    manifest: &'m CargoManifest<'m>,
    program_messages: Vec<String>,
    dependency_errors: Vec<String>,
    executable: Option<PathBuf>,
}

impl<'w> CargoCompiler<'w> {
    const CARGO: &'static str = "cargo";
    const TARGET_VARIABLE: &'static str = "CARGO_TARGET_DIR";
    const USER_TARGET_VARIABLES: [&'static str; 2] = ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
    const TARGET_DIRECTORY: &'static str = "target";
    const TARGET_LOCKS: [&'static str; 2] = [
        "Blocking waiting for file lock on build directory",
        "Blocking waiting for file lock on artifact directory",
    ];
    const OWN_TARGET: &'static str = "Cargo's target directory";
    const CARGO_USE: &'static str = "run cargo, which builds a project with crate dependencies";
    const MANIFEST: &'static str = "Cargo.toml";
    const LOCK_FILE: &'static str = "Cargo.lock";
    const SOURCE: &'static str = "src/main.rs";
    const COMPILER_MESSAGE: &'static str = "compiler-message";
    const COMPILER_ARTIFACT: &'static str = "compiler-artifact";
    const ERROR_LEVEL: &'static str = "error";

    pub fn new(directory: PathBuf, plan: BuildPlan, waiting: &'w dyn Fn(&str)) -> Self {
        Self {
            directory,
            plan,
            waiting,
        }
    }

    pub fn compile(
        &self,
        emitted: &EmittedCrate,
        target: &CargoTarget<'_>,
        entry: Span,
    ) -> Result<(), BuildError> {
        let _lock = self.lock_crate()?;
        self.write_crate(emitted, target)
            .map_err(|error| Self::environment("write the generated crate", &error))?;
        let output = self.run_cargo(&target.manifest)?;
        if InterruptSignal::stopped(output.status) {
            return Err(BuildError::Interrupted);
        }
        let report = CargoReport::read(&String::from_utf8_lossy(&output.stdout), &target.manifest);
        match (&report.executable, output.status.success()) {
            (Some(executable), true) => self.finish(executable, target),
            _ => Err(report.failure(emitted, entry, &output.stderr)),
        }
    }

    fn lock_crate(&self) -> Result<CrateLock, BuildError> {
        let waiting = || (self.waiting)(&format!("`{}`", self.directory.display()));
        CrateLock::acquire(&self.directory, waiting)
            .map_err(|error| Self::environment("lock the generated crate", &error))
    }

    fn write_crate(&self, emitted: &EmittedCrate, target: &CargoTarget<'_>) -> io::Result<()> {
        let source = self.directory.join(Self::SOURCE);
        fs::create_dir_all(source.parent().unwrap_or(&self.directory))?;
        Self::write_if_changed(
            &self.directory.join(Self::MANIFEST),
            &target.manifest.render(&self.plan),
        )?;
        Self::write_if_changed(&source, &emitted.code)?;
        if target.lock_file.is_file() {
            fs::copy(&target.lock_file, self.directory.join(Self::LOCK_FILE))?;
        }
        Ok(())
    }

    fn write_if_changed(path: &Path, text: &str) -> io::Result<()> {
        if fs::read_to_string(path).is_ok_and(|written| written == text) {
            return Ok(());
        }
        fs::write(path, text)
    }

    fn run_cargo(&self, manifest: &CargoManifest<'_>) -> Result<Output, BuildError> {
        let mut command = Command::new(Self::CARGO);
        command
            .arg("rustc")
            .args(manifest.target_arguments())
            .arg("--message-format=json")
            .args(self.plan.cargo_flags())
            .arg("--")
            .args(self.plan.lint_flags())
            .current_dir(&self.directory)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let shared_target = Self::shared_target();
        if let Some(target) = &shared_target {
            command.env(Self::TARGET_VARIABLE, target);
        }
        self.watched(&mut command, shared_target)
            .map_err(|error| Self::environment(Self::CARGO_USE, &error))
    }

    fn watched(&self, command: &mut Command, target: Option<PathBuf>) -> io::Result<Output> {
        let mut child = command.spawn()?;
        let mut stdout = child
            .stdout
            .take()
            .expect("cargo's standard output is piped");
        let reader = thread::spawn(move || {
            let mut text = Vec::new();
            stdout.read_to_end(&mut text).map(|_| text)
        });
        let stderr = child
            .stderr
            .take()
            .expect("cargo's standard error is piped");
        let mut stderr_text = Vec::new();
        for line in BufReader::new(stderr).lines() {
            let line = line?;
            if Self::TARGET_LOCKS.iter().any(|lock| line.contains(lock)) {
                (self.waiting)(&Self::target_name(target.as_deref()));
            }
            stderr_text.extend_from_slice(line.as_bytes());
            stderr_text.push(b'\n');
        }
        let status = child.wait()?;
        let stdout_text = reader
            .join()
            .expect("the reader of cargo's output does not panic")?;
        Ok(Output {
            status,
            stdout: stdout_text,
            stderr: stderr_text,
        })
    }

    fn target_name(target: Option<&Path>) -> String {
        target.map_or_else(
            || Self::OWN_TARGET.to_string(),
            |target| format!("`{}`", target.display()),
        )
    }

    fn shared_target() -> Option<PathBuf> {
        let user_target = Self::USER_TARGET_VARIABLES
            .iter()
            .any(|name| UserCache::variable_path(name).is_some());
        Self::target_for(user_target, UserCache::directory())
    }

    fn target_for(user_target_set: bool, cache: Option<PathBuf>) -> Option<PathBuf> {
        if user_target_set {
            return None;
        }
        cache.map(|cache| cache.join(Self::TARGET_DIRECTORY))
    }

    fn finish(&self, executable: &Path, target: &CargoTarget<'_>) -> Result<(), BuildError> {
        if let Some(directory) = target.output.parent() {
            fs::create_dir_all(directory)
                .map_err(|error| Self::environment("create the output directory", &error))?;
        }
        fs::copy(executable, &target.output)
            .map_err(|error| Self::environment("copy the built program", &error))?;
        fs::copy(self.directory.join(Self::LOCK_FILE), &target.lock_file)
            .map_err(|error| Self::environment("copy `Cargo.lock` back", &error))?;
        Ok(())
    }

    fn environment(action: &str, error: &io::Error) -> BuildError {
        BuildError::Environment(format!("cannot {action}: {error}"))
    }
}

impl<'m> CargoReport<'m> {
    fn read(stdout: &str, manifest: &'m CargoManifest<'m>) -> Self {
        let mut report = Self {
            manifest,
            program_messages: Vec::new(),
            dependency_errors: Vec::new(),
            executable: None,
        };
        stdout
            .lines()
            .filter_map(|line| serde_json::from_str::<Value>(line).ok())
            .for_each(|record| report.add(&record));
        report
    }

    fn add(&mut self, record: &Value) {
        match record["reason"].as_str() {
            Some(CargoCompiler::COMPILER_MESSAGE) => self.add_message(record),
            Some(CargoCompiler::COMPILER_ARTIFACT) => self.add_artifact(record),
            _ => {}
        }
    }

    fn add_message(&mut self, record: &Value) {
        let message = &record["message"];
        if self.is_program(record) {
            return self.program_messages.push(message.to_string());
        }
        if message["level"].as_str() == Some(CargoCompiler::ERROR_LEVEL) {
            let rendered = message["rendered"].as_str().unwrap_or_default();
            let crate_name = record["target"]["name"].as_str().unwrap_or_default();
            self.dependency_errors
                .push(format!("crate `{crate_name}` failed to build:\n{rendered}"));
        }
    }

    fn add_artifact(&mut self, record: &Value) {
        if let Some(executable) = record["executable"].as_str() {
            self.executable = Some(PathBuf::from(executable));
        }
    }

    fn is_program(&self, record: &Value) -> bool {
        record["target"]["name"]
            .as_str()
            .is_some_and(|name| self.manifest.names_target(name))
    }

    fn failure(&self, emitted: &EmittedCrate, entry: Span, stderr: &[u8]) -> BuildError {
        let program_errors =
            RustcDiagnosticMapper::new(emitted, entry).errors_of(&self.program_messages);
        if program_errors.has_errors() {
            return BuildError::Program(program_errors);
        }
        if !self.dependency_errors.is_empty() {
            return BuildError::Environment(self.dependency_errors.join("\n"));
        }
        BuildError::Environment(String::from_utf8_lossy(stderr).trim_end().to_string())
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::CargoCompiler;

    #[test]
    fn the_shared_target_sits_in_the_user_cache_unless_cargo_target_dir_is_set() {
        let cache = Some(PathBuf::from("/cache/knave"));
        assert_eq!(
            CargoCompiler::target_for(false, cache.clone()),
            Some(PathBuf::from("/cache/knave/target"))
        );
        assert_eq!(CargoCompiler::target_for(true, cache), None);
    }

    #[test]
    fn without_a_cache_directory_cargo_keeps_its_own_target() {
        assert_eq!(CargoCompiler::target_for(false, None), None);
    }
}