hypothalamus 0.5.0

An optimizing Brainfuck AOT compiler with an LLVM IR backend
Documentation
use hypothalamus::DEFAULT_TAPE_SIZE;
use hypothalamus::bf;
use hypothalamus::driver::{CompilerConfig, EmitKind, compile_to_llvm, compile_with_tools};
use hypothalamus::llvm::{self, LlvmOptions};
use hypothalamus::target::TargetProfile;
use hypothalamus::targets::gba;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};

fn compile_example(source: &[u8], name: &str) -> String {
    let ops = bf::parse(source).expect("example should parse");
    llvm::generate_module(
        &ops,
        &LlvmOptions {
            tape_size: DEFAULT_TAPE_SIZE,
            target_triple: None,
            source_filename: Some(name.to_string()),
            bounds_check: false,
            runtime: llvm::Runtime::Hosted,
        },
    )
    .expect("example should lower to LLVM IR")
}

#[test]
fn hello_example_lowers_to_llvm() {
    let ir = compile_example(include_bytes!("../examples/hello.bf"), "examples/hello.bf");
    assert!(ir.contains("define i32 @main()"));
}

#[test]
fn scan_loop_fixture_lowers_to_explicit_scan() {
    let ir = compile_example(b"+[>]", "scan-loop.bf");
    assert!(ir.contains("define i32 @main()"));
    assert!(ir.contains("scan_check_"));
}

#[test]
fn multiply_transfer_fixture_lowers_to_straight_line_ir() {
    let ir = compile_example(b"+++++[->+++>++<<]>>.", "multiply-transfer.bf");
    assert!(ir.contains("define i32 @main()"));
    assert!(ir.contains("mul i8"));
    assert!(!ir.contains("loop_check_"));
}

#[test]
fn target_presets_emit_expected_llvm_runtime() {
    let mut config = CompilerConfig::for_target("examples/hello.bf", TargetProfile::resolve("gba"));
    config.emit = EmitKind::LlvmIr;

    let ir = compile_to_llvm(&config).expect("GBA target should lower to LLVM IR");

    assert!(ir.contains("target triple = \"thumbv4t-none-eabi\""));
    assert!(ir.contains("define void @bf_main()"));
    assert!(ir.contains("declare void @bf_putchar(i8)"));
    assert!(!ir.contains("define i32 @main()"));

    let mut config =
        CompilerConfig::for_target("examples/hello.bf", TargetProfile::resolve("nds-arm9"));
    config.emit = EmitKind::LlvmIr;

    let ir = compile_to_llvm(&config).expect("DS ARM9 target should lower to LLVM IR");

    assert!(ir.contains("target triple = \"armv5te-none-eabi\""));
    assert!(ir.contains("define void @bf_main()"));
    assert!(ir.contains("declare void @bf_putchar(i8)"));
    assert!(!ir.contains("define i32 @main()"));
}

#[test]
fn freestanding_targets_emit_objects_when_clang_supports_them() {
    for target_name in ["i386-none", "nds-arm9", "gba"] {
        let target = TargetProfile::resolve(target_name);
        let Some(triple) = target.llvm_triple() else {
            continue;
        };
        if !clang_supports_target(triple, target.clang_args()) {
            eprintln!("skipping {target_name} object smoke test: clang does not support {triple}");
            continue;
        }

        let temp_dir = TestTempDir::new(&format!("target-smoke-{target_name}"));
        let output = temp_dir.path().join("hello.o");

        let mut config = CompilerConfig::for_target("examples/hello.bf", target);
        config.emit = EmitKind::Object;
        config.output = Some(output.clone());

        compile_with_tools(&config).expect("target object smoke should compile");
        assert!(output.exists());
    }
}

#[test]
fn gba_target_builds_rom_when_tools_are_available() {
    if gba::find_gba_tool(None, "arm-none-eabi-gcc").is_none()
        || gba::find_gba_tool(None, "arm-none-eabi-objcopy").is_none()
    {
        eprintln!("skipping GBA ROM smoke test: devkitARM tools are not available");
        return;
    }

    let temp_dir = TestTempDir::new("gba-rom-smoke");
    let output = temp_dir.path().join("hello.gba");

    let mut config = CompilerConfig::for_target("examples/hello.bf", TargetProfile::resolve("gba"));
    config.output = Some(output.clone());

    compile_with_tools(&config).expect("GBA ROM smoke should compile");

    let rom = fs::read(output).expect("read generated GBA ROM");
    assert!(gba::has_valid_header(&rom));
    assert!(rom.len() > gba::HEADER_SIZE);
    assert_eq!(&rom[0xA0..0xAC], gba::ROM_TITLE);
    assert_eq!(&rom[0xAC..0xB0], gba::GAME_CODE);
}

fn clang_supports_target(triple: &str, clang_args: &[String]) -> bool {
    let Some(temp_dir) = TestTempDir::try_new("clang-target-check") else {
        return false;
    };
    let output = temp_dir.path().join("check.o");

    let mut child = match Command::new("clang")
        .arg(format!("--target={triple}"))
        .args(clang_args)
        .arg("-x")
        .arg("c")
        .arg("-c")
        .arg("-")
        .arg("-o")
        .arg(output)
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
    {
        Ok(child) => child,
        Err(_) => return false,
    };

    {
        let Some(stdin) = child.stdin.as_mut() else {
            return false;
        };
        use std::io::Write;
        if stdin.write_all(b"void f(void) {}\n").is_err() {
            return false;
        }
    }

    child.wait().map(|status| status.success()).unwrap_or(false)
}

struct TestTempDir {
    path: PathBuf,
}

impl TestTempDir {
    fn new(label: &str) -> Self {
        Self::try_new(label).expect("create test temp dir")
    }

    fn try_new(label: &str) -> Option<Self> {
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .ok()?
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "hypothalamus-{label}-{}-{timestamp}",
            std::process::id()
        ));
        fs::create_dir_all(&path).ok()?;
        Some(Self { path })
    }

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

impl Drop for TestTempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}