iter embedding-abi-m1.1 (DONE 7/7): Embedding-ABI M1 — scalar AILang fn callable from C/Rust

Tasks 4-7 on the Boss-Repaired plan, completing the M1 iteration:

- Task 4: check-side export-signature gate — CheckError variants
  ExportNonScalarSignature / ExportHasEffects (codes
  export-non-scalar-signature / export-has-effects), code() arms,
  check_fn gate (params->ret->effects, first-violation-wins,
  unconditional on f.export.is_some()). The clause-3 discriminator
  in code: effectful/non-scalar exports fail to typecheck. 6/6.
- Task 5: codegen Target::StaticLib — Target enum, threaded param,
  @main/MissingEntryMain gated behind Target::Executable, one
  external @<sym> forwarder per export to @ail_<module>_<fn>
  (fn_scalar_sig/llvm_scalar). In-source missing_entry_main_is_error
  byte-unchanged. Lowering pin 2/2.
- Task 6: CLI ail build --emit=staticlib — clap field, Cmd::Build
  branch, build_staticlib (verbatim build_to prefix + has_export
  guard + two-archive clang -c/ar rcs tail) + run_cmd. CLI pin 2/2.
- Task 7: C-host E2E coherent-stop proof (cc links
  libembed_backtest_step.a + libailang_rt.a, backtest_step(0,3)=9 /
  (9,4)=25, s==25, exit 0) + DESIGN.md §"Embedding ABI (M1)"
  (scalar C ABI, provisional until M3). E2E pin 1/1.

Independent Boss verification: E2E 1/1, gate 6/6, full workspace
0 failures, roundtrip_cli + design_schema_drift + spec_drift green,
Invariant 1 holds (no new dep in core/codegen/runtime). 3 behaviour-
neutral plan pseudo-vs-reality concerns in the journal. Default-exe
codegen/runtime path byte-unchanged (check.py firing = tracked-P2
known-noise, audit-adjudicated). INDEX line added.

