Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 181 additions & 82 deletions compiler/src/codegen.rs

Large diffs are not rendered by default.

30 changes: 19 additions & 11 deletions compiler/src/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,14 @@ fn can_report_out_of_range(module: &IrModule) -> bool {
}

/// Can any FALLIBLE function in this module answer `ML_ST_OVERFLOW`? One predicate for both
/// bindings, for the reason [`can_report_out_of_range`] gives. Infallible bodies are not asked:
/// they keep wrapping and have no status to report it with (SPEC-checked-arithmetic DP-O1).
/// bindings, for the reason [`can_report_out_of_range`] gives — and the same set codegen reads to
/// decide which helpers get a checked copy (SPEC-helper-check-propagation DP-P6).
///
/// It asked only `f.fallible && can_fail_overflow(&f.body)` while the rule was the body's. Once a
/// helper under a `!` caller is checked too, that answered `false` for a module that returns `-3`
/// through the helper: measured on the prototype, both bindings stayed silent.
fn can_report_overflow(module: &IrModule) -> bool {
module
.functions
.iter()
.any(|f| f.fallible && crate::ir::can_fail_overflow(&f.body))
crate::ir::module_can_fail_overflow(module)
}

/// Does this module RETURN any byte outside ASCII?
Expand Down Expand Up @@ -354,12 +355,15 @@ pub fn emit_c_header(module: &IrModule, dll_name: &str) -> String {
if can_report_overflow(module) {
let _ = writeln!(
s,
"/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value \
(overflow,"
"/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in"
);
let _ = writeln!(
s,
" * its own body or in any function it calls -- has no i32 value (overflow, or NaN)."
);
let _ = writeln!(
s,
" * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */"
" * The out-parameter is NOT written (D17); calling again gives the same answer. */"
);
let _ = writeln!(s, "#ifndef ML_ST_OVERFLOW");
let _ = writeln!(s, "#define ML_ST_OVERFLOW ({})", crate::abi::ML_ST_OVERFLOW);
Expand Down Expand Up @@ -850,9 +854,13 @@ pub fn emit_delphi_unit(module: &IrModule, dll_name: &str) -> String {
if can_report_overflow(module) {
let _ = writeln!(
s,
" {{ A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value\n \
(overflow, or NaN). The out-parameter is NOT written (D17). }}"
" {{ i32 arithmetic or an f64-to-i32 conversion that a fallible function executes --"
);
let _ = writeln!(
s,
" in its own body or in any function it calls -- has no i32 value (overflow, or NaN)."
);
let _ = writeln!(s, " The out-parameter is NOT written (D17). }}");
let _ = writeln!(s, " ML_ST_OVERFLOW = {};", crate::abi::ML_ST_OVERFLOW);
}
s.push('\n');
Expand Down
144 changes: 144 additions & 0 deletions compiler/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
//!
//! Every [`IrExpr`] carries its resolved [`IrType`], so the backend never re-infers types.

use std::collections::{BTreeMap, BTreeSet};

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum IrType {
/// Lowered as `*const u8`: borrowed for the call (D16 rule 1), never owned.
Expand Down Expand Up @@ -617,6 +619,148 @@ pub fn can_fail_overflow(body: &[IrStmt]) -> bool {
})
}

/// The functions a body calls in expression position, in order, with repeats.
///
/// Expression-position calls are the only calls to INFALLIBLE functions: calling a fallible
/// one takes a `try` statement, whose callee is not listed here — but whose arguments are
/// walked, because a helper call can sit inside them. Built-in rounders are `Call`s too; they
/// name no module function, so a lookup by name simply misses them.
pub fn expression_callees(body: &[IrStmt]) -> Vec<&str> {
fn in_expr<'a>(e: &'a IrExpr, out: &mut Vec<&'a str>) {
match &e.kind {
IrExprKind::Call { name, args } => {
out.push(name.as_str());
args.iter().for_each(|a| in_expr(a, out));
}
IrExprKind::Concat(args) => args.iter().for_each(|a| in_expr(a, out)),
IrExprKind::Binary { lhs, rhs, .. } => {
in_expr(lhs, out);
in_expr(rhs, out);
}
IrExprKind::Unary { operand, .. }
| IrExprKind::Cast { operand, .. }
| IrExprKind::ByteLen(operand) => in_expr(operand, out),
IrExprKind::Index { index, .. } => in_expr(index, out),
IrExprKind::ByteSlice { s, from, to } => {
in_expr(s, out);
in_expr(from, out);
in_expr(to, out);
}
IrExprKind::Fixed { x, places } => {
in_expr(x, out);
in_expr(places, out);
}
IrExprKind::Len { .. }
| IrExprKind::ConstF64(_)
| IrExprKind::ConstStr(_)
| IrExprKind::ConstI32(_)
| IrExprKind::ConstBool(_)
| IrExprKind::Var(_) => {}
}
}
fn in_stmts<'a>(body: &'a [IrStmt], out: &mut Vec<&'a str>) {
for s in body {
match s {
IrStmt::If { cond, body } | IrStmt::While { cond, body } => {
in_expr(cond, out);
in_stmts(body, out);
}
IrStmt::ResultSet { index, value } => {
in_expr(index, out);
in_expr(value, out);
}
IrStmt::ResultLen(e)
| IrStmt::Return(e)
| IrStmt::Let { value: e, .. }
| IrStmt::Assign { value: e, .. }
| IrStmt::AssignOut { value: e, .. } => in_expr(e, out),
IrStmt::TryCall { args, .. } => args.iter().for_each(|a| in_expr(a, out)),
IrStmt::Fail(_) => {}
}
}
}
let mut out = Vec::new();
in_stmts(body, &mut out);
out
}

