Something went wrong. Try again.
The Monad language. Dependent types, functional programming compiled with LLVM. Hobby project. monad-lang.org
dependent-types language compiler programming-language functional-programming
Something went wrong. Try again.
15 kB · 303 lines
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304// ffi_example / ffi_codegen_e2e_test — AUTOMATED LLVM-path E2E test for// the `#[extern "c"]` codegen (Phase 3 part 2 of// `plans/implementations/c-rust-ffi.md`).//// `ffi_test.mo` (this directory) already exercises the same path, but// only as a program a human runs by hand and eyeballs -- nothing in the// test suite ran it. That gap is why a whole class of codegen bugs// survived a rebase onto main undetected: the ONLY automated FFI test// was `ffi_eval_test.mo`, which dispatches through the Rust interpreter// (`core/src/ffi.rs::extern_execute`) and never reaches// `lang/codegen/emit.mo` at all. Every bug below produced a clean// `ffi_eval_test` pass and a broken compiler.//// This test drives the REAL backend end to end -- load -> codegen ->// `llc` -> `clang` -> execute the binary -> check its exit code. Bugs// 1, 2 and 4 below are caught by that pipeline (each one either makes// `llc` reject the module or makes the link fail); bug 3 is invisible// to it and needs its own assertion on the emitted text, for the reason// given in `test_extern_f64_return_type_declares_double`. All four were// re-verified by reverting each fix in turn and confirming the specific// test that covers it goes red://// 1. `llc` accepting the module at all. The extern wrapper's ABI// casts were invalid LLVM three separate ways: a `bitcast` with no// source type, a `bitcast` between `i64` and a pointer (LLVM// allows neither direction -- `inttoptr`/`ptrtoint` are the only// legal casts, `llc: invalid cast opcode for cast from 'i64' to// 'ptr'`), and a cast written inline inside a call argument rather// than as its own instruction.// 2. The `double`-typed call argument. `llvm_value_type` hardcodes// `i64` for any `var_`, so `sin`'s bitcast temp was emitted as// `call double @sin(i64 %t3)` until `LLVMValue.typed` was added to// carry a cast temp's real SSA type.// 3. `sin : F64 -> F64` mapping to `double`, not `i64`. A def's `typ`// is the FULL pi type, so `return_llvm_type` has to peel// `Term.pi`; without that the module declared `i64 @sin(double)`.// 4. `-lm` reaching the linker, from the mote's `[link] libs` rather// than from any attribute. Without it the compile succeeds and the// LINK fails with `undefined reference to 'sin'`. Asserted here by// linking `sin` for real, and directly on the collected libs in// `test_extern_link_libs_flag` below (a plain wrong-flags bug// would otherwise only ever surface as a link failure).//// The compiled program returns `strlen "hello"` (5) as its exit code// rather than printing: an exit code is what `exec_cmd` can actually// observe, and it proves a real value crossed the C boundary and came// back correct, not merely that the binary ran. `sin` is called and its// result consumed so the `double` path is genuinely exercised; the value// is not asserted on HERE because an exit code is this test's only// channel back from the fixture program -- `ffi_link_test.mo` in this// directory is where `sin`'s result, and the narrow-int round trips, are// asserted on directly, from plain `#[test]` defs.open IO {println}use std::process {exec_cmd, process_id}use lang::types {}use lang::module {collect_link_libs, get_loaded_all, load_file_modules}use llvm::ir {emit_module}use lang::codegen::emit {compile_loaded_modules_to_ir}use llvm::link {compile_ir_to_obj, compile_runtime_obj, link_objects, map_dash_l}use runtime {Runtime.c_path}/// The fixture program: exercises all four extern shapes the wrapper/// has to adapt -- `puts` (`String` -> `I32`: `inttoptr` in, `sext`/// out), `strlen` (`String` -> `I64`: `inttoptr` in, no-op out),/// `sin` (`F64` -> `F64`: `bitcast` both ways, plus `-lm`) and `abs`/// (`I32` -> `I32`: `trunc` in, `sext` out). `abs` is fed by `puts`'s/// own `I32` result (the corpus has no `I32` literal yet); its wrapper/// is what proves the narrow-integer casts -- before the review fixes,/// a narrow param emitted `bitcast i64 %p0 to i32` (`llc: invalid cast/// opcode`) and an `i32` return was `zext`-widened, corrupting C's/// signed values (`puts` yields EOF = -1)./// NOTE ON WHY THIS FIXTURE INLINES ITS OWN EXTERN DECLS rather than/// writing `use ffi_example.libc {puts, strlen, sin}` like the/// eval-path test next door does: the self-hosted compiler cannot/// resolve a mote-qualified import yet. Calling a mote-imported def/// through `lang/main.mo compile` fails at TYPECHECK, before codegen is/// ever reached (`error: unknown variable 'strlen' in main`) -- the/// mote's decls never enter the target file's scope. The eval path/// resolves the identical import fine (`run motes/ffi_example/src//// ffi_test.mo` prints all four lines), so this is a module-resolution/// gap in the self-hosted path, upstream of and unrelated to the FFI/// codegen this file tests. `ffi_test.mo` in this directory is the/// mote-import form and is blocked on that gap; keeping THIS fixture/// inline is what lets the extern codegen itself be tested today/// instead of waiting on it. The declarations below mirror `libc.mo`'s/// exactly -- when mote imports start working in the self-hosted path,/// this fixture should collapse to the one-line `use` and/// `ffi_test.mo` should be folded into it.#[partial]def ffi_fixture_source : String := r#"#[extern "c"]def puts (s : String) : I32#[extern "c" {link_name := "strlen"}]def strlen (s : String) : I64#[extern "c"]def sin (x : F64) : F64#[extern "c"]def abs (x : I32) : I32def main (args : List String) : IO I64 := do { let greeted := puts "llvm ffi works"; let cleaned := abs (puts "narrow casts work"); let n := strlen "hello"; let sine := sin 0.0; return n}"#/// The temp mote's manifest. `sin` lives in libm, and THAT is what/// `[link] libs` declares -- the fixture's own `#[extern "c"]` says only/// which symbol to bind. Writing a real `mote.toml` beside the fixture/// is what makes this test exercise the manifest path rather than a/// hardcoded flag list.#[partial]def ffi_fixture_manifest : String := r#"[mote]name = "ffi_probe"version = "0.1.0"edition = "2026"[link]libs = ["m"]"#/// Compiles `ffi_fixture_source` through the self-hosted backend and/// runs the result, threading the mote's declared `[link] libs` into the/// `clang` invocation exactly as `cli/src/main.mo` does. Returns the/// binary's exit code, or a negative marker for each stage that failed/// (so a failure says WHICH stage broke rather than just "not 5").#[partial]def compile_ffi_fixture_exit_code : IO I64 := do { let output_dir := "/tmp/monad_ffi_e2e_" ++ I64.to_string process_id; let basename := "ffi_codegen_e2e"; let src_path := output_dir ++ "/src/" ++ basename ++ ".mo"; let ir_path := output_dir ++ "/" ++ basename ++ ".ll"; let obj_path := output_dir ++ "/" ++ basename ++ ".o"; let runtime_obj := output_dir ++ "/" ++ basename ++ "_runtime.o"; let output_path := output_dir ++ "/" ++ basename; // A real mote, not a loose file: `collect_link_libs` walks up from // the source to the nearest `mote.toml`, so the fixture only gets // `-lm` if the manifest beside it actually declares it. exec_cmd "mkdir" ["-p", output_dir ++ "/src"]; IO.write_file (Path.path (output_dir ++ "/mote.toml")) ffi_fixture_manifest; IO.write_file (Path.path src_path) ffi_fixture_source; let loaded_result <- load_file_modules src_path false; match loaded_result { Result.err e => do { println ("ffi_codegen_e2e: failed to load: " ++ e); return (0 - 1) }, Result.ok loaded => do { let mod_result <- compile_loaded_modules_to_ir loaded false; match mod_result { Result.err e => do { println ("ffi_codegen_e2e: codegen failed: " ++ e); return (0 - 2) }, Result.ok mod_ => do { let link_libs : List String <- collect_link_libs (get_loaded_all loaded); let ir_text := emit_module mod_; IO.write_file (Path.path ir_path) ir_text; // Stage 1: `llc`. This is the assertion that the // wrapper's ABI casts are legal LLVM at all. let llc_result <- compile_ir_to_obj ir_path obj_path; if not (llc_result == 0) then do { println "ffi_codegen_e2e: llc rejected the module (invalid extern wrapper IR)"; return (0 - 3) } else do { let rt_result <- compile_runtime_obj Runtime.c_path List.empty runtime_obj; if not (rt_result == 0) then do { println "ffi_codegen_e2e: compiling runtime.c failed"; return (0 - 4) } else do { // Stage 2: link WITH `link_libs`. Without // `-lm` this is where `sin` fails to resolve. // `link_objects` is the compiler's OWN linker // step (`llvm::link`, the module written to end // the drifted copy this test used to hold), so // `-lgc` and the argv shape come from one place. let link_result <- link_objects [obj_path, runtime_obj] output_path (map_dash_l link_libs); if not (link_result == 0) then do { println "ffi_codegen_e2e: clang link failed (missing -l flag for an extern's lib?)"; return (0 - 5) } else do { // Stage 3: run it. Exit code is // `strlen "hello"` -- a real value // round-tripped through the C boundary. let exec_result <- exec_cmd output_path []; exec_cmd "rm" ["-rf", output_dir]; return exec_result } } } }, } }, }}/// `sin : F64 -> F64` must declare as `double @sin(double)`.////// This one needs its own assertion on the emitted TEXT because it is/// invisible to every other check here: `return_llvm_type` failing to/// peel the `Term.pi` chain (a def's `typ` is the FULL pi type, so the/// bare `term_to_llvm_type` hits its `_ =>` i64 fallback) yields/// `declare i64 @sin(double)` -- wrong at the C ABI, but internally/// SELF-CONSISTENT, so `llc` accepts the module, the linker resolves/// `sin` against libm, and the binary runs and exits 5. Verified by/// reverting the pi-peel fix: the E2E test below still passed while/// the declaration was wrong. Only the declared return type shows it.#[test]def test_extern_f64_return_type_declares_double : IO Bool := do { let output_dir := "/tmp/monad_ffi_e2e_" ++ I64.to_string process_id; let src_path := output_dir ++ "/ffi_f64_decl_probe.mo"; exec_cmd "mkdir" ["-p", output_dir]; IO.write_file (Path.path src_path) ffi_fixture_source; let loaded_result <- load_file_modules src_path false; match loaded_result { Result.err e => do { println ("test_extern_f64_return_type_declares_double: failed to load: " ++ e); return false }, Result.ok loaded => do { let mod_result <- compile_loaded_modules_to_ir loaded false; exec_cmd "rm" ["-f", src_path]; match mod_result { Result.err e => do { println ("test_extern_f64_return_type_declares_double: codegen failed: " ++ e); return false }, Result.ok mod_ => do { let ir_text := emit_module mod_; if String.contains ir_text "declare double @sin(double)" then return true else do { println "test_extern_f64_return_type_declares_double: expected `declare double @sin(double)` -- an `i64` return type here means return_llvm_type stopped peeling Term.pi"; return false } }, } }, }}/// The end-to-end assertion: the compiled binary exits with 5, i.e./// `strlen "hello"` really crossed into libc and came back. Covers/// every stage above; a negative return names the stage that broke.#[test]def test_extern_llvm_path_compiles_links_and_runs : IO Bool := do { let code <- compile_ffi_fixture_exit_code; if I64.beq code 5 then return true else do { println ("test_extern_llvm_path_compiles_links_and_runs: expected exit 5, got " ++ I64.to_string code); return false }}/// `[link] libs = ["m"]` in the mote's manifest must reach the linker as/// `-lm`. Asserted directly, not just via the link succeeding: a/// manifest that fails to parse, or a closure walk that misses the/// fixture's own mote, silently yields an EMPTY lib list, and a direct/// check names that cause immediately instead of leaving a bare/// "undefined reference to 'sin'" to be diagnosed by hand.#[test]def test_extern_link_libs_flag : IO Bool := do { let output_dir := "/tmp/monad_ffi_libs_" ++ I64.to_string process_id; let src_path := output_dir ++ "/src/ffi_link_libs_probe.mo"; exec_cmd "mkdir" ["-p", output_dir ++ "/src"]; IO.write_file (Path.path (output_dir ++ "/mote.toml")) ffi_fixture_manifest; IO.write_file (Path.path src_path) ffi_fixture_source; let loaded_result <- load_file_modules src_path false; match loaded_result { Result.err e => do { println ("test_extern_link_libs_flag: failed to load: " ++ e); return false }, Result.ok loaded => do { let link_libs : List String <- collect_link_libs (get_loaded_all loaded); exec_cmd "rm" ["-rf", output_dir]; let flags := map_dash_l link_libs; match flags { List.empty => do { println "test_extern_link_libs_flag: no link libs -- `[link] libs = [\"m\"]` never reached the linker"; return false }, List.cons hd _ => do { if String.beq hd "-lm" then return true else do { println ("test_extern_link_libs_flag: expected -lm, got " ++ hd); return false } }, } }, }}