Completes the M1 iteration (impl). Milestone-close audit + fieldtest
(surface-touching) are the next pipeline steps.
This commit is contained in:
2026-05-18 14:50:27 +02:00
parent 818177d835
commit e406d07d81
17 changed files with 804 additions and 81 deletions
+120 -3
View File
@@ -137,6 +137,14 @@ enum Cmd {
/// every allocation by design.
#[arg(long, default_value = "rc", value_parser = ["gc", "bump", "rc"])]
alloc: String,
/// Output shape. `exe` (default): whole-program executable
/// (requires an entry `main`). `staticlib`: a relocatable
/// `lib<entry>.a` (program objects only) plus a separate
/// `libailang_rt.a` (RC runtime), with one external C
/// entrypoint per `(export "<sym>")` fn and no `@main`
/// (Embedding ABI M1). With `staticlib`, `-o` is a directory.
#[arg(long, default_value = "exe", value_parser = ["exe", "staticlib"])]
emit: String,
},
/// Build into a tempdir and execute. Exits with the binary's exit code.
/// Convenience wrapper around `build` + invocation of the resulting
@@ -688,10 +696,15 @@ fn main() -> Result<()> {
None => print!("{ir}"),
}
}
Cmd::Build { path, out, opt, alloc } => {
Cmd::Build { path, out, opt, alloc, emit } => {
let strategy = parse_alloc_strategy(&alloc)?;
let bin = build_to(&path, out, &opt, strategy)?;
eprintln!("built {}", bin.display());
if emit == "staticlib" {
let (lib, rt) = build_staticlib(&path, out, &opt, strategy)?;
eprintln!("built {} + {}", lib.display(), rt.display());
} else {
let bin = build_to(&path, out, &opt, strategy)?;
eprintln!("built {}", bin.display());
}
}
Cmd::Run { path, opt, alloc, args } => {
// Iter 9b: build into a fresh tempdir per run, exec, propagate
@@ -2404,6 +2417,110 @@ fn build_to(
Ok(out_bin)
}
/// Embedding-ABI M1: `ail build --emit=staticlib`. Produces
/// `<outdir>/lib<entry>.a` (program objects only) and a separate
/// `<outdir>/libailang_rt.a` (RC runtime: rc.c + str.c). No
/// executable, no `@main`. `out` is the output directory.
fn build_staticlib(
path: &Path,
out: Option<PathBuf>,
opt: &str,
alloc: ailang_codegen::AllocStrategy,
) -> Result<(PathBuf, PathBuf)> {
// ---- shared front matter: identical to build_to:2239-2294 ----
let ws = load_workspace_human(path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
eprintln!(
"{}: [{}] {}{}",
match d.severity {
ailang_check::Severity::Error => "error",
ailang_check::Severity::Warning => "warning",
},
d.code,
d.def.as_ref().map(|n| format!("{n}: ")).unwrap_or_default(),
d.message,
);
}
if diags.iter().any(|d| matches!(d.severity, ailang_check::Severity::Error)) {
std::process::exit(1);
}
}
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.map_err(|e| anyhow::anyhow!("lift_letrecs in module `{mname}`: {e}"))?;
lifted_modules.insert(mname.clone(), lifted);
}
let ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
let ws = ailang_check::monomorphise_workspace(&ws)
.map_err(|e| anyhow::anyhow!("monomorphise_workspace: {e}"))?;
// ---- staticlib-specific: require ≥1 export, then lower ----
let has_export = ws.modules.values().any(|m| m.defs.iter().any(|d|
matches!(d, ailang_core::Def::Fn(f) if f.export.is_some())));
if !has_export {
anyhow::bail!(
"staticlib target needs at least one `(export \"<sym>\")` fn"
);
}
let ir = ailang_codegen::lower_workspace_staticlib_with_alloc(&ws, alloc)?;
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
std::fs::create_dir_all(&tmpdir)?;
let ll_path = tmpdir.join(format!("{}.ll", ws.entry));
std::fs::write(&ll_path, &ir)?;
let outdir = out.unwrap_or_else(|| PathBuf::from("."));
std::fs::create_dir_all(&outdir)?;
// program object → lib<entry>.a (program objects ONLY — no rt)
let prog_obj = tmpdir.join(format!("{}.o", ws.entry));
run_cmd("clang", &[opt, "-c", "-o",
prog_obj.to_str().unwrap(), ll_path.to_str().unwrap()],
"compiling program .ll")?;
let prog_archive = outdir.join(format!("lib{}.a", ws.entry));
let _ = std::fs::remove_file(&prog_archive);
run_cmd("ar", &["rcs", prog_archive.to_str().unwrap(),
prog_obj.to_str().unwrap()], "archiving lib<entry>.a")?;
// RC runtime → libailang_rt.a (rc.c + str.c), separate archive
let rc_src = locate_rc_runtime()?;
let str_src = locate_str_runtime()?;
let rc_obj = tmpdir.join("rc.o");
let str_obj = tmpdir.join("str.o");
run_cmd("clang", &["-O2", "-c", "-o",
rc_obj.to_str().unwrap(), rc_src.to_str().unwrap()],
"compiling runtime/rc.c")?;
run_cmd("clang", &["-O2", "-c", "-o",
str_obj.to_str().unwrap(), str_src.to_str().unwrap()],
"compiling runtime/str.c")?;
let rt_archive = outdir.join("libailang_rt.a");
let _ = std::fs::remove_file(&rt_archive);
run_cmd("ar", &["rcs", rt_archive.to_str().unwrap(),
rc_obj.to_str().unwrap(), str_obj.to_str().unwrap()],
"archiving libailang_rt.a")?;
Ok((prog_archive, rt_archive))
}
/// Run an external command, bailing with stderr on non-zero exit.
fn run_cmd(bin: &str, args: &[&str], ctx: &str) -> Result<()> {
let status = std::process::Command::new(bin)
.args(args).status().context(ctx.to_string())?;
if !status.success() {
anyhow::bail!("{ctx}: `{bin}` exited {status}");
}
Ok(())
}
/// ct.1 migration helper: rewrite bare cross-module Type::Con names
/// and Term::Ctor.type_name to their qualified form. Sets
/// `*changed` to `true` if any rewrite happened. Uses the
+10
View File
@@ -0,0 +1,10 @@
#include <stdint.h>
#include <assert.h>
extern int64_t backtest_step(int64_t state, int64_t sample);
int main(void) {
int64_t s = 0;
s = backtest_step(s, 3); /* 0 + 9 */
s = backtest_step(s, 4); /* 9 + 16 */
assert(s == 25);
return 0;
}
+46
View File
@@ -0,0 +1,46 @@
//! Embedding-ABI M1 coherent-stop proof (spec §"Testing strategy" 5
//! + Acceptance criteria): build `examples/embed_backtest_step.ail`
//! as a staticlib, link the C host against `libembed_backtest_step.a`
//! + `libailang_rt.a`, run it, assert the `s == 25` assertion holds
//! (exit 0). This is the whole-milestone existence proof.
use std::path::PathBuf;
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn manifest() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) }
fn ws_root() -> PathBuf {
manifest().parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn c_host_calls_exported_scalar_kernel() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let host_c = manifest().join("tests").join("embed").join("host.c");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-e2e-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let build = Command::new(ail_bin())
.args(["build", fixture.to_str().unwrap(),
"--emit=staticlib", "-o", outdir.to_str().unwrap()])
.output().expect("ail build --emit=staticlib");
assert!(build.status.success(),
"staticlib build failed: {}",
String::from_utf8_lossy(&build.stderr));
let host_bin = outdir.join("host");
let cc = Command::new("cc")
.arg(&host_c)
.arg(outdir.join("libembed_backtest_step.a"))
.arg(outdir.join("libailang_rt.a"))
.arg("-o").arg(&host_bin)
.output().expect("cc host link");
assert!(cc.status.success(),
"cc link failed: {}", String::from_utf8_lossy(&cc.stderr));
let run = Command::new(&host_bin).output().expect("run host");
assert!(run.status.success(),
"host assertion `s == 25` failed (exit {:?}); stderr={}",
run.status.code(), String::from_utf8_lossy(&run.stderr));
}
+57
View File
@@ -0,0 +1,57 @@
//! Embedding-ABI M1 CLI (spec §5): `ail build --emit=staticlib`
//! produces `lib<entry>.a` (program-only) + a separate
//! `libailang_rt.a`, and emits NO executable / NO `@main`.
use std::path::{Path, PathBuf};
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn ws_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn staticlib_emit_produces_two_archives() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-cli-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let out = Command::new(ail_bin())
.args([
"build", fixture.to_str().unwrap(),
"--emit=staticlib",
"-o", outdir.to_str().unwrap(),
])
.output().expect("ail build --emit=staticlib");
assert!(out.status.success(),
"staticlib build failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr));
assert!(outdir.join("libembed_backtest_step.a").exists(),
"expected libembed_backtest_step.a in {outdir:?}");
assert!(outdir.join("libailang_rt.a").exists(),
"expected separate libailang_rt.a in {outdir:?}");
}
#[test]
fn staticlib_emit_requires_an_export() {
// embed_noentry_baseline has no `(export …)` fn.
let fixture = ws_root().join("examples")
.join("embed_noentry_baseline.ail");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-noexp-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let out = Command::new(ail_bin())
.args(["build", fixture.to_str().unwrap(),
"--emit=staticlib", "-o", outdir.to_str().unwrap()])
.output().expect("ail build");
assert!(!out.status.success(),
"staticlib build with zero exports must fail");
assert!(String::from_utf8_lossy(&out.stderr)
.contains("at least one `(export"),
"expected the zero-export staticlib error; got {}",
String::from_utf8_lossy(&out.stderr));
}
+51
View File
@@ -444,6 +444,20 @@ pub enum CheckError {
#[error("const `{0}` may not have effects (got !{1:?})")]
ConstHasEffects(String, Vec<String>),
/// An `(export …)` fn has a parameter or return type that is not
/// a C-scalar (`Int`/`Float`). M1's embedding ABI is scalar-only;
/// `Bool`/`Str`/every ADT is rejected at typecheck (the
/// feature-acceptance clause-3 discriminator, in code).
/// Code: `export-non-scalar-signature`.
#[error("export `{0}`: M1 embedding ABI is scalar-only — {1} type `{2}` is not `Int`/`Float`")]
ExportNonScalarSignature(String, &'static str, String),
/// An `(export …)` fn has a non-empty effect set. An embedding
/// kernel must be pure (a `(State,Chunk)->State` fold has no
/// effects). Code: `export-has-effects`.
#[error("export `{0}` must be pure — declared effects !{1:?} are not allowed on an embedding boundary")]
ExportHasEffects(String, Vec<String>),
/// A `Type::Con` named a type not in [`Env::types`] or with a wrong
/// arity for a parameterised ADT. Code: `unknown-type`.
#[error("unknown type: `{0}`")]
@@ -695,6 +709,8 @@ impl CheckError {
CheckError::ParamCountMismatch { .. } => "param-count-mismatch",
CheckError::PolymorphicNotSupported(_) => "polymorphic-not-supported",
CheckError::ConstHasEffects(..) => "const-has-effects",
CheckError::ExportNonScalarSignature(..) => "export-non-scalar-signature",
CheckError::ExportHasEffects(..) => "export-has-effects",
CheckError::UnknownType(_) => "unknown-type",
CheckError::UnknownCtor { .. } => "unknown-ctor",
CheckError::UnknownCtorInPattern(_) => "unknown-ctor-in-pattern",
@@ -1895,6 +1911,41 @@ fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<
}
check_type_well_formed(&ret_ty, &env)?;
// Embedding-ABI M1: an `(export …)` fn is the C call boundary.
// Its signature must be scalar-only (`Int`/`Float`) and pure.
// Gate order: params → ret → effects, first violation wins
// (so a combined Str+IO export deterministically fails on the
// signature). Unconditional at check-time — independent of the
// codegen emit mode — this IS the clause-3 discriminator.
if f.export.is_some() {
fn is_c_scalar(t: &Type) -> bool {
matches!(t, Type::Con { name, args, .. }
if args.is_empty() && (name == "Int" || name == "Float"))
}
for p in &param_tys {
if !is_c_scalar(p) {
return Err(CheckError::ExportNonScalarSignature(
f.name.clone(),
"parameter",
ailang_core::pretty::type_to_string(p),
));
}
}
if !is_c_scalar(&ret_ty) {
return Err(CheckError::ExportNonScalarSignature(
f.name.clone(),
"return",
ailang_core::pretty::type_to_string(&ret_ty),
));
}
if !declared_effs.is_empty() {
return Err(CheckError::ExportHasEffects(
f.name.clone(),
declared_effs.clone(),
));
}
}
// mq.3: build the post-superclass-expansion declared-constraint
// set ONCE here and stash on env, so synth's Var-arm class-method
// branch can hand it to `resolve_method_dispatch`'s constraint-
@@ -0,0 +1,63 @@
//! Embedding-ABI M1 export-signature gate (spec §3, clause-3
//! discriminator): an `(export …)` fn whose params/ret are not all
//! `Int`/`Float`, or whose effect set is non-empty, MUST fail
//! `ail check` with the new diagnostic. A scalar+pure export passes.
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap()
.join("examples")
}
fn check_codes(file: &str) -> Vec<String> {
let path = examples_dir().join(file);
let ws = ailang_surface::load_workspace(&path)
.unwrap_or_else(|e| panic!("load {file}: {e}"));
ailang_check::check_workspace(&ws)
.into_iter()
.map(|d| d.code)
.collect()
}
#[test]
fn str_param_export_rejected() {
assert!(check_codes("embed_export_str_param_rejected.ail")
.iter().any(|c| c == "export-non-scalar-signature"));
}
#[test]
fn adt_ret_export_rejected() {
assert!(check_codes("embed_export_adt_ret_rejected.ail")
.iter().any(|c| c == "export-non-scalar-signature"));
}
#[test]
fn io_export_rejected() {
assert!(check_codes("embed_export_io_rejected.ail")
.iter().any(|c| c == "export-has-effects"));
}
#[test]
fn combined_effectful_export_rejected_on_signature_first() {
let codes = check_codes("embed_export_effectful_rejected.ail");
assert!(codes.iter().any(|c| c == "export-non-scalar-signature"),
"combined fixture must fail signature-first; got {codes:?}");
}
#[test]
fn scalar_pure_export_passes() {
let codes = check_codes("embed_export_float_ok.ail");
assert!(!codes.iter().any(|c|
c == "export-non-scalar-signature" || c == "export-has-effects"),
"scalar+pure export must pass the gate; got {codes:?}");
}
#[test]
fn headline_int_export_passes() {
let codes = check_codes("embed_backtest_step.ail");
assert!(!codes.iter().any(|c|
c == "export-non-scalar-signature" || c == "export-has-effects"),
"headline export must pass the gate; got {codes:?}");
}
+111 -19
View File
@@ -162,6 +162,19 @@ pub enum AllocStrategy {
Rc,
}
/// Embedding-ABI M1: codegen output shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// Default: emit the `@main` trampoline; require an entry `main`
/// (`MissingEntryMain` if absent). Whole-program executable.
Executable,
/// `--emit=staticlib`: suppress the `@main` trampoline AND the
/// `MissingEntryMain` shape-check (a kernel module is a library);
/// emit one external C entrypoint per `(export …)` fn forwarding
/// to `@ail_<module>_<fn>`. Provisional signature until M3.
StaticLib,
}
impl AllocStrategy {
/// LLVM IR-level name of the allocator fn (without leading `@`).
fn fn_name(self) -> &'static str {
@@ -221,7 +234,17 @@ pub fn emit_ir(m: &Module) -> Result<String> {
/// `runtime/bump.c`). Used by `ail build --alloc=bump` to quantify the
/// GC's runtime overhead via an A/B comparison.
pub fn lower_workspace_with_alloc(ws: &Workspace, alloc: AllocStrategy) -> Result<String> {
lower_workspace_inner(ws, alloc)
lower_workspace_inner(ws, alloc, Target::Executable)
}
/// Embedding-ABI M1 entry point. Lowers a [`Workspace`] for the
/// static-library target: no `@main`, no `MissingEntryMain`, one
/// external `@<sym>` forwarder per `(export …)` fn.
pub fn lower_workspace_staticlib_with_alloc(
ws: &Workspace,
alloc: AllocStrategy,
) -> Result<String> {
lower_workspace_inner(ws, alloc, Target::StaticLib)
}
/// Multi-module entry point. Lowers an entire [`Workspace`] (entry
@@ -250,10 +273,10 @@ pub fn lower_workspace_with_alloc(ws: &Workspace, alloc: AllocStrategy) -> Resul
/// Use [`emit_ir`] for the single-file shortcut when there are no
/// imports.
pub fn lower_workspace(ws: &Workspace) -> Result<String> {
lower_workspace_inner(ws, AllocStrategy::Gc)
lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable)
}
fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String> {
fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result<String> {
// Iter 16a: desugar every module before any lowering work runs.
// The pass is idempotent and structurally identical to what
// `ailang-check` runs at its public entries, so the codegen
@@ -417,17 +440,20 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
}
// Trampoline: verify that the entry module has a
// `main : () -> Unit !IO`. If not, the workspace isn't runnable.
let entry_module = ws
.modules
.get(&ws.entry)
.ok_or_else(|| CodegenError::Internal(format!("entry module `{}` not in workspace", ws.entry)))?;
let has_main = entry_module
.defs
.iter()
.any(|d| matches!(d, Def::Fn(f) if f.name == "main" && main_is_void(&f.ty)));
if !has_main {
return Err(CodegenError::MissingEntryMain(ws.entry.clone()));
// `main : () -> Unit !IO`. Suppressed for the staticlib target —
// a kernel module is a library and has no `main`.
if target == Target::Executable {
let entry_module = ws
.modules
.get(&ws.entry)
.ok_or_else(|| CodegenError::Internal(format!("entry module `{}` not in workspace", ws.entry)))?;
let has_main = entry_module
.defs
.iter()
.any(|d| matches!(d, Def::Fn(f) if f.name == "main" && main_is_void(&f.ty)));
if !has_main {
return Err(CodegenError::MissingEntryMain(ws.entry.clone()));
}
}
let mut out = String::new();
@@ -551,15 +577,81 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
out.push_str(&header);
out.push_str(&body);
// Trampoline @main → @ail_<entry>_main.
out.push_str(&format!(
"\ndefine i32 @main() {{\n call i8 @ail_{}_main()\n ret i32 0\n}}\n",
ws.entry
));
match target {
Target::Executable => {
// Trampoline @main → @ail_<entry>_main.
out.push_str(&format!(
"\ndefine i32 @main() {{\n call i8 @ail_{}_main()\n ret i32 0\n}}\n",
ws.entry
));
}
Target::StaticLib => {
// One external C entrypoint per `(export …)` fn,
// forwarding to the internal `@ail_<module>_<fn>`.
// The check-side gate (Task 4) guarantees every param
// and the ret are `Int`/`Float`; map Int→i64,
// Float→double. M1 scalar fns have no captured env, so
// the internal callee takes the scalars positionally
// (confirmed against `emit_fn`'s signature emission,
// `lib.rs:1087`).
for (mname, m) in &ws.modules {
for d in &m.defs {
if let Def::Fn(f) = d {
if let Some(sym) = &f.export {
let (param_tys, ret_ty) = match fn_scalar_sig(&f.ty) {
Some(sig) => sig,
None => return Err(CodegenError::Internal(format!(
"export `{sym}`: non-scalar signature reached codegen \
(check-side gate should have rejected it)"))),
};
let cret = llvm_scalar(&ret_ty);
let params: Vec<String> = param_tys.iter().enumerate()
.map(|(i, t)| format!("{} %a{i}", llvm_scalar(t)))
.collect();
let args: Vec<String> = param_tys.iter().enumerate()
.map(|(i, t)| format!("{} %a{i}", llvm_scalar(t)))
.collect();
out.push_str(&format!(
"\ndefine {cret} @{sym}({}) {{\n \
%r = call {cret} @ail_{mname}_{}({})\n \
ret {cret} %r\n}}\n",
params.join(", "),
f.name,
args.join(", "),
));
}
}
}
}
}
}
Ok(out)
}
/// Scalar-signature view of an export fn's type. `None` if not the
/// flat `Type::Fn` of scalars the M1 gate guarantees.
fn fn_scalar_sig(t: &Type) -> Option<(Vec<Type>, Type)> {
let inner = match t {
Type::Forall { body, .. } => body.as_ref(),
other => other,
};
match inner {
Type::Fn { params, ret, .. } =>
Some((params.clone(), (**ret).clone())),
_ => None,
}
}
/// `Int` → `i64`, `Float` → `double` (the only two M1 scalars; the
/// check-side gate rejects everything else before codegen).
fn llvm_scalar(t: &Type) -> &'static str {
match t {
Type::Con { name, .. } if name == "Float" => "double",
_ => "i64",
}
}
fn main_is_void(t: &Type) -> bool {
match t {
Type::Fn { params, ret, .. } => {
@@ -0,0 +1,44 @@
//! Embedding-ABI M1 codegen (spec §4): `Target::StaticLib` lowering
//! emits NO `@main` trampoline, NO `MissingEntryMain`, and one
//! external `define i64 @backtest_step(...)` (the author-chosen C
//! symbol from `(export "backtest_step")`) forwarding to the
//! internal `@ail_embed_backtest_step_step` (module
//! `embed_backtest_step` == file stem, per Design-decision 6).
//! Executable-target lowering is byte-unchanged (the
//! `missing_entry_main_is_error` in-source pin still holds).
use ailang_core::Workspace;
use std::path::PathBuf;
fn load(file: &str) -> Workspace {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap()
.join("examples").join(file);
ailang_surface::load_workspace(&path).unwrap()
}
#[test]
fn staticlib_emits_forwarder_no_main() {
let ws = load("embed_backtest_step.ail");
let ir = ailang_codegen::lower_workspace_staticlib_with_alloc(
&ws, ailang_codegen::AllocStrategy::Rc).unwrap();
assert!(!ir.contains("define i32 @main()"),
"staticlib IR must not emit the @main trampoline");
assert!(ir.contains("@backtest_step("),
"staticlib IR must emit the external @backtest_step entrypoint \
(the author-chosen C symbol — invariant under module rename)");
assert!(ir.contains("@ail_embed_backtest_step_step("),
"the forwarder must call the internal \
@ail_embed_backtest_step_step (module embed_backtest_step)");
}
#[test]
fn executable_target_still_emits_main() {
let ws = load("embed_backtest_step.ail");
// Default executable lowering is unaffected by the new field;
// backtest has no `main`, so exe-target lowering still rejects.
let err = ailang_codegen::lower_workspace_with_alloc(
&ws, ailang_codegen::AllocStrategy::Rc).unwrap_err();
assert!(format!("{err}").contains("has no `main` def"),
"exe target must still surface MissingEntryMain; got {err}");
}