/// The infallible functions whose arithmetic a fallible body executes and that can overflow —
/// `SPEC-helper-check-propagation` §2.5 (DP-P6). **One set, three readers**: codegen gives each
/// member a checked copy and calls it from fallible bodies, and both bindings declare
/// `ML_ST_OVERFLOW` through [`module_can_fail_overflow`], which asks this set. Two walks would be
/// two answers, and the prototype measured what that costs: the module returned `-3` through a
/// helper while neither binding named it (#238's defect, one hop further out).
///
/// A member is (1) reached from a fallible body through calls into infallible functions —
/// exported ones included (DP-P2): `export` decides host visibility, not internal meaning — and
/// (2) able to overflow in its own body or in an infallible function it calls. The call graph
/// is acyclic (recursion is rejected, SPEC-calls), so both walks terminate.
pub fn checked_helpers(module: &IrModule) -> BTreeSet<String> {
let infallible: BTreeMap<&str, &IrFunction> = module
.functions
.iter()
.filter(|f| !f.fallible)
.map(|f| (f.name.as_str(), f))
.collect();

// (2) Can this infallible function overflow, itself or below? Memoised over the DAG.
fn can<'a>(
name: &'a str,
infallible: &BTreeMap<&'a str, &'a IrFunction>,
memo: &mut BTreeMap<&'a str, bool>,
) -> bool {
if let Some(&v) = memo.get(name) {
return v;
}
let Some(f) = infallible.get(name) else {
return false;
};
let v = can_fail_overflow(&f.body)
|| expression_callees(&f.body)
.into_iter()
.any(|c| can(c, infallible, memo));
memo.insert(name, v);
v
}

// (1) Everything reachable from a fallible body through infallible callees.
let mut reached: BTreeSet<&str> = BTreeSet::new();
let mut stack: Vec<&str> = module
.functions
.iter()
.filter(|f| f.fallible)
.flat_map(|f| expression_callees(&f.body))
.collect();
while let Some(name) = stack.pop() {
if let Some(f) = infallible.get(name) {
if reached.insert(name) {
stack.extend(expression_callees(&f.body));
}
}
}

let mut memo = BTreeMap::new();
reached
.into_iter()
.filter(|name| can(name, &infallible, &mut memo))
.map(str::to_string)
.collect()
}

/// Can this module return `ML_ST_OVERFLOW`? A fallible body holds a checked operation of its own,
/// or calls a member of [`checked_helpers`] — the only two places `-3` comes from. The bindings'
/// one predicate (SPEC-helper-check-propagation §2.5). A `try` callee needs no walk: it is a
/// fallible function, so its own body is one of the bodies asked.
pub fn module_can_fail_overflow(module: &IrModule) -> bool {
let checked = checked_helpers(module);
module.functions.iter().filter(|f| f.fallible).any(|f| {
can_fail_overflow(&f.body)
|| expression_callees(&f.body)
.into_iter()
.any(|c| checked.contains(c))
})
}

