saja 0.1.0

Zero-configuration C build system
/*
 * Helper functions for building and linking.
 *
 * Copyright (C) 2026  Madeleine Choi
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

use std::{
    env, fs,
    path::{Path, PathBuf},
    process,
};

use anyhow::Context;
use log::{debug, info};
use serde::Serialize;
use walkdir::WalkDir;

use crate::Saja;

const CLANG_PLUGIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libsaja.so"));

#[derive(Serialize)]
struct CompileCommand {
    directory: PathBuf,
    file: PathBuf,
    arguments: Vec<String>,
    output: PathBuf,
}

pub fn needs_rebuild(source: &Path, object: &Path) -> bool {
    let obj_t = match object.metadata().and_then(|m| m.modified()) {
        Ok(t) => t,
        Err(_) => return true, // doesn't exist
    };

    source
        .metadata()
        .and_then(|m| m.modified())
        .map(|src_t| src_t > obj_t)
        .unwrap_or(true)
}

impl Saja {
    fn cache_dir() -> PathBuf {
        if let Some(cache) = env::var_os("XDG_CACHE_HOME") {
            return PathBuf::from(cache);
        }

        if let Some(home) = env::var_os("HOME") {
            return PathBuf::from(home).join(".cache");
        }

        env::temp_dir()
    }

    fn plugin_cache_path(cache_dir: PathBuf) -> PathBuf {
        cache_dir
            .join(env!("CARGO_PKG_NAME"))
            .join(env!("CARGO_PKG_VERSION"))
            .join(format!("{}-{}", env::consts::ARCH, env::consts::OS))
            .join("libsaja.so")
    }

    fn write_clang_plugin(path: PathBuf) -> anyhow::Result<PathBuf> {
        if fs::read(&path).is_ok_and(|contents| contents == CLANG_PLUGIN) {
            return Ok(path);
        }

        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }

        let tmp = path.with_extension(format!("so.{}.tmp", process::id()));
        fs::write(&tmp, CLANG_PLUGIN)?;
        fs::rename(&tmp, &path)?;

        Ok(path)
    }

    fn clang_plugin_path(&self) -> anyhow::Result<PathBuf> {
        let preferred = Self::plugin_cache_path(Self::cache_dir());

        match Self::write_clang_plugin(preferred) {
            Ok(path) => Ok(path),
            Err(e) => {
                let fallback = Self::plugin_cache_path(env::temp_dir());

                Self::write_clang_plugin(fallback)
                    .with_context(|| format!("could not write Clang plugin to cache: {e}"))
            }
        }
    }

    fn object_path(&self, source: &Path, build_dir: &Path) -> anyhow::Result<PathBuf> {
        let out = build_dir.join("objects");
        let relative = source.strip_prefix(build_dir.parent().unwrap().join("src"))?;

        Ok(out.join(relative).with_extension("o"))
    }

    fn clang_plugin_args(&self) -> anyhow::Result<Vec<String>> {
        let plugin = self.clang_plugin_path()?;
        let plugin = plugin.display().to_string();

        Ok(["-Xclang", "-load", "-Xclang", &plugin]
            .into_iter()
            .map(ToString::to_string)
            .collect())
    }

    fn build_object(
        &self,
        source: &Path,
        build_dir: &Path,
        profile: &[String],
    ) -> anyhow::Result<Option<CompileCommand>> {
        let object = self.object_path(source, build_dir)?;

        if !needs_rebuild(source, &object) {
            debug!("skipping {source:?}, up to date");
            return Ok(None);
        }

        let include = build_dir.join("include");

        if let Some(parent) = object.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let mut command = process::Command::new("clang");
        let plugin_args = self.clang_plugin_args()?;

        let arguments = std::iter::once("clang".to_string())
            .chain(profile.iter().cloned())
            .chain(plugin_args)
            .chain([
                "-c".to_string(),
                format!("-I{}", include.display()),
                "-o".to_string(),
                object.display().to_string(),
                source.display().to_string(),
            ])
            .collect::<Vec<_>>();

        command.args(&arguments[1..]);

        debug!("{:?}", command);

        let status = command.status()?;

        if !status.success() {
            anyhow::bail!("compilation failed");
        }

        Ok(Some(CompileCommand {
            directory: build_dir.parent().unwrap().to_owned(),
            file: source.to_owned(),
            arguments,
            output: object,
        }))
    }

    fn link(&self, objects: Vec<PathBuf>, target: &Path, build_dir: &Path) -> anyhow::Result<()> {
        let objects = objects.into_iter().map(|s| target.join(s));

        let mut command = process::Command::new("clang");
        let out = build_dir.join(&self.config.name).display().to_string();
        command.args(objects).args(["-o", &out]);

        debug!("{:?}", command);

        let status = command.status()?;

        if !status.success() {
            anyhow::bail!("linking failed");
        }

        Ok(())
    }

    pub fn build(
        &self,
        target: &Path,
        profile: &str,
        no_compile_commands: &bool,
    ) -> anyhow::Result<()> {
        let build_dir = target.join(&self.args.build);

        let profile = self
            .profiles
            .get(profile)
            .with_context(|| format!("no such profile {profile}"))?;

        let sources = WalkDir::new(target.join("src"))
            .into_iter()
            .filter_map(Result::ok)
            .map(|e| e.into_path())
            .filter(|p| matches!(p.extension().and_then(|e| e.to_str()), Some("c")))
            .collect::<Vec<_>>();

        let exports = sources
            .iter()
            .map(|source| self.extract(source, target))
            .collect::<anyhow::Result<Vec<_>>>()?
            .into_iter()
            .flatten()
            .collect::<Vec<_>>();

        self.make_headers(exports, &build_dir.join("include"))?;

        let compile_commands = sources
            .iter()
            .map(|s| self.build_object(s, &build_dir, profile))
            .collect::<anyhow::Result<Vec<_>>>()?
            .into_iter()
            .flatten()
            .collect::<Vec<_>>();

        if compile_commands.is_empty() {
            info!("nothing to do");
            return Ok(());
        }

        if !no_compile_commands {
            let db = target.join("compile_commands.json");
            let json = serde_json::to_string_pretty(&compile_commands)?;
            fs::write(db, json)?;
        }

        let objects = sources
            .iter()
            .map(|source| self.object_path(source, &build_dir))
            .collect::<anyhow::Result<Vec<_>>>()?;

        self.link(objects, target, &build_dir)?;

        Ok(())
    }
}