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
5 changes: 3 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ Windows x64에 툴체인을 고정한 상태입니다.
`no_std` + `extern "C"` Rust를 내보내고 이를 `cargo` cdylib으로 빌드합니다.

**현재 언어.** 타입은 `f64`, `bool`, `i32`, `string` 네 가지입니다. 수치 타입에는 산술(`+`, `-`,
`*`, `/`, 그리고 `i32`의 `%`)과 비교, 그리고 명시적 `as` 변환이 있습니다. `i32`의 나눗셈은
전역(total)이라 `x / 0`은 트랩이 아니라 `0`입니다. `string`은 **파라미터**나 **`-> string!` 반환**에
`*`, `/`, 그리고 `i32`의 `%`)과 비교, 그리고 명시적 `as` 변환이 있습니다. `i32`를 0으로
나누면 트랩이 아니라 `0`이고, 실패 가능(`-> T!`) 함수 안에서 `i32` 결과가 넘치면 wrap하지 않고
예약 status로 실패합니다. `string`은 **파라미터**나 **`-> string!` 반환**에
쓸 수 있고, 연산은 `==`·`!=`(바이트 비교)와 **연결 `+`** 입니다 — 연결 결과는 모듈에 할당자가 없어
`return` 자리에만 놓을 수 있습니다. `i32 as string`으로 숫자를 렌더링합니다. 반환은 **호스트가 준
버퍼**에 씁니다 — 모듈은 할당하지 않습니다. 배열 파라미터는 `xs: [i32]`로 씁니다(원소는 `i32`·`f64`·`bool`).
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ backend emits `no_std`, `extern "C"` Rust and builds it as a `cargo` cdylib.

**The language today.** Four types: `f64`, `bool`, `i32` and `string`. The numeric ones come
with arithmetic (`+`, `-`, `*`, `/`, and `%` on `i32`), comparisons, and an explicit `as`
conversion between them; `i32` division is total, so `x / 0` is `0` rather than a trap.
conversion between them; `i32` division by zero gives `0` rather than a trap, and inside a
fallible (`-> T!`) function an `i32` result that overflows fails with a reserved status instead
of wrapping.
A `string` can be a parameter or a `-> string!` return; it compares with `==` and `!=` on
bytes, and concatenates with `+` — which only a `return` may hold, since the module has no
allocator. `i32 as string` renders a number. Returning a string uses a caller-allocated
Expand Down
7 changes: 7 additions & 0 deletions compiler/src/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ pub const ML_ST_INSUFFICIENT_BUFFER: i32 = -1;

pub const ML_ST_INDEX_OUT_OF_RANGE: i32 = -2;

/// An i32 result — or an `f64 as i32` value — does not exist: the operation overflowed, or the
/// f64 was NaN or truncates outside i32. Returned only from FALLIBLE bodies
/// (`SPEC-checked-arithmetic`); an infallible body has no status channel and keeps wrapping.
/// Its own value rather than `-2`, so a host can tell "outside an array" from "no such value"
/// (DP-O3).
pub const ML_ST_OVERFLOW: i32 = -3;

/// Longest module name the compiler accepts, in ASCII characters.
///
/// The module name is not just a file stem: it becomes the crate name, the C header guard,
Expand Down
56 changes: 56 additions & 0 deletions compiler/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,19 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) {
}
}

/// How a FALLIBLE body leaves when an i32 operation or `f64 as i32` has no value
/// (`SPEC-checked-arithmetic`), or `None` for an infallible body — which has no status channel
/// and keeps wrapping and saturating (DP-O1). The same per-shape choice the `Index` arm makes.
fn overflow_bail(abi: RetAbi) -> Option<String> {
match abi {
RetAbi::Fallible => Some(format!("return Err({});", crate::abi::ML_ST_OVERFLOW)),
RetAbi::StringOut | RetAbi::ArrayOut(_) => {
Some(format!("return {};", crate::abi::ML_ST_OVERFLOW))
}
RetAbi::Plain => None,
}
}

