use crate::{OptLevel, RUNTIME_LIB};
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime};
const MIN_CLANG_VERSION: u32 = 15;
static CLANG_VERSION_CHECKED: OnceLock<Result<u32, String>> = OnceLock::new();
pub fn check_clang_version() -> Result<u32, String> {
CLANG_VERSION_CHECKED
.get_or_init(|| {
let output = Command::new("clang")
.arg("--version")
.output()
.map_err(|e| {
format!(
"Failed to run clang: {e}.\n\
plgc needs clang {MIN_CLANG_VERSION}+ to link compiled binaries \
(the binaries themselves need nothing).\n\
Install it with:\n\
\x20 debian/ubuntu: sudo apt install clang\n\
\x20 fedora: sudo dnf install clang\n\
\x20 macOS: xcode-select --install"
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"clang --version failed with exit code {:?}: {stderr}",
output.status.code(),
));
}
let version_str = String::from_utf8_lossy(&output.stdout);
let version = parse_clang_version(&version_str).ok_or_else(|| {
format!(
"Could not parse clang version from: {}\n\
plgc requires clang {MIN_CLANG_VERSION} or later (opaque pointer support).",
version_str.lines().next().unwrap_or(&version_str),
)
})?;
let is_apple = version_str.contains("Apple clang");
let effective_min = if is_apple { 14 } else { MIN_CLANG_VERSION };
if version < effective_min {
return Err(format!(
"clang version {version} detected, but plgc requires {} {effective_min} or later.\n\
The generated LLVM IR uses opaque pointers (requires LLVM 15+).",
if is_apple { "Apple clang" } else { "clang" },
));
}
Ok(version)
})
.clone()
}
fn parse_clang_version(output: &str) -> Option<u32> {
for line in output.lines() {
if line.contains("clang version")
&& let Some(idx) = line.find("version ")
{
let after_version = &line[idx + 8..];
let major: String = after_version
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !major.is_empty() {
return major.parse().ok();
}
}
}
None
}
static RUNTIME_EXTRACTED: OnceLock<Result<std::path::PathBuf, String>> = OnceLock::new();
const STALE_TEMP_AGE: Duration = Duration::from_secs(10 * 60);
const STALE_SIBLING_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
enum RuntimeArchive {
Cached(std::path::PathBuf),
Ephemeral(#[allow(dead_code)] tempfile::TempDir, std::path::PathBuf),
}
impl RuntimeArchive {
fn lib_path(&self) -> &Path {
match self {
RuntimeArchive::Cached(path) => path,
RuntimeArchive::Ephemeral(_, path) => path,
}
}
}
fn extracted_runtime() -> Result<RuntimeArchive, String> {
let Some(base) = cache_base() else {
let dir = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
let path = dir.path().join("libplg_runtime.a");
fs::write(&path, RUNTIME_LIB).map_err(|e| format!("Failed to write runtime lib: {e}"))?;
return Ok(RuntimeArchive::Ephemeral(dir, path));
};
RUNTIME_EXTRACTED
.get_or_init(|| {
let dir = base.join(concat!("runtime-", env!("PLG_RUNTIME_HASH")));
let path = dir.join("libplg_runtime.a");
sweep_stale(&base, &dir);
if let Ok(meta) = fs::metadata(&path)
&& meta.len() == RUNTIME_LIB.len() as u64
{
return Ok(path);
}
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create runtime cache dir: {e}"))?;
let tmp = dir.join(format!(".libplg_runtime.a.{}", std::process::id()));
fs::write(&tmp, RUNTIME_LIB)
.map_err(|e| format!("Failed to write runtime lib: {e}"))?;
fs::rename(&tmp, &path).map_err(|e| format!("Failed to install runtime lib: {e}"))?;
Ok(path)
})
.clone()
.map(RuntimeArchive::Cached)
}
fn cache_base() -> Option<std::path::PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
&& !xdg.is_empty()
{
return Some(std::path::PathBuf::from(xdg).join("plgc"));
}
if let Some(home) = std::env::var_os("HOME")
&& !home.is_empty()
{
return Some(std::path::PathBuf::from(home).join(".cache").join("plgc"));
}
None
}
fn sweep_stale(base: &Path, keep: &Path) {
if let Ok(entries) = fs::read_dir(base) {
for entry in entries.flatten() {
let path = entry.path();
if path != keep
&& path
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("runtime-"))
&& older_than(&path, STALE_SIBLING_AGE)
{
let _ = fs::remove_dir_all(&path);
}
}
}
if let Ok(entries) = fs::read_dir(keep) {
for entry in entries.flatten() {
let path = entry.path();
if path
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with(".libplg_runtime.a."))
&& older_than(&path, STALE_TEMP_AGE)
{
let _ = fs::remove_file(&path);
}
}
}
}
fn older_than(path: &Path, age: Duration) -> bool {
let Ok(meta) = fs::metadata(path) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
SystemTime::now()
.duration_since(modified)
.is_ok_and(|elapsed| elapsed > age)
}
pub fn link_ir(ir_path: &Path, output_path: &Path, opt: OptLevel) -> Result<(), String> {
check_clang_version()?;
let runtime = extracted_runtime()?;
let opt_flag = match opt {
OptLevel::O0 => "-O0",
OptLevel::O3 => "-O3",
};
let mut clang = Command::new("clang");
clang.arg(opt_flag);
if opt == OptLevel::O0 {
clang.arg("-g");
}
clang
.arg(ir_path)
.arg("-o")
.arg(output_path)
.arg("-L")
.arg(runtime.lib_path().parent().unwrap())
.arg("-lplg_runtime")
.arg("-lm");
if cfg!(target_os = "macos") {
clang.arg("-Wl,-dead_strip");
} else if cfg!(target_os = "linux") {
clang.arg("-Wl,--gc-sections");
if opt != OptLevel::O0 {
clang.arg("-Wl,--strip-debug");
}
}
let output = clang
.output()
.map_err(|e| format!("Failed to run clang: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Clang compilation failed:\n{stderr}"));
}
Ok(())
}