/// Does this body compare a span anywhere? — `SPEC-string-slice-compare` DP-C2.
///
/// The companion to [`first_index`], and it exists for the same stated reason: `check_expr`
Expand Down
7 changes: 4 additions & 3 deletions compiler/src/reserved.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
//! produce invalid output. The frontend rejects such names with a clear error — a single
//! check that protects all backends (WBS hardening).
//!
//! **Exported** function names are safe without this check: they are emitted with the `mlx_`
//! prefix. **Internal** function names are not — since SPEC-calls they are emitted as-is —
//! so `typeck` runs them through here too.
//! **Function** names are safe without this check: every function body is emitted as
//! `ml_fn_<name>` (SPEC-export-wrappers DP-W4, #101), its export adapter as `mlx_<name>`, and a
//! checked copy as `ml_ck_<name>` (SPEC-helper-check-propagation DP-P7) — prefixes no parameter
//! or local may take (see [`generated_prefix`]).
//!
//! Target keywords are only half the problem. codegen also injects its OWN identifiers into
//! the same emitted scope, and those need reserving as well — see [`generated_prefix`].
Expand Down
9 changes: 9 additions & 0 deletions compiler/tests/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ fn each_conditional_binding_block_fires_for_exactly_the_shapes_that_need_it() {
&[H_OVF],
&[pas_ovf],
),
// The same status reached ONLY through a helper: the fallible body does no arithmetic
// of its own. The predicate that looked at that body alone said "absent" here while
// the module returned -3 (SPEC-helper-check-propagation §2.5).
(
"helper overflow",
"fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32! { return sq(x) }",
&[H_OVF],
&[pas_ovf],
),
// A string parameter with a scalar return: only the Delphi note, because only Delphi
// has a way to get this wrong silently (measured 2026-09-10, DP-S2).
(
Expand Down
39 changes: 39 additions & 0 deletions compiler/tests/emit_robustness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,45 @@ fn a_build_ignores_whatever_cargo_variables_are_already_set() {
let _ = std::fs::remove_dir_all(&dir);
}

/// **SPEC-helper-check-propagation acceptance G(2): checked copies leave no dead code.**
///
/// One helper is called from both kinds of body (it needs its plain body AND its checked copy),
/// one only from a fallible body (its plain body would be dead). Built under an ambient
/// `RUSTFLAGS=-D warnings`, any body nobody calls is a hard error. The prototype emitted a copy
/// for every helper and kept every plain body, and `examples/discount4.mls` stopped building this
/// way (measured). The snake_case names are deliberate: a non-snake_case user name trips
/// `non_snake_case` under this flag today, which is recorded in the SPEC §5 and is not this
/// test's subject.
#[cfg(windows)]
#[test]
fn checked_copies_build_under_an_ambient_deny_warnings() {
let dir = common::TempOut::new("ckdw");
let src = dir.join("ckdw.mls");
std::fs::write(
&src,
"fn sq(x: i32) -> i32 { return x * x }\n\
fn cube(x: i32) -> i32 { return x * x * x }\n\
export fn f(x: i32) -> i32! { return sq(x) + cube(x) }\n\
export fn g(x: i32) -> i32 { return sq(x) }\n",
)
.expect("write src");
let out = dir.join("out");
let r = std::process::Command::new(env!("CARGO_BIN_EXE_mlc"))
.args(["build".as_ref(), src.as_os_str()])
.arg("-o")
.arg(&out)
.env("RUSTFLAGS", "-D warnings")
.output()
.expect("run mlc");
assert!(
r.status.success(),
"a module with checked helpers must build under RUSTFLAGS=-D warnings:\n{}\n{}",
String::from_utf8_lossy(&r.stdout),
String::from_utf8_lossy(&r.stderr)
);
assert!(out.join("ckdw.dll").exists(), "missing ckdw.dll");
}

/// A module name has a maximum length, and the boundary is checked from both sides.
///
/// The name becomes the suffix of a reserved export (`ml_iface_hash_<module>`) since
Expand Down
40 changes: 24 additions & 16 deletions compiler/tests/golden/headers.h.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@
#ifndef ML_ST_INDEX_OUT_OF_RANGE
#define ML_ST_INDEX_OUT_OF_RANGE (-2)
#endif
/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down Expand Up @@ -164,8 +165,9 @@ int32_t mlx_add(int32_t /* a */, int32_t /* b */);
#ifndef ML_ST_INDEX_OUT_OF_RANGE
#define ML_ST_INDEX_OUT_OF_RANGE (-2)
#endif
/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down Expand Up @@ -222,8 +224,9 @@ int32_t mlx_in_stock(const int32_t* /* stock */, int32_t /* stock_len */, bool*
#ifndef ML_ST_INDEX_OUT_OF_RANGE
#define ML_ST_INDEX_OUT_OF_RANGE (-2)
#endif
/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down Expand Up @@ -904,8 +907,9 @@ int32_t mlx_negate_if(int32_t /* x */, bool /* flip */);
#ifndef ML_ST_INDEX_OUT_OF_RANGE
#define ML_ST_INDEX_OUT_OF_RANGE (-2)
#endif
/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down Expand Up @@ -975,8 +979,9 @@ int32_t mlx_state_label(int32_t /* state */, char* ml_buf, int32_t ml_cap, int32
* type, parameter order, out-ness or error code does. */
#define ML_PACK_IFACE_HASH 0xDA0077B3A04515ABULL

/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down Expand Up @@ -1089,8 +1094,9 @@ int32_t mlx_line_check(int32_t /* qty */, int32_t* /* tier */, int32_t* out_valu
#define ML_ST_INSUFFICIENT_BUFFER (-1)
#endif

/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down Expand Up @@ -1261,8 +1267,9 @@ int32_t mlx_safe_div(double /* a */, double /* b */, double* out_value);
#ifndef ML_ST_INDEX_OUT_OF_RANGE
#define ML_ST_INDEX_OUT_OF_RANGE (-2)
#endif
/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down Expand Up @@ -1324,8 +1331,9 @@ int32_t mlx_one_payment(int32_t /* principal */, int32_t /* months */, int32_t /
#define ML_ST_INSUFFICIENT_BUFFER (-1)
#endif

/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow,
* or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */
/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in
* its own body or in any function it calls -- has no i32 value (overflow, or NaN).
* The out-parameter is NOT written (D17); calling again gives the same answer. */
#ifndef ML_ST_OVERFLOW
#define ML_ST_OVERFLOW (-3)
#endif
Expand Down
Loading
Loading