fn emit_expr(e: &IrExpr, abi: RetAbi) -> String {
match &e.kind {
// `xs[i]`, bounds-checked (SPEC-array-input 2.3).
Expand Down Expand Up @@ -874,6 +887,18 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String {
// exactly the semantics the SPEC pins. That agreement is why this lowering is one
// line; it is NOT the reason the semantics were chosen, and a C backend must
// implement them by hand (C casts are UB out of range).
// In a fallible body `f64 as i32` fails where there is no i32 value: NaN, or a value
// that truncates outside i32 (SPEC-checked-arithmetic §2.1). The open interval
// (-2147483649, 2147483648) is exactly what truncation toward zero keeps in range,
// and NaN fails both comparisons.
if *to == IrType::I32 && operand.ty == IrType::F64 {
if let Some(bail) = overflow_bail(abi) {
return format!(
"{{ let __c = {}; if !(__c > -2147483649.0f64 && __c < 2147483648.0f64) {{ {bail} }} __c as i32 }}",
emit_expr(operand, abi)
);
}
}
format!("({} as {})", emit_expr(operand, abi), rust_type(*to))
}
IrExprKind::Unary { op, operand } => {
Expand All @@ -893,6 +918,13 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String {
// i32::MIN`"), and Rust's plain `-` only wraps while `overflow-checks` is off.
// Same reasoning as the `wrapping_*` arms below: put the rule in the code.
if matches!(op, IrUnOp::Neg) && operand.ty == IrType::I32 {
// Checked in a fallible body: `-i32::MIN` has no value (SPEC-checked-arithmetic).
if let Some(bail) = overflow_bail(abi) {
return format!(
"(match ({}).checked_neg() {{ Some(__o) => __o, None => {{ {bail} }} }})",
emit_expr(operand, abi)
);
}
return format!("({}).wrapping_neg()", emit_expr(operand, abi));
}
let sym = match op {
Expand Down Expand Up @@ -940,6 +972,19 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String {
//
// DP-N4 is untouched: a zero divisor still yields the defined `0i32`.
if matches!(op, IrBinOp::Div | IrBinOp::Rem) && lhs.ty == IrType::I32 {
// In a fallible body the one quotient with no value — `i32::MIN / -1`, 2^31 —
// fails (SPEC-checked-arithmetic). `%` does not: `i32::MIN % -1` is 0, which
// exists (DP-O5), so the remainder keeps the template below. Operands are still
// bound left to right before anything is tested.
if matches!(op, IrBinOp::Div) {
if let Some(bail) = overflow_bail(abi) {
return format!(
"{{ let __l = {}; let __d = {}; if __d == 0 {{ 0i32 }} else if __d == -1 && __l == i32::MIN {{ {bail} }} else {{ __l.wrapping_div(__d) }} }}",
emit_expr(lhs, abi),
emit_expr(rhs, abi)
);
}
}
let method = if matches!(op, IrBinOp::Div) {
"wrapping_div"
} else {
Expand Down Expand Up @@ -986,6 +1031,17 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String {
| IrBinOp::And
| IrBinOp::Or => None,
} {
// Checked in a fallible body (SPEC-checked-arithmetic): the same receiver-
// first evaluation, and no value becomes the reserved status instead of a
// wrapped number.
if let Some(bail) = overflow_bail(abi) {
let checked = method.replace("wrapping_", "checked_");
return format!(
"(match ({}).{checked}({}) {{ Some(__o) => __o, None => {{ {bail} }} }})",
emit_expr(lhs, abi),
emit_expr(rhs, abi)
);
}
return format!(
"({}).{method}({})",
emit_expr(lhs, abi),
Expand Down
32 changes: 32 additions & 0 deletions compiler/src/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@ fn can_report_out_of_range(module: &IrModule) -> bool {
.any(|f| crate::ir::can_fail_out_of_range(&f.body))
}

/// 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).
fn can_report_overflow(module: &IrModule) -> bool {
module
.functions
.iter()
.any(|f| f.fallible && crate::ir::can_fail_overflow(&f.body))
}

/// Does this module RETURN any byte outside ASCII?
///
/// Delegates to [`crate::ir::returns_non_ascii_bytes`], which is where it moved when the
Expand Down Expand Up @@ -341,6 +351,20 @@ pub fn emit_c_header(module: &IrModule, dll_name: &str) -> String {
);
let _ = writeln!(s, "#endif");
}
if can_report_overflow(module) {
let _ = writeln!(
s,
"/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value \
(overflow,"
);
let _ = writeln!(
s,
" * or NaN). 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);
let _ = writeln!(s, "#endif");
}
// D17 error codes (module-defined, positive i32). Constants — not exported symbols.
if !module.errors.is_empty() {
for e in &module.errors {
Expand Down Expand Up @@ -823,6 +847,14 @@ pub fn emit_delphi_unit(module: &IrModule, dll_name: &str) -> String {
crate::abi::ML_ST_INDEX_OUT_OF_RANGE
);
}
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). }}"
);
let _ = writeln!(s, " ML_ST_OVERFLOW = {};", crate::abi::ML_ST_OVERFLOW);
}
s.push('\n');
let _ = writeln!(
s,
Expand Down
60 changes: 60 additions & 0 deletions compiler/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,66 @@ pub fn can_fail_out_of_range(body: &[IrStmt]) -> bool {
})
}

/// Can this body return `ML_ST_OVERFLOW` — does it hold a checked operation?
///
/// `SPEC-checked-arithmetic` §2.1: i32 `+ - * /` and unary `-`, and `f64 as i32`. `%` is not
/// here (DP-O5 — `i32::MIN % -1` is 0, which exists). The caller asks only about FALLIBLE
/// bodies: an infallible one lowers the same operators wrapping. Exhaustive over the IR for
/// the reason [`can_fail_out_of_range`] is — a gate that asks a narrower question lets a
/// module return a status its bindings never name (#238).
pub fn can_fail_overflow(body: &[IrStmt]) -> bool {
fn in_expr(e: &IrExpr) -> bool {
match &e.kind {
IrExprKind::Binary { op, lhs, rhs } => {
let checked = lhs.ty == IrType::I32
&& match op {
IrBinOp::Add | IrBinOp::Sub | IrBinOp::Mul | IrBinOp::Div => true,
IrBinOp::Rem
| IrBinOp::Lt
| IrBinOp::Gt
| IrBinOp::Le
| IrBinOp::Ge
| IrBinOp::Eq
| IrBinOp::Ne
| IrBinOp::And
| IrBinOp::Or => false,
};
checked || in_expr(lhs) || in_expr(rhs)
}
IrExprKind::Unary { op, operand } => {
(matches!(op, IrUnOp::Neg) && operand.ty == IrType::I32) || in_expr(operand)
}
IrExprKind::Cast { to, operand } => {
(*to == IrType::I32 && operand.ty == IrType::F64) || in_expr(operand)
}
IrExprKind::ByteLen(operand) => in_expr(operand),
IrExprKind::Index { index, .. } => in_expr(index),
IrExprKind::ByteSlice { s, from, to } => in_expr(s) || in_expr(from) || in_expr(to),
IrExprKind::Fixed { x, places } => in_expr(x) || in_expr(places),
IrExprKind::Call { args, .. } | IrExprKind::Concat(args) => args.iter().any(in_expr),
IrExprKind::Len { .. }
| IrExprKind::ConstF64(_)
| IrExprKind::ConstStr(_)
| IrExprKind::ConstI32(_)
| IrExprKind::ConstBool(_)
| IrExprKind::Var(_) => false,
}
}
body.iter().any(|s| match s {
IrStmt::If { cond, body } | IrStmt::While { cond, body } => {
in_expr(cond) || can_fail_overflow(body)
}
IrStmt::ResultSet { index, value } => in_expr(index) || in_expr(value),
IrStmt::ResultLen(e)
| IrStmt::Return(e)
| IrStmt::Let { value: e, .. }
| IrStmt::Assign { value: e, .. }
| IrStmt::AssignOut { value: e, .. } => in_expr(e),
IrStmt::TryCall { args, .. } => args.iter().any(in_expr),
IrStmt::Fail(_) => false,
})
}

/// 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
20 changes: 16 additions & 4 deletions compiler/tests/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ fn delphi_unit_matches_module_abi() {

/// **Every conditional block in both binding generators, both branches, one table.**
///
/// `header.rs` decides **eleven** things by inspecting the module, and the count is the first
/// `header.rs` decides **twelve** things by inspecting the module — eleven when this test was
/// written; `ML_ST_OVERFLOW` (`SPEC-checked-arithmetic`) is the twelfth — and the count is the first
/// thing this test got wrong: the first draft said eight, covered seven of them, and claimed
/// to cover all. Review counted again. The `.pas` has its own copies of both status constants
/// under their own conditions, and the UTF-8 notice is two emissions, not one.
Expand Down Expand Up @@ -86,6 +87,7 @@ fn each_conditional_binding_block_fires_for_exactly_the_shapes_that_need_it() {
const H_Q12_ARR: &str = "Q12 for an ARRAY return";
const H_INSUF: &str = "ML_ST_INSUFFICIENT_BUFFER";
const H_OOR: &str = "ML_ST_INDEX_OUT_OF_RANGE";
const H_OVF: &str = "#define ML_ST_OVERFLOW";
const H_UTF8: &str = "UTF-8";
// The error-constant block. Module-level (`!module.errors.is_empty()`), so it belongs in
// this matrix — the first draft of the comment below called it per-function and was wrong.
Expand All @@ -111,15 +113,16 @@ fn each_conditional_binding_block_fires_for_exactly_the_shapes_that_need_it() {
"ML_ST_INDEX_OUT_OF_RANGE = {};",
mlc::abi::ML_ST_INDEX_OUT_OF_RANGE
);
let (pas_insuf, pas_oor) = (p_insuf.as_str(), p_oor.as_str());
let p_ovf = format!("ML_ST_OVERFLOW = {};", mlc::abi::ML_ST_OVERFLOW);
let (pas_insuf, pas_oor, pas_ovf) = (p_insuf.as_str(), p_oor.as_str(), p_ovf.as_str());
// Nested inside the `returns_str || returns_array` block: only an ARRAY return gets it.
const P_ELEMENTS: &str = "count ELEMENTS, not bytes, so the";
const P_UTF8: &str = "UTF-8";
const P_ERR: &str = "ML_M_ERR_E_BAD";

let all = [H_Q12_STR, H_Q12_ARR, H_INSUF, H_OOR, H_UTF8, H_ERR];
let all = [H_Q12_STR, H_Q12_ARR, H_INSUF, H_OOR, H_OVF, H_UTF8, H_ERR];
let all_pas = [
P_ARR_RET, P_ARR_PAR, P_UNICODE, pas_insuf, pas_oor, P_ELEMENTS, P_UTF8, P_ERR,
P_ARR_RET, P_ARR_PAR, P_UNICODE, pas_insuf, pas_oor, pas_ovf, P_ELEMENTS, P_UTF8, P_ERR,
];

let cases: &[(&str, &str, &[&str], &[&str])] = &[
Expand Down Expand Up @@ -191,6 +194,15 @@ fn each_conditional_binding_block_fires_for_exactly_the_shapes_that_need_it() {
&[H_ERR],
&[P_ERR],
),
// i32 arithmetic in a FALLIBLE body can have no i32 value, so the overflow status is
// declared. Every other row does arithmetic on nothing, on f64, or in no fallible body,
// and carries the absent half; which operators count is `checked_arithmetic.rs`'s table.
(
"checked arithmetic",
"export fn f(a: i32) -> i32! { return a + 1 }",
&[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
68 changes: 68 additions & 0 deletions compiler/tests/checked_arithmetic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Checked i32 arithmetic in fallible bodies — what the bindings promise (`SPEC-checked-arithmetic`).
//!
//! The values live in `hosts/rust-oracle/tests/checked_arithmetic.rs`. What is here is decided
//! without loading a module: which modules declare `ML_ST_OVERFLOW` (§2.5 — exactly those
//! that can return it, one predicate for both bindings), and that the fingerprint does not
//! move (DP-O4).

use mlc::compile_to_ir;
use mlc::header::{emit_c_header, emit_delphi_unit};
use mlc::iface::fingerprint;

fn declares(src: &str) -> (bool, bool) {
let ir = compile_to_ir(src).expect("compile");
let h = emit_c_header(&ir, "m");
let pas = emit_delphi_unit(&ir, "m");
(
h.contains("#define ML_ST_OVERFLOW (-3)"),
pas.contains("ML_ST_OVERFLOW = -3;"),
)
}

/// **§2.5: the status is declared exactly where it can be returned — and both bindings agree.**
#[test]
fn only_a_module_that_can_overflow_declares_the_status() {
for (src, can) in [
// Every checked operation, each in a fallible body.
("export fn f(a: i32, b: i32) -> i32! { return a + b }", true),
("export fn f(a: i32, b: i32) -> i32! { return a - b }", true),
("export fn f(a: i32, b: i32) -> i32! { return a * b }", true),
("export fn f(a: i32) -> i32! { return -a }", true),
("export fn f(a: i32, b: i32) -> i32! { return a / b }", true),
("export fn f(x: f64) -> i32! { return x as i32 }", true),
// The other two fallible shapes.
("export fn f(a: i32) -> string! { return (a * 2) as string }", true),
(
"export fn f(a: i32) -> [i32]! {\n result 1\n result[0] = a * 2\n}",
true,
),
// An internal fallible helper counts: it is a fallible body too.
(
"fn h(a: i32) -> i32! { return a * 2 }\nexport fn f(a: i32) -> i32! { let x = try h(a) return x }",
true,
),
// Not checked: infallible bodies, `%` (DP-O5), f64 arithmetic, widening casts.
("export fn f(a: i32, b: i32) -> i32 { return a * b }", false),
("export fn f(a: i32, b: i32) -> i32! { return a % b }", false),
("export fn f(x: f64, y: f64) -> f64! { return x * y }", false),
("export fn f(a: i32) -> f64! { return a as f64 }", false),
("error E = 1\nexport fn f(a: i32) -> i32! { if a < 0 { fail E } return a }", false),
] {
let (h, pas) = declares(src);
assert_eq!(h, pas, "the header and the unit disagree about {src:?}");
assert_eq!(h, can, "{src:?} declares ML_ST_OVERFLOW: {h}, expected {can}");
}
}

/// **DP-O4: the fingerprint does not see which reserved negatives a body reaches.**
///
/// The same signature with and without an overflowing operation has one fingerprint — the
/// manifest records signatures and error codes, and a reserved status is the ABI's (D17).
#[test]
fn checking_arithmetic_does_not_move_the_fingerprint() {
let fp = |src: &str| fingerprint(&compile_to_ir(src).expect("compile"));
assert_eq!(
fp("export fn f(a: i32) -> i32! { return a }"),
fp("export fn f(a: i32) -> i32! { return a * 2 }"),
);
}
Loading